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