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