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