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