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