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