/
/
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 PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features:
378 # The device's own announcement handler is built for exactly this and
379 # overlays the clip on whatever the speaker is playing - including the
380 # stream a linked protocol renders into it (e.g. Sonos audioClip while
381 # the Sonos plays through its AirPlay child), so it always wins.
382 return player
383 if announce_player := self._get_control_target(
384 player,
385 required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
386 require_active=True,
387 ):
388 # No native handler, so the output that is ACTIVELY rendering announces:
389 # the announcement rides the same audio path as the music (mixed into
390 # that live stream, in sync with the rest of the group) instead of a
391 # second mechanism firing beside the playback.
392 return announce_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: a fake mute is simulated with the volume itself,
494 # so it only sticks once the level it hides behind is back in place
495 async with TaskManager(self.mass) as tg:
496 for muted_player in muted_players:
497 tg.create_task(self._set_announcement_mute(muted_player, True))
498
499 async def _play_announcement(
500 self,
501 player: Player,
502 announcement: PlayerMedia,
503 volume_level: int | None = None,
504 ) -> None:
505 """
506 Handle (default/fallback) implementation of the play announcement feature.
507
508 This default implementation will;
509 - stop playback of the current media (if needed)
510 - power on the player (if needed)
511 - raise the volume a bit
512 - play the announcement (from given url)
513 - wait for the player to finish playing
514 - restore the previous power and volume
515 - restore playback (if needed and if possible)
516
517 This default implementation will only be used if the player
518 (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
519 """
520 prev_state = player.state.playback_state
521 # A player without power control has no power state to restore, so it counts as
522 # powered here - otherwise the restore below would be skipped altogether for it,
523 # leaving the player ungrouped from its (sync)group.
524 prev_power = (
525 player.state.power_control == PLAYER_CONTROL_NONE
526 or bool(player.state.powered)
527 or prev_state != PlaybackState.IDLE
528 )
529 prev_synced_to = player.state.synced_to
530 prev_group = (
531 self.get_player(player.state.active_group) if player.state.active_group else None
532 )
533 prev_source = player.state.active_source
534 prev_media = player.state.current_media
535 prev_media_name = prev_media.title or prev_media.uri if prev_media else None
536 # An announcement is transient: a player that is still busy with an earlier
537 # announcement holds no user content, so there is nothing to restore for it.
538 # The raw media attribute is read here (instead of state.current_media, which
539 # reports the active queue item) since it tells what the device is playing.
540 restore_playback = prev_state == PlaybackState.PLAYING and not (
541 player.current_media is not None
542 and player.current_media.media_type == MediaType.ANNOUNCEMENT
543 )
544 # filled while the players are unmuted and the temporary volume is applied below
545 prev_volumes: dict[str, int] = {}
546 prev_muted: set[str] = set()
547 # everything from here on alters the player state, so the restore in the finally
548 # block must run even when the announcement itself fails halfway through
549 try:
550 await self._prepare_for_announcement(
551 player,
552 volume_level=volume_level,
553 prev_state=prev_state,
554 prev_synced_to=prev_synced_to,
555 prev_group=prev_group,
556 prev_media_name=prev_media_name,
557 prev_volumes=prev_volumes,
558 prev_muted=prev_muted,
559 )
560 # play the announcement
561 self.logger.debug(
562 "Announcement to player %s - playing the announcement on the player...",
563 player.state.name,
564 )
565 render = (
566 self.mass.streams.announcement_renderer.get(
567 cast("AnnounceData", announcement.custom_data)
568 )
569 if announcement.custom_data
570 else None
571 )
572 if render is not None and not await render.wait_ready():
573 # the render has been filling while the player was prepared above; play on
574 # regardless when it came up empty, so the restore still runs
575 self.logger.warning(
576 "Announcement to player %s - no audio available for %s",
577 player.state.name,
578 announcement.uri,
579 )
580 await self._handle_play_media(player.player_id, announcement)
581 # wait for the player(s) to play
582 await self._wait_for_playback_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
583 playback_started = time.time()
584 # wait for the player to stop playing
585 duration = float(announcement.duration) if announcement.duration else None
586 if duration is None and render is not None:
587 # the render knows the exact length of the audio it produced
588 duration = await render.wait_finished()
589 if duration:
590 announcement.duration = ceil(duration)
591 if duration is None:
592 # length unknown (e.g. the source stalled): wait for the player to report it
593 # finished, bounded by the longest clip an announcement can produce
594 await self._wait_for_playback_state(
595 player, PlaybackState.IDLE, timeout=MAX_CLIP_SECONDS + 10
596 )
597 else:
598 # waiting for the length above already consumed part of the announcement
599 elapsed = time.time() - playback_started
600 await self._wait_for_playback_state(
601 player,
602 PlaybackState.IDLE,
603 timeout=max(duration + 10 - elapsed, 1),
604 minimal_time=max(duration + 2 - elapsed, 0),
605 )
606 finally:
607 await self._restore_after_announcement(
608 player,
609 prev_power=prev_power,
610 prev_volumes=prev_volumes,
611 prev_muted=prev_muted,
612 prev_synced_to=prev_synced_to,
613 prev_group=prev_group,
614 prev_source=prev_source,
615 prev_media=prev_media,
616 restore_playback=restore_playback,
617 )
618
619 async def _prepare_for_announcement(
620 self,
621 player: Player,
622 *,
623 volume_level: int | None,
624 prev_state: PlaybackState,
625 prev_synced_to: str | None,
626 prev_group: Player | None,
627 prev_media_name: str | None,
628 prev_volumes: dict[str, int],
629 prev_muted: set[str],
630 ) -> None:
631 """
632 Free up the player for an announcement and apply the temporary announcement volume.
633
634 :param player: The player the announcement will be played on.
635 :param volume_level: Optional volume level override for the announcement.
636 :param prev_state: The playback state the player had before the announcement.
637 :param prev_synced_to: Player ID of the sync leader the player is synced to (if any).
638 :param prev_group: The group player the player is a member of (if any).
639 :param prev_media_name: Name of the media the player was playing (for logging).
640 :param prev_volumes: Mapping that is filled in-place with the previous volume level
641 per player id, so the caller can restore the volumes even if this call fails.
642 :param prev_muted: Set that is filled in-place with the ids of the players that were
643 muted, so the caller can restore the mute state even if this call fails.
644 """
645 if prev_synced_to:
646 # ungroup player if its currently synced
647 self.logger.debug(
648 "Announcement to player %s - ungrouping player from %s...",
649 player.state.name,
650 prev_synced_to,
651 )
652 await self.cmd_ungroup(player.player_id)
653 elif prev_group:
654 # if the player is part of a group player, we need to ungroup it
655 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
656 self.logger.debug(
657 "Announcement to player %s - ungrouping from group player %s...",
658 player.state.name,
659 prev_group.display_name,
660 )
661 await prev_group.set_members(player_ids_to_remove=[player.player_id])
662 else:
663 # if the player is part of a group player that does not support ungrouping,
664 # we need to power off the groupplayer instead
665 self.logger.debug(
666 "Announcement to player %s - turning off group player %s...",
667 player.state.name,
668 prev_group.display_name,
669 )
670 await self._handle_cmd_power(prev_group.player_id, False)
671 elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
672 # normal/standalone player: stop player if its currently playing
673 self.logger.debug(
674 "Announcement to player %s - stop existing content (%s)...",
675 player.state.name,
676 prev_media_name,
677 )
678 await self._handle_cmd_stop(player.player_id)
679 # wait for the player to stop
680 await self._wait_for_playback_state(player, PlaybackState.IDLE, 10, 0.4)
681 # unmute and adjust volume if needed
682 # in case of a (sync) group, we need to do this for all child players
683 async with TaskManager(self.mass) as tg:
684 for volume_player_id in player.state.group_members or (player.player_id,):
685 if not (volume_player := self.get_player(volume_player_id)):
686 continue
687 # catch any players that have a different source active
688 if (
689 volume_player.state.active_source
690 not in (
691 player.state.active_source,
692 volume_player.player_id,
693 None,
694 )
695 and volume_player.state.playback_state == PlaybackState.PLAYING
696 ):
697 self.logger.warning(
698 "Detected announcement to playergroup %s while group member %s is playing "
699 "other content, this may lead to unexpected behavior.",
700 player.state.name,
701 volume_player.state.name,
702 )
703 tg.create_task(self._handle_cmd_stop(volume_player.player_id))
704 tg.create_task(
705 self._unmute_and_set_announcement_volume(
706 volume_player, volume_level, prev_volumes, prev_muted
707 )
708 )
709
710 async def _restore_after_announcement(
711 self,
712 player: Player,
713 *,
714 prev_power: bool,
715 prev_volumes: dict[str, int],
716 prev_muted: set[str],
717 prev_synced_to: str | None,
718 prev_group: Player | None,
719 prev_source: str | None,
720 prev_media: PlayerMedia | None,
721 restore_playback: bool,
722 ) -> None:
723 """
724 Restore the player state that was captured before an announcement was played.
725
726 This also runs when the announcement failed halfway through, so a failing restore
727 step is logged instead of raised: it may never mask the error that caused it.
728
729 :param player: The player the announcement was played on.
730 :param prev_power: Whether the player was powered before the announcement.
731 :param prev_volumes: The previous volume level per player id.
732 :param prev_muted: The ids of the players that were muted before the announcement.
733 :param prev_synced_to: Player ID of the sync leader the player was synced to (if any).
734 :param prev_group: The group player the player was a member of (if any).
735 :param prev_source: The source that was active before the announcement.
736 :param prev_media: The media that was loaded before the announcement.
737 :param restore_playback: Whether playback needs to be resumed.
738 """
739 self.logger.debug(
740 "Announcement to player %s - restore previous state...", player.state.name
741 )
742 # restore volume
743 async with TaskManager(self.mass) as tg:
744 for volume_player_id, prev_volume in prev_volumes.items():
745 tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
746 # restore mute after the volume: a fake mute is simulated with the volume itself,
747 # so it only sticks once the level it hides behind is back in place
748 async with TaskManager(self.mass) as tg:
749 for muted_player_id in prev_muted:
750 if not (muted_player := self.get_player(muted_player_id)):
751 continue
752 tg.create_task(self._set_announcement_mute(muted_player, True))
753 await asyncio.sleep(0.2)
754 try:
755 # either power off the player or resume playing
756 if not prev_power:
757 # prev_power is always True for a player without power control,
758 # so there is an actual power control to switch off here
759 self.logger.debug(
760 "Announcement to player %s - turning player off again...", player.state.name
761 )
762 await self._handle_cmd_power(player.player_id, False)
763 return
764 if prev_synced_to:
765 self.logger.debug(
766 "Announcement to player %s - syncing back to %s...",
767 player.state.name,
768 prev_synced_to,
769 )
770 await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
771 elif prev_group:
772 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
773 self.logger.debug(
774 "Announcement to player %s - grouping back to group player %s...",
775 player.state.name,
776 prev_group.display_name,
777 )
778 await prev_group.set_members(player_ids_to_add=[player.player_id])
779 elif restore_playback:
780 # if the player is part of a group player that does not support set_members,
781 # we need to restart the groupplayer
782 self.logger.debug(
783 "Announcement to player %s - restarting playback on group player %s...",
784 player.state.name,
785 prev_group.display_name,
786 )
787 await self.cmd_play(prev_group.player_id)
788 elif restore_playback:
789 # player was playing something before the announcement - try to resume that here
790 await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
791 except Exception as err:
792 # deliberately broad: set_members is a raw provider call that is not wrapped
793 # into a MusicAssistantError, so it can surface anything its client library
794 # raises. CancelledError is a BaseException and still propagates.
795 self.logger.warning(
796 "Announcement to player %s - restoring the previous state failed: %s",
797 player.state.name,
798 err,
799 )
800
801 async def _unmute_and_set_announcement_volume(
802 self,
803 volume_player: Player,
804 volume_level: int | None,
805 prev_volumes: dict[str, int],
806 prev_muted: set[str],
807 ) -> None:
808 """
809 Make a single player ready to be heard: unmute it and set the announcement volume.
810
811 :param volume_player: The player to prepare.
812 :param volume_level: Optional volume level override for the announcement.
813 :param prev_volumes: Mapping that is filled in-place with the previous volume level
814 per player id.
815 :param prev_muted: Set that is filled in-place with the ids of the players that
816 were muted.
817 """
818 if volume_player.state.volume_muted:
819 # an announcement is always meant to be heard, so a deliberate mute is lifted
820 # for its duration. this happens before the volume is read below, since a
821 # fake mute control parks the player at volume 0 to mute it.
822 prev_muted.add(volume_player.player_id)
823 await self._set_announcement_mute(volume_player, False)
824 if volume_player.state.volume_control == PLAYER_CONTROL_NONE:
825 return
826 if (prev_volume := volume_player.state.volume_level) is None:
827 return
828 announcement_volume = self.get_announcement_volume(volume_player.player_id, volume_level)
829 # get_announcement_volume already returns None when the volume must be left
830 # alone, so any number it does return is the volume to announce at - including
831 # 0, which must not be mistaken for 'no volume configured'
832 if announcement_volume is None or announcement_volume == prev_volume:
833 return
834 prev_volumes[volume_player.player_id] = prev_volume
835 self.logger.debug(
836 "Announcement to player %s - setting temporary volume (%s)...",
837 volume_player.state.name,
838 announcement_volume,
839 )
840 await self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
841
842 def _output_owns_volume(self, player: Player, announce_player: Player) -> bool:
843 """
844 Return True if the announcing output can apply the announcement volume itself.
845
846 :param player: The player the announcement is played on.
847 :param announce_player: The player (or linked protocol) that plays the announcement.
848 """
849 volume_control = player.volume_control_for_output(announce_player.player_id)
850 if volume_control == PLAYER_CONTROL_NATIVE:
851 # A native volume lives on the player itself, so only its own output can
852 # apply it: a linked protocol rendering the audio has no way to reach it.
853 return announce_player.player_id == player.player_id
854 if volume_control == announce_player.player_id:
855 # the volume lives on the device the announcing output talks to
856 return True
857 # a bridge player riding on the announcing output forwards its volume to it
858 if control_player := self.get_player(volume_control):
859 return control_player.underlying_player_id == announce_player.player_id
860 return False
861
862 async def _set_announcement_volume(
863 self, player: Player, announcement_volume: int, prev_volumes: dict[str, int]
864 ) -> None:
865 """
866 Apply the announcement volume through the player's own volume control.
867
868 :param player: The player to set the announcement volume on.
869 :param announcement_volume: The resolved announcement volume level.
870 :param prev_volumes: Mapping that is filled in-place with the previous volume level
871 per player id, so the caller can restore the volume even if this call fails.
872 """
873 if player.state.volume_control == PLAYER_CONTROL_NONE:
874 # nothing in the signal path can set a volume at all
875 return
876 if (prev_volume := player.state.volume_level) is None or prev_volume == announcement_volume:
877 return
878 prev_volumes[player.player_id] = prev_volume
879 self.logger.debug(
880 "Announcement to player %s - setting temporary volume (%s)...",
881 player.state.name,
882 announcement_volume,
883 )
884 await self._handle_cmd_volume_set(player.player_id, announcement_volume)
885
886 async def _set_announcement_mute(self, player: Player, muted: bool) -> None:
887 """
888 Mute or unmute a player for the duration of an announcement.
889
890 :param player: The player to mute or unmute.
891 :param muted: bool if the player should be muted.
892 """
893 # the internal handler is used instead of cmd_volume_mute so the player keeps the
894 # mute lock it earned as a group member: the public command clears that lock on
895 # unmute and the player may well be ungrouped by the time it is muted back.
896 mute_control = player.mute_control
897 if mute_control == PLAYER_CONTROL_NONE:
898 return
899 await self._handle_cmd_volume_mute(player, mute_control, muted)
900