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