/
/
/
1"""
2Announcements Mixin for the Player Controller.
3
4Handles playback of announcements (such as TTS messages) on a player: preparing the
5player, handing it the announcement, waiting for it to finish and restoring whatever
6the player was doing before.
7
8This module provides the AnnouncementsMixin class which is inherited by
9PlayerController to add announcement capabilities. The audio itself is rendered and
10served by the streams controller (see controllers/streams/announcements.py).
11"""
12
13from __future__ import annotations
14
15import asyncio
16import logging
17import time
18from math import ceil
19from typing import TYPE_CHECKING, cast
20
21from music_assistant_models.auth import Scope
22from music_assistant_models.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE
23from music_assistant_models.enums import (
24 MediaType,
25 PlaybackState,
26 PlayerFeature,
27 PlayerType,
28)
29from music_assistant_models.errors import PlayerCommandFailed
30from music_assistant_models.player import PlayerMedia
31
32from music_assistant.constants import (
33 ANNOUNCE_ALERT_FILE,
34 ATTR_ANNOUNCEMENT_IN_PROGRESS,
35 CONF_ANNOUNCE_TTS_ENGINE,
36 CONF_ENTRY_ANNOUNCE_VOLUME,
37 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
38 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
39 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
40 CONF_ENTRY_TTS_PRE_ANNOUNCE,
41 CONF_PRE_ANNOUNCE_CHIME_URL,
42)
43from music_assistant.controllers.streams.announcements import MAX_CLIP_SECONDS
44from music_assistant.helpers.api import api_command
45from music_assistant.helpers.plugin_engines import (
46 engine_display_name,
47 get_tts_engines,
48 resolve_tts_engine,
49 select_core_tts_engine,
50)
51from music_assistant.helpers.tts import (
52 query_tts_engine_with_language_fallback,
53 resolve_tts_stream_path,
54)
55from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
56from music_assistant.models.player import Player
57
58from .constants import PlayerLockPurpose
59from .helpers import AnnounceData, handle_player_command
60
61if TYPE_CHECKING:
62 from collections.abc import Iterator
63
64 from music_assistant import MusicAssistant
65
66# the caller waits for this command and it holds the player's playback lock while it runs,
67# so a wedged engine must give up well before the generic (background) engine timeout
68ANNOUNCEMENT_TTS_TIMEOUT = 30
69
70
71class AnnouncementsMixin:
72 """
73 Mixin class providing announcement playback for PlayerController.
74
75 Handles:
76 - Resolving the pre-announce chime and announcement volume from configuration
77 - Forwarding a group announcement to its individual members
78 - Native announcement support (on the player itself or a linked protocol)
79 - The fallback implementation for players without native support
80
81 This mixin expects to be mixed with a class that provides:
82 - mass: MusicAssistant instance
83 - logger: logging.Logger instance
84 - get_player(): method to get a player by ID
85 - iter_group_members(): method to iterate the members of a group player
86 - _get_control_target(): method to resolve the player to send a command to
87 - the _handle_cmd_* / cmd_* playback and grouping commands used below
88 """
89
90 # Type hints for attributes provided by the class this mixin is used with
91 if TYPE_CHECKING:
92 mass: MusicAssistant
93 logger: logging.Logger
94 domain: str
95 _players: dict[str, Player]
96
97 def get_player( # noqa: D102
98 self, player_id: str, raise_unavailable: bool = False
99 ) -> Player | None: ...
100
101 def iter_group_members( # noqa: D102
102 self,
103 group_player: Player,
104 only_powered: bool = False,
105 only_playing: bool = False,
106 active_only: bool = False,
107 exclude_self: bool = True,
108 ) -> Iterator[Player]: ...
109
110 def _get_control_target(
111 self,
112 player: Player,
113 required_feature: PlayerFeature,
114 require_active: bool = False,
115 ) -> Player | None: ...
116
117 async def _wait_for_playback_state(
118 self,
119 player: Player,
120 wanted_state: PlaybackState,
121 timeout: float,
122 minimal_time: float = 0,
123 ) -> None: ...
124
125 async def _handle_play_media(self, player_id: str, media: PlayerMedia) -> None: ...
126
127 async def _handle_cmd_stop(self, player_id: str) -> None: ...
128
129 async def _handle_cmd_volume_set(
130 self, player_id: str, volume_level: int, *, record_target: bool = True
131 ) -> None: ...
132
133 async def _handle_cmd_volume_mute(
134 self, player: Player, mute_control: str, muted: bool
135 ) -> None: ...
136
137 async def _handle_cmd_power(
138 self, player_id: str, powered: bool, skip_auto_play: bool = False
139 ) -> None: ...
140
141 async def _handle_cmd_resume(
142 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
143 ) -> None: ...
144
145 async def cmd_play(self, player_id: str) -> None: ... # noqa: D102
146
147 async def cmd_ungroup(self, player_id: str) -> None: ... # noqa: D102
148
149 async def cmd_set_members( # noqa: D102
150 self,
151 target_player: str,
152 player_ids_to_add: list[str] | None = None,
153 player_ids_to_remove: list[str] | None = None,
154 ) -> None: ...
155
156 # handle_player_command is typed against PlayerController, which this mixin only
157 # becomes once mixed in; the attributes it needs are declared in the block above.
158 # mypy reports that on the outermost decorator, hence the ignore below.
159 @api_command("players/cmd/play_announcement", required_scope=Scope.PLAYERS_CONTROL) # type: ignore[type-var]
160 @handle_player_command(lock=PlayerLockPurpose.PLAYBACK)
161 async def play_announcement(
162 self,
163 player_id: str,
164 url: str | None = None,
165 pre_announce: bool | None = None,
166 volume_level: int | None = None,
167 pre_announce_url: str | None = None,
168 message: str | None = None,
169 tts_engine: str | None = None,
170 language: str | None = None,
171 ) -> None:
172 """
173 Handle playback of an announcement on given player.
174
175 Provide either a url to play or a message to speak, not both.
176
177 :param player_id: Player ID of the player to handle the command.
178 :param url: URL of the announcement to play.
179 :param pre_announce: Optional bool if pre-announce should be used.
180 :param volume_level: Optional volume level to set for the announcement.
181 :param pre_announce_url: Optional custom URL to use for the pre-announce chime.
182 :param message: Text to speak as the announcement, rendered by a TTS engine.
183 :param tts_engine: Optional uid of the TTS engine to speak the message,
184 defaults to the engine configured on the player controller.
185 :param language: Optional language code to speak the message in (e.g. 'nl-NL'),
186 omit to let the engine speak in the language it is configured for.
187 """
188 player = self.get_player(player_id, True)
189 assert player is not None # for type checking
190 if not url and not message:
191 raise PlayerCommandFailed("Either a url or a message is required.")
192 if url and message:
193 raise PlayerCommandFailed("Provide either a url or a message, not both.")
194 if tts_engine and not message:
195 raise PlayerCommandFailed("A tts_engine can only be used to speak a message.")
196 if language and not message:
197 raise PlayerCommandFailed("A language can only be used to speak a message.")
198 if url and not url.startswith("http"):
199 raise PlayerCommandFailed("Only URLs are supported for announcements")
200 if (
201 pre_announce
202 and pre_announce_url
203 and not validate_announcement_chime_url(pre_announce_url)
204 ):
205 raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
206 # a spoken message is rendered up front so everything below - including each member of
207 # a group - plays the resulting audio instead of speaking the text again
208 is_speech = bool(message)
209 if message:
210 url = await self._render_announcement_message(message, tts_engine, language)
211 assert url is not None # for type checking
212 # determine pre-announce from (group)player config
213 if pre_announce is None and (is_speech or "tts" in url):
214 conf_pre_announce = self.mass.config.get_raw_player_config_value(
215 player_id,
216 CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
217 CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
218 )
219 pre_announce = cast("bool", conf_pre_announce)
220 if pre_announce_url is None:
221 if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
222 player_id,
223 CONF_PRE_ANNOUNCE_CHIME_URL,
224 ):
225 # player default custom chime url
226 pre_announce_url = cast("str", conf_pre_announce_url)
227 else:
228 # use global default chime url
229 pre_announce_url = ANNOUNCE_ALERT_FILE
230 announce_data = AnnounceData(
231 announcement_url=url,
232 pre_announce=bool(pre_announce),
233 pre_announce_url=pre_announce_url,
234 # filled in below, once we know which player fetches the stream
235 announce_player_id=None,
236 )
237 # Register right away, so the audio is (nearly always fully) rendered by the time
238 # the player is ready for it. The render is shared by everything that consumes
239 # this announcement, including all members of a group.
240 render = self.mass.streams.announcement_renderer.register(player_id, announce_data)
241 try:
242 # mark announcement_in_progress on player
243 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
244 # if player type is group with all members supporting announcements,
245 # we forward the request to each individual player
246 if player.state.type == PlayerType.GROUP and (
247 all(
248 PlayerFeature.PLAY_ANNOUNCEMENT in x.state.supported_features
249 for x in self.iter_group_members(player)
250 )
251 ):
252 # forward the request to each individual player
253 async with TaskManager(self.mass) as tg:
254 for group_member in player.state.group_members:
255 tg.create_task(
256 self.play_announcement(
257 group_member,
258 url=url,
259 pre_announce=pre_announce,
260 volume_level=volume_level,
261 pre_announce_url=pre_announce_url,
262 )
263 )
264 return
265 self.logger.info(
266 "Playback announcement to player %s (with pre-announce: %s): %s",
267 player.state.name,
268 pre_announce,
269 url,
270 )
271 announce_player = self._resolve_announce_player(player)
272 native_announce_support = announce_player is not None
273 if announce_player is None:
274 announce_player = player
275 # create a PlayerMedia object for the announcement so
276 # we can send a regular play-media call downstream
277 announce_data["announce_player_id"] = (
278 announce_player.player_id if native_announce_support else None
279 )
280 announcement = PlayerMedia(
281 uri=self.mass.streams.get_announcement_url(player_id),
282 media_type=MediaType.ANNOUNCEMENT,
283 title="Announcement",
284 custom_data=dict(announce_data),
285 )
286 # handle native announce support (player or linked protocol)
287 if native_announce_support:
288 # hand the url to the player as soon as there is audio to serve from;
289 # its exact length is resolved further downstream, while it plays
290 if not await render.wait_ready():
291 self.logger.warning(
292 "Announcement to player %s - no audio available for %s",
293 player.state.name,
294 url,
295 )
296 await self._play_native_announcement(
297 player, announce_player, announcement, volume_level
298 )
299 return
300 # use fallback/default implementation
301 await self._play_announcement(player, announcement, volume_level)
302 finally:
303 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
304 await self.mass.streams.announcement_renderer.unregister(player_id, render)
305
306 @api_command("players/tts_engines", required_scope=Scope.PLAYERS_CONTROL)
307 async def get_announcement_tts_engines(self) -> list[dict[str, str]]:
308 """Return the TTS engines that can speak an announcement."""
309 return [
310 {"uid": engine.uid, "name": engine_display_name(engine)}
311 for engine in await get_tts_engines(self.mass)
312 ]
313
314 def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
315 """
316 Get the (player specific) volume for a announcement.
317
318 :param player_id: The player the announcement is played on.
319 :param volume_override: Volume level that overrides the configured strategy.
320 """
321 volume_strategy = self.mass.config.get_raw_player_config_value(
322 player_id,
323 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
324 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
325 )
326 volume_strategy_volume = self.mass.config.get_raw_player_config_value(
327 player_id,
328 CONF_ENTRY_ANNOUNCE_VOLUME.key,
329 CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
330 )
331 if volume_strategy == "none":
332 return None
333 volume_level = volume_override
334 if volume_level is None and volume_strategy == "absolute":
335 volume_level = int(cast("float", volume_strategy_volume))
336 elif volume_level is None and volume_strategy == "relative":
337 if (player := self.get_player(player_id)) and player.state.volume_level is not None:
338 volume_level = int(
339 player.state.volume_level + cast("float", volume_strategy_volume)
340 )
341 elif volume_level is None and volume_strategy == "percentual":
342 if (player := self.get_player(player_id)) and player.state.volume_level is not None:
343 percentual = (player.state.volume_level / 100) * cast(
344 "float", volume_strategy_volume
345 )
346 volume_level = int(player.state.volume_level + percentual)
347 if volume_level is not None:
348 announce_volume_min = cast(
349 "float",
350 self.mass.config.get_raw_player_config_value(
351 player_id,
352 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
353 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
354 ),
355 )
356 volume_level = max(int(announce_volume_min), volume_level)
357 announce_volume_max = cast(
358 "float",
359 self.mass.config.get_raw_player_config_value(
360 player_id,
361 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
362 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
363 ),
364 )
365 volume_level = min(int(announce_volume_max), volume_level)
366 return None if volume_level is None else int(volume_level)
367
368 def _resolve_announce_player(self, player: Player) -> Player | None:
369 """
370 Return the player (or linked protocol) that plays an announcement natively.
371
372 Returns None when nothing in the chain announces natively, so the caller has to
373 fall back to the default implementation.
374
375 :param player: The player the announcement is played on.
376 """
377 if announce_player := self._get_control_target(
378 player,
379 required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
380 require_active=True,
381 ):
382 # The output that is ACTIVELY rendering can announce: the
383 # announcement rides the same audio path as the music (e.g. a
384 # Sonos playing through its AirPlay child gets the clip mixed
385 # into that live stream, in sync with the rest of the group)
386 # instead of a second mechanism firing beside the playback.
387 return announce_player
388 if PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features:
389 # Not playing through an announce-capable output: the player's
390 # own native support (e.g. Sonos audioClip, which overlays the
391 # clip on whatever source is playing) is the next-best path.
392 return player
393 if player.state.playback_state != PlaybackState.PLAYING:
394 # An idle player may announce through any linked protocol. A
395 # PLAYING player deliberately gets no such fallback: routing to
396 # an idle linked protocol (e.g. the AirPlay child of a WiiM
397 # playing natively) would seize the device from the active
398 # output, with nothing restoring that playback afterwards.
399 # The pick is deliberately not published as the active output
400 # protocol: the player would then mirror that protocol's playback
401 # state, report PLAYING for the length of the clip and so fail
402 # the check above on the next announcement.
403 return self._get_control_target(
404 player,
405 required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
406 require_active=False,
407 )
408 return None
409
410 async def _render_announcement_message(
411 self, message: str, tts_engine: str | None, language: str | None
412 ) -> str:
413 """
414 Speak a message through a TTS engine and return the url of the resulting audio.
415
416 :param message: The text to speak.
417 :param tts_engine: Optional uid of the engine to use, defaults to the configured one.
418 :param language: Optional language to speak the message in, omit to let the
419 engine speak in the language it is configured for.
420 """
421 if tts_engine:
422 engine = await resolve_tts_engine(self.mass, tts_engine)
423 if engine is None:
424 raise PlayerCommandFailed(f"TTS engine '{tts_engine}' is not available.")
425 else:
426 engine = await select_core_tts_engine(self.mass, self.domain, CONF_ANNOUNCE_TTS_ENGINE)
427 if engine is None:
428 raise PlayerCommandFailed("No text-to-speech engine is available.")
429 stream_details = await query_tts_engine_with_language_fallback(
430 engine,
431 message,
432 language,
433 timeout=ANNOUNCEMENT_TTS_TIMEOUT,
434 logger=self.logger,
435 )
436 path, _ = await resolve_tts_stream_path(engine, stream_details)
437 if not path.startswith("http"):
438 # a group announcement is forwarded to each member through this same command,
439 # whose url guard only accepts http - so a clip rendered to disk never gets past it
440 raise PlayerCommandFailed(
441 f"TTS engine '{engine.uid}' rendered the message to a local file. "
442 "Announcements need an engine that serves its audio over http."
443 )
444 return path
445
446 async def _play_native_announcement(
447 self,
448 player: Player,
449 announce_player: Player,
450 announcement: PlayerMedia,
451 volume_level: int | None,
452 ) -> None:
453 """
454 Hand an announcement to a player that plays it natively.
455
456 :param player: The player the announcement is played on.
457 :param announce_player: The player (or linked protocol) that plays the announcement.
458 :param announcement: The announcement to play.
459 :param volume_level: Optional volume level override for the announcement.
460 """
461 # an announcement is always meant to be heard, so a deliberate mute is lifted for
462 # its duration. this happens before the announcement volume is resolved below,
463 # since a fake mute control parks the player at volume 0 to mute it.
464 # in case of a (sync) group, this covers all child players.
465 muted_players = [
466 muted_player
467 for member_id in player.state.group_members or (player.player_id,)
468 if (muted_player := self.get_player(member_id)) and muted_player.state.volume_muted
469 ]
470 # filled while the announcement volume is applied below
471 prev_volumes: dict[str, int] = {}
472 try:
473 async with TaskManager(self.mass) as tg:
474 for muted_player in muted_players:
475 tg.create_task(self._set_announcement_mute(muted_player, False))
476 announcement_volume = self.get_announcement_volume(player.player_id, volume_level)
477 if announcement_volume is not None and not self._output_owns_volume(
478 player, announce_player
479 ):
480 # The level is resolved on the scale of the control that owns the player's
481 # volume, so an output that does not own it cannot apply it: whatever that
482 # output sets is either discarded or stacks on top of the control that is
483 # already attenuating on the device. Apply it through the control instead
484 # and let the provider announce at the level the device now plays at.
485 await self._set_announcement_volume(player, announcement_volume, prev_volumes)
486 announcement_volume = None
487 await announce_player.play_announcement(announcement, announcement_volume)
488 finally:
489 # the provider only returns once the announcement finished playing
490 async with TaskManager(self.mass) as tg:
491 for volume_player_id, prev_volume in prev_volumes.items():
492 tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
493 # restore mute after the volume, because setting a volume unmutes the player again
494 async with TaskManager(self.mass) as tg:
495 for muted_player in muted_players:
496 tg.create_task(self._set_announcement_mute(muted_player, True))
497
498 async def _play_announcement(
499 self,
500 player: Player,
501 announcement: PlayerMedia,
502 volume_level: int | None = None,
503 ) -> None:
504 """
505 Handle (default/fallback) implementation of the play announcement feature.
506
507 This default implementation will;
508 - stop playback of the current media (if needed)
509 - power on the player (if needed)
510 - raise the volume a bit
511 - play the announcement (from given url)
512 - wait for the player to finish playing
513 - restore the previous power and volume
514 - restore playback (if needed and if possible)
515
516 This default implementation will only be used if the player
517 (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
518 """
519 prev_state = player.state.playback_state
520 # A player without power control has no power state to restore, so it counts as
521 # powered here - otherwise the restore below would be skipped altogether for it,
522 # leaving the player ungrouped from its (sync)group.
523 prev_power = (
524 player.state.power_control == PLAYER_CONTROL_NONE
525 or bool(player.state.powered)
526 or prev_state != PlaybackState.IDLE
527 )
528 prev_synced_to = player.state.synced_to
529 prev_group = (
530 self.get_player(player.state.active_group) if player.state.active_group else None
531 )
532 prev_source = player.state.active_source
533 prev_media = player.state.current_media
534 prev_media_name = prev_media.title or prev_media.uri if prev_media else None
535 # An announcement is transient: a player that is still busy with an earlier
536 # announcement holds no user content, so there is nothing to restore for it.
537 # The raw media attribute is read here (instead of state.current_media, which
538 # reports the active queue item) since it tells what the device is playing.
539 restore_playback = prev_state == PlaybackState.PLAYING and not (
540 player.current_media is not None
541 and player.current_media.media_type == MediaType.ANNOUNCEMENT
542 )
543 # filled while the players are unmuted and the temporary volume is applied below
544 prev_volumes: dict[str, int] = {}
545 prev_muted: set[str] = set()
546 # everything from here on alters the player state, so the restore in the finally
547 # block must run even when the announcement itself fails halfway through
548 try:
549 await self._prepare_for_announcement(
550 player,
551 volume_level=volume_level,
552 prev_state=prev_state,
553 prev_synced_to=prev_synced_to,
554 prev_group=prev_group,
555 prev_media_name=prev_media_name,
556 prev_volumes=prev_volumes,
557 prev_muted=prev_muted,
558 )
559 # play the announcement
560 self.logger.debug(
561 "Announcement to player %s - playing the announcement on the player...",
562 player.state.name,
563 )
564 render = (
565 self.mass.streams.announcement_renderer.get(
566 cast("AnnounceData", announcement.custom_data)
567 )
568 if announcement.custom_data
569 else None
570 )
571 if render is not None and not await render.wait_ready():
572 # the render has been filling while the player was prepared above; play on
573 # regardless when it came up empty, so the restore still runs
574 self.logger.warning(
575 "Announcement to player %s - no audio available for %s",
576 player.state.name,
577 announcement.uri,
578 )
579 await self._handle_play_media(player.player_id, announcement)
580 # wait for the player(s) to play
581 await self._wait_for_playback_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
582 playback_started = time.time()
583 # wait for the player to stop playing
584 duration = float(announcement.duration) if announcement.duration else None
585 if duration is None and render is not None:
586 # the render knows the exact length of the audio it produced
587 duration = await render.wait_finished()
588 if duration:
589 announcement.duration = ceil(duration)
590 if duration is None:
591 # length unknown (e.g. the source stalled): wait for the player to report it
592 # finished, bounded by the longest clip an announcement can produce
593 await self._wait_for_playback_state(
594 player, PlaybackState.IDLE, timeout=MAX_CLIP_SECONDS + 10
595 )
596 else:
597 # waiting for the length above already consumed part of the announcement
598 elapsed = time.time() - playback_started
599 await self._wait_for_playback_state(
600 player,
601 PlaybackState.IDLE,
602 timeout=max(duration + 10 - elapsed, 1),
603 minimal_time=max(duration + 2 - elapsed, 0),
604 )
605 finally:
606 await self._restore_after_announcement(
607 player,
608 prev_power=prev_power,
609 prev_volumes=prev_volumes,
610 prev_muted=prev_muted,
611 prev_synced_to=prev_synced_to,
612 prev_group=prev_group,
613 prev_source=prev_source,
614 prev_media=prev_media,
615 restore_playback=restore_playback,
616 )
617
618 async def _prepare_for_announcement(
619 self,
620 player: Player,
621 *,
622 volume_level: int | None,
623 prev_state: PlaybackState,
624 prev_synced_to: str | None,
625 prev_group: Player | None,
626 prev_media_name: str | None,
627 prev_volumes: dict[str, int],
628 prev_muted: set[str],
629 ) -> None:
630 """
631 Free up the player for an announcement and apply the temporary announcement volume.
632
633 :param player: The player the announcement will be played on.
634 :param volume_level: Optional volume level override for the announcement.
635 :param prev_state: The playback state the player had before the announcement.
636 :param prev_synced_to: Player ID of the sync leader the player is synced to (if any).
637 :param prev_group: The group player the player is a member of (if any).
638 :param prev_media_name: Name of the media the player was playing (for logging).
639 :param prev_volumes: Mapping that is filled in-place with the previous volume level
640 per player id, so the caller can restore the volumes even if this call fails.
641 :param prev_muted: Set that is filled in-place with the ids of the players that were
642 muted, so the caller can restore the mute state even if this call fails.
643 """
644 if prev_synced_to:
645 # ungroup player if its currently synced
646 self.logger.debug(
647 "Announcement to player %s - ungrouping player from %s...",
648 player.state.name,
649 prev_synced_to,
650 )
651 await self.cmd_ungroup(player.player_id)
652 elif prev_group:
653 # if the player is part of a group player, we need to ungroup it
654 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
655 self.logger.debug(
656 "Announcement to player %s - ungrouping from group player %s...",
657 player.state.name,
658 prev_group.display_name,
659 )
660 await prev_group.set_members(player_ids_to_remove=[player.player_id])
661 else:
662 # if the player is part of a group player that does not support ungrouping,
663 # we need to power off the groupplayer instead
664 self.logger.debug(
665 "Announcement to player %s - turning off group player %s...",
666 player.state.name,
667 prev_group.display_name,
668 )
669 await self._handle_cmd_power(prev_group.player_id, False)
670 elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
671 # normal/standalone player: stop player if its currently playing
672 self.logger.debug(
673 "Announcement to player %s - stop existing content (%s)...",
674 player.state.name,
675 prev_media_name,
676 )
677 await self._handle_cmd_stop(player.player_id)
678 # wait for the player to stop
679 await self._wait_for_playback_state(player, PlaybackState.IDLE, 10, 0.4)
680 # unmute and adjust volume if needed
681 # in case of a (sync) group, we need to do this for all child players
682 async with TaskManager(self.mass) as tg:
683 for volume_player_id in player.state.group_members or (player.player_id,):
684 if not (volume_player := self.get_player(volume_player_id)):
685 continue
686 # catch any players that have a different source active
687 if (
688 volume_player.state.active_source
689 not in (
690 player.state.active_source,
691 volume_player.player_id,
692 None,
693 )
694 and volume_player.state.playback_state == PlaybackState.PLAYING
695 ):
696 self.logger.warning(
697 "Detected announcement to playergroup %s while group member %s is playing "
698 "other content, this may lead to unexpected behavior.",
699 player.state.name,
700 volume_player.state.name,
701 )
702 tg.create_task(self._handle_cmd_stop(volume_player.player_id))
703 tg.create_task(
704 self._unmute_and_set_announcement_volume(
705 volume_player, volume_level, prev_volumes, prev_muted
706 )
707 )
708
709 async def _restore_after_announcement(
710 self,
711 player: Player,
712 *,
713 prev_power: bool,
714 prev_volumes: dict[str, int],
715 prev_muted: set[str],
716 prev_synced_to: str | None,
717 prev_group: Player | None,
718 prev_source: str | None,
719 prev_media: PlayerMedia | None,
720 restore_playback: bool,
721 ) -> None:
722 """
723 Restore the player state that was captured before an announcement was played.
724
725 This also runs when the announcement failed halfway through, so a failing restore
726 step is logged instead of raised: it may never mask the error that caused it.
727
728 :param player: The player the announcement was played on.
729 :param prev_power: Whether the player was powered before the announcement.
730 :param prev_volumes: The previous volume level per player id.
731 :param prev_muted: The ids of the players that were muted before the announcement.
732 :param prev_synced_to: Player ID of the sync leader the player was synced to (if any).
733 :param prev_group: The group player the player was a member of (if any).
734 :param prev_source: The source that was active before the announcement.
735 :param prev_media: The media that was loaded before the announcement.
736 :param restore_playback: Whether playback needs to be resumed.
737 """
738 self.logger.debug(
739 "Announcement to player %s - restore previous state...", player.state.name
740 )
741 # restore volume
742 async with TaskManager(self.mass) as tg:
743 for volume_player_id, prev_volume in prev_volumes.items():
744 tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
745 # restore mute after the volume, because setting a volume unmutes the player again
746 async with TaskManager(self.mass) as tg:
747 for muted_player_id in prev_muted:
748 if not (muted_player := self.get_player(muted_player_id)):
749 continue
750 tg.create_task(self._set_announcement_mute(muted_player, True))
751 await asyncio.sleep(0.2)
752 try:
753 # either power off the player or resume playing
754 if not prev_power:
755 # prev_power is always True for a player without power control,
756 # so there is an actual power control to switch off here
757 self.logger.debug(
758 "Announcement to player %s - turning player off again...", player.state.name
759 )
760 await self._handle_cmd_power(player.player_id, False)
761 return
762 if prev_synced_to:
763 self.logger.debug(
764 "Announcement to player %s - syncing back to %s...",
765 player.state.name,
766 prev_synced_to,
767 )
768 await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
769 elif prev_group:
770 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
771 self.logger.debug(
772 "Announcement to player %s - grouping back to group player %s...",
773 player.state.name,
774 prev_group.display_name,
775 )
776 await prev_group.set_members(player_ids_to_add=[player.player_id])
777 elif restore_playback:
778 # if the player is part of a group player that does not support set_members,
779 # we need to restart the groupplayer
780 self.logger.debug(
781 "Announcement to player %s - restarting playback on group player %s...",
782 player.state.name,
783 prev_group.display_name,
784 )
785 await self.cmd_play(prev_group.player_id)
786 elif restore_playback:
787 # player was playing something before the announcement - try to resume that here
788 await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
789 except Exception as err:
790 # deliberately broad: set_members is a raw provider call that is not wrapped
791 # into a MusicAssistantError, so it can surface anything its client library
792 # raises. CancelledError is a BaseException and still propagates.
793 self.logger.warning(
794 "Announcement to player %s - restoring the previous state failed: %s",
795 player.state.name,
796 err,
797 )
798
799 async def _unmute_and_set_announcement_volume(
800 self,
801 volume_player: Player,
802 volume_level: int | None,
803 prev_volumes: dict[str, int],
804 prev_muted: set[str],
805 ) -> None:
806 """
807 Make a single player ready to be heard: unmute it and set the announcement volume.
808
809 :param volume_player: The player to prepare.
810 :param volume_level: Optional volume level override for the announcement.
811 :param prev_volumes: Mapping that is filled in-place with the previous volume level
812 per player id.
813 :param prev_muted: Set that is filled in-place with the ids of the players that
814 were muted.
815 """
816 if volume_player.state.volume_muted:
817 # an announcement is always meant to be heard, so a deliberate mute is lifted
818 # for its duration. this happens before the volume is read below, since a
819 # fake mute control parks the player at volume 0 to mute it.
820 prev_muted.add(volume_player.player_id)
821 await self._set_announcement_mute(volume_player, False)
822 if volume_player.state.volume_control == PLAYER_CONTROL_NONE:
823 return
824 if (prev_volume := volume_player.state.volume_level) is None:
825 return
826 announcement_volume = self.get_announcement_volume(volume_player.player_id, volume_level)
827 # get_announcement_volume already returns None when the volume must be left
828 # alone, so any number it does return is the volume to announce at - including
829 # 0, which must not be mistaken for 'no volume configured'
830 if announcement_volume is None or announcement_volume == prev_volume:
831 return
832 prev_volumes[volume_player.player_id] = prev_volume
833 self.logger.debug(
834 "Announcement to player %s - setting temporary volume (%s)...",
835 volume_player.state.name,
836 announcement_volume,
837 )
838 await self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
839
840 def _output_owns_volume(self, player: Player, announce_player: Player) -> bool:
841 """
842 Return True if the announcing output can apply the announcement volume itself.
843
844 :param player: The player the announcement is played on.
845 :param announce_player: The player (or linked protocol) that plays the announcement.
846 """
847 volume_control = player.volume_control_for_output(announce_player.player_id)
848 if volume_control == PLAYER_CONTROL_NATIVE:
849 # A native volume lives on the player itself, so only its own output can
850 # apply it: a linked protocol rendering the audio has no way to reach it.
851 return announce_player.player_id == player.player_id
852 if volume_control == announce_player.player_id:
853 # the volume lives on the device the announcing output talks to
854 return True
855 # a bridge player riding on the announcing output forwards its volume to it
856 if control_player := self.get_player(volume_control):
857 return control_player.underlying_player_id == announce_player.player_id
858 return False
859
860 async def _set_announcement_volume(
861 self, player: Player, announcement_volume: int, prev_volumes: dict[str, int]
862 ) -> None:
863 """
864 Apply the announcement volume through the player's own volume control.
865
866 :param player: The player to set the announcement volume on.
867 :param announcement_volume: The resolved announcement volume level.
868 :param prev_volumes: Mapping that is filled in-place with the previous volume level
869 per player id, so the caller can restore the volume even if this call fails.
870 """
871 if player.state.volume_control == PLAYER_CONTROL_NONE:
872 # nothing in the signal path can set a volume at all
873 return
874 if (prev_volume := player.state.volume_level) is None or prev_volume == announcement_volume:
875 return
876 prev_volumes[player.player_id] = prev_volume
877 self.logger.debug(
878 "Announcement to player %s - setting temporary volume (%s)...",
879 player.state.name,
880 announcement_volume,
881 )
882 await self._handle_cmd_volume_set(player.player_id, announcement_volume)
883
884 async def _set_announcement_mute(self, player: Player, muted: bool) -> None:
885 """
886 Mute or unmute a player for the duration of an announcement.
887
888 :param player: The player to mute or unmute.
889 :param muted: bool if the player should be muted.
890 """
891 # the internal handler is used instead of cmd_volume_mute so the player keeps the
892 # mute lock it earned as a group member: the public command clears that lock on
893 # unmute and the player may well be ungrouped by the time it is muted back.
894 mute_control = player.mute_control
895 if mute_control == PLAYER_CONTROL_NONE:
896 return
897 await self._handle_cmd_volume_mute(player, mute_control, muted)
898