/
/
/
1"""
2MusicAssistant PlayerController.
3
4Handles all logic to control supported players,
5which are provided by Player Providers.
6
7Note that the PlayerController has a concept of a 'player' and a 'playerstate'.
8The Player is the actual object that is provided by the provider,
9which incorporates the actual state of the player (e.g. volume, state, etc)
10and functions for controlling the player (e.g. play, pause, etc).
11
12The playerstate is the (final) state of the player, including any user customizations
13and transformations that are applied to the player.
14The playerstate is the object that is exposed to the outside world (via the API).
15"""
16
17from __future__ import annotations
18
19import asyncio
20import functools
21import time
22from collections.abc import Awaitable, Callable, Coroutine
23from contextlib import suppress
24from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, cast, overload
25
26from music_assistant_models.auth import UserRole
27from music_assistant_models.constants import (
28 PLAYER_CONTROL_FAKE,
29 PLAYER_CONTROL_NATIVE,
30 PLAYER_CONTROL_NONE,
31)
32from music_assistant_models.enums import (
33 EventType,
34 MediaType,
35 PlaybackState,
36 PlayerFeature,
37 PlayerType,
38 ProviderFeature,
39 ProviderType,
40)
41from music_assistant_models.errors import (
42 AlreadyRegisteredError,
43 InsufficientPermissions,
44 MusicAssistantError,
45 PlayerCommandFailed,
46 PlayerUnavailableError,
47 ProviderUnavailableError,
48 UnsupportedFeaturedException,
49)
50from music_assistant_models.player_control import PlayerControl # noqa: TC002
51
52from music_assistant.constants import (
53 ANNOUNCE_ALERT_FILE,
54 ATTR_ANNOUNCEMENT_IN_PROGRESS,
55 ATTR_AVAILABLE,
56 ATTR_ELAPSED_TIME,
57 ATTR_ENABLED,
58 ATTR_FAKE_MUTE,
59 ATTR_FAKE_POWER,
60 ATTR_FAKE_VOLUME,
61 ATTR_GROUP_MEMBERS,
62 ATTR_LAST_POLL,
63 ATTR_MUTE_LOCK,
64 ATTR_PREVIOUS_VOLUME,
65 CONF_AUTO_PLAY,
66 CONF_ENTRY_ANNOUNCE_VOLUME,
67 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
68 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
69 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
70 CONF_ENTRY_TTS_PRE_ANNOUNCE,
71 CONF_ENTRY_ZEROCONF_INTERFACES,
72 CONF_PLAYER_DSP,
73 CONF_PLAYERS,
74 CONF_PRE_ANNOUNCE_CHIME_URL,
75 SYNCGROUP_PREFIX,
76)
77from music_assistant.controllers.webserver.helpers.auth_middleware import (
78 get_current_user,
79 get_sendspin_player_id,
80)
81from music_assistant.helpers.api import api_command
82from music_assistant.helpers.tags import async_parse_tags
83from music_assistant.helpers.throttle_retry import Throttler
84from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
85from music_assistant.models.core_controller import CoreController
86from music_assistant.models.player import Player, PlayerMedia, PlayerState
87from music_assistant.models.player_provider import PlayerProvider
88from music_assistant.models.plugin import PluginProvider, PluginSource
89
90from .sync_groups import SyncGroupController, SyncGroupPlayer
91
92if TYPE_CHECKING:
93 from collections.abc import Iterator
94
95 from music_assistant_models.config_entries import (
96 ConfigEntry,
97 ConfigValueType,
98 CoreConfig,
99 PlayerConfig,
100 )
101 from music_assistant_models.player_queue import PlayerQueue
102
103 from music_assistant import MusicAssistant
104
105CACHE_CATEGORY_PLAYER_POWER = 1
106
107
108class AnnounceData(TypedDict):
109 """Announcement data."""
110
111 announcement_url: str
112 pre_announce: bool
113 pre_announce_url: str
114
115
116@overload
117def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
118 func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
119) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: ...
120
121
122@overload
123def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
124 func: None = None,
125 *,
126 lock: bool = False,
127) -> Callable[
128 [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
129 Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
130]: ...
131
132
133def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
134 func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]] | None = None,
135 *,
136 lock: bool = False,
137) -> (
138 Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]
139 | Callable[
140 [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
141 Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
142 ]
143):
144 """Check and log commands to players.
145
146 :param func: The function to wrap (when used without parentheses).
147 :param lock: If True, acquire a lock per player_id and function name before executing.
148 """
149
150 def decorator(
151 fn: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
152 ) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]:
153 @functools.wraps(fn)
154 async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> None:
155 """Log and handle_player_command commands to players."""
156 player_id = kwargs.get("player_id") or args[0]
157 assert isinstance(player_id, str) # for type checking
158 if (player := self._players.get(player_id)) is None or not player.available:
159 # player not existent
160 self.logger.warning(
161 "Ignoring command %s for unavailable player %s",
162 fn.__name__,
163 player_id,
164 )
165 return
166
167 current_user = get_current_user()
168 current_sendspin_player = get_sendspin_player_id()
169 if (
170 current_user
171 and current_user.player_filter
172 and player.player_id not in current_user.player_filter
173 and player.player_id != current_sendspin_player
174 ):
175 msg = (
176 f"{current_user.username} does not have access to player {player.display_name}"
177 )
178 raise InsufficientPermissions(msg)
179
180 self.logger.debug(
181 "Handling command %s for player %s (%s)",
182 fn.__name__,
183 player.display_name,
184 f"by user {current_user.username}" if current_user else "unauthenticated",
185 )
186
187 async def execute() -> None:
188 try:
189 await fn(self, *args, **kwargs)
190 except Exception as err:
191 raise PlayerCommandFailed(str(err)) from err
192
193 if lock:
194 # Acquire a lock specific to player_id and function name
195 lock_key = f"{fn.__name__}_{player_id}"
196 if lock_key not in self._player_command_locks:
197 self._player_command_locks[lock_key] = asyncio.Lock()
198 async with self._player_command_locks[lock_key]:
199 await execute()
200 else:
201 await execute()
202
203 return wrapper
204
205 # Support both @handle_player_command and @handle_player_command(lock=True)
206 if func is not None:
207 return decorator(func)
208 return decorator
209
210
211class PlayerController(CoreController):
212 """Controller holding all logic to control registered players."""
213
214 domain: str = "players"
215
216 def __init__(self, mass: MusicAssistant) -> None:
217 """Initialize core controller."""
218 super().__init__(mass)
219 self._players: dict[str, Player] = {}
220 self._controls: dict[str, PlayerControl] = {}
221 self.manifest.name = "Player Controller"
222 self.manifest.description = (
223 "Music Assistant's core controller which manages all players from all providers."
224 )
225 self.manifest.icon = "speaker-multiple"
226 self._poll_task: asyncio.Task[None] | None = None
227 self._player_throttlers: dict[str, Throttler] = {}
228 self._player_command_locks: dict[str, asyncio.Lock] = {}
229 self._sync_groups: SyncGroupController = SyncGroupController(self)
230
231 async def get_config_entries(
232 self,
233 action: str | None = None,
234 values: dict[str, ConfigValueType] | None = None,
235 ) -> tuple[ConfigEntry, ...]:
236 """Return Config Entries for the Player Controller."""
237 return (CONF_ENTRY_ZEROCONF_INTERFACES,)
238
239 async def setup(self, config: CoreConfig) -> None:
240 """Async initialize of module."""
241 self._poll_task = self.mass.create_task(self._poll_players())
242
243 async def close(self) -> None:
244 """Cleanup on exit."""
245 if self._poll_task and not self._poll_task.done():
246 self._poll_task.cancel()
247
248 async def on_provider_loaded(self, provider: PlayerProvider) -> None:
249 """Handle logic when a provider is loaded."""
250 if ProviderFeature.SYNC_PLAYERS in provider.supported_features:
251 await self._sync_groups.on_provider_loaded(provider)
252
253 async def on_provider_unload(self, provider: PlayerProvider) -> None:
254 """Handle logic when a provider is (about to get) unloaded."""
255 if ProviderFeature.SYNC_PLAYERS in provider.supported_features:
256 await self._sync_groups.on_provider_unload(provider)
257
258 @property
259 def providers(self) -> list[PlayerProvider]:
260 """Return all loaded/running MusicProviders."""
261 return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
262
263 def all(
264 self,
265 return_unavailable: bool = True,
266 return_disabled: bool = False,
267 provider_filter: str | None = None,
268 return_sync_groups: bool = True,
269 ) -> list[Player]:
270 """
271 Return all registered players.
272
273 Note that this applies user filters for players (for non admin users).
274
275 :param return_unavailable [bool]: Include unavailable players.
276 :param return_disabled [bool]: Include disabled players.
277 :param provider_filter [str]: Optional filter by provider lookup key.
278
279 :return: List of Player objects.
280 """
281 current_user = get_current_user()
282 user_filter = (
283 current_user.player_filter
284 if current_user and current_user.role != UserRole.ADMIN
285 else None
286 )
287 current_sendspin_player = get_sendspin_player_id()
288 return [
289 player
290 for player in self._players.values()
291 if (player.available or return_unavailable)
292 and (player.enabled or return_disabled)
293 and (provider_filter is None or player.provider.instance_id == provider_filter)
294 and (
295 not user_filter
296 or player.player_id in user_filter
297 or player.player_id == current_sendspin_player
298 )
299 and (return_sync_groups or not isinstance(player, SyncGroupPlayer))
300 ]
301
302 @api_command("players/all")
303 def all_states(
304 self,
305 return_unavailable: bool = True,
306 return_disabled: bool = False,
307 provider_filter: str | None = None,
308 ) -> list[PlayerState]:
309 """
310 Return PlayerState for all registered players.
311
312 :param return_unavailable [bool]: Include unavailable players.
313 :param return_disabled [bool]: Include disabled players.
314 :param provider_filter [str]: Optional filter by provider lookup key.
315
316 :return: List of PlayerState objects.
317 """
318 return [
319 player.state
320 for player in self.all(
321 return_unavailable=return_unavailable,
322 return_disabled=return_disabled,
323 provider_filter=provider_filter,
324 )
325 ]
326
327 def get(
328 self,
329 player_id: str,
330 raise_unavailable: bool = False,
331 ) -> Player | None:
332 """
333 Return Player by player_id.
334
335 :param player_id [str]: ID of the player.
336 :param raise_unavailable [bool]: Raise if player is unavailable.
337
338 :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
339 :return: Player object or None.
340 """
341 if player := self._players.get(player_id):
342 if (not player.available or not player.enabled) and raise_unavailable:
343 msg = f"Player {player_id} is not available"
344 raise PlayerUnavailableError(msg)
345 return player
346 if raise_unavailable:
347 msg = f"Player {player_id} is not available"
348 raise PlayerUnavailableError(msg)
349 return None
350
351 @api_command("players/get")
352 def get_state(
353 self,
354 player_id: str,
355 raise_unavailable: bool = False,
356 ) -> PlayerState | None:
357 """
358 Return PlayerState by player_id.
359
360 :param player_id [str]: ID of the player.
361 :param raise_unavailable [bool]: Raise if player is unavailable.
362
363 :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
364 :return: Player object or None.
365 """
366 current_user = get_current_user()
367 user_filter = (
368 current_user.player_filter
369 if current_user and current_user.role != UserRole.ADMIN
370 else None
371 )
372 current_sendspin_player = get_sendspin_player_id()
373 if (
374 current_user
375 and user_filter
376 and player_id not in user_filter
377 and player_id != current_sendspin_player
378 ):
379 msg = f"{current_user.username} does not have access to player {player_id}"
380 raise InsufficientPermissions(msg)
381 if player := self.get(player_id, raise_unavailable):
382 return player.state
383 return None
384
385 def get_player_by_name(self, name: str) -> Player | None:
386 """
387 Return Player by name.
388
389 Performs case-insensitive matching against the player's state name
390 (the final name visible in clients and API).
391 If multiple players match, logs a warning and returns the first match.
392
393 :param name: Name of the player.
394 :return: Player object or None.
395 """
396 name_normalized = name.strip().lower()
397 matches: list[Player] = []
398
399 for player in self._players.values():
400 if player.state.name.strip().lower() == name_normalized:
401 matches.append(player)
402
403 if not matches:
404 return None
405
406 if len(matches) > 1:
407 player_ids = [p.player_id for p in matches]
408 self.logger.warning(
409 "players/get_by_name: Multiple players found with name '%s': %s - "
410 "returning first match (%s). "
411 "Consider using the players/get API with player_id instead "
412 "for unambiguous lookups.",
413 name,
414 player_ids,
415 matches[0].player_id,
416 )
417
418 return matches[0]
419
420 @api_command("players/get_by_name")
421 def get_player_state_by_name(self, name: str) -> PlayerState | None:
422 """
423 Return PlayerState by name.
424
425 :param name: Name of the player.
426 :return: PlayerState object or None.
427 """
428 current_user = get_current_user()
429 user_filter = (
430 current_user.player_filter
431 if current_user and current_user.role != UserRole.ADMIN
432 else None
433 )
434 current_sendspin_player = get_sendspin_player_id()
435 if player := self.get_player_by_name(name):
436 if (
437 current_user
438 and user_filter
439 and player.player_id not in user_filter
440 and player.player_id != current_sendspin_player
441 ):
442 msg = f"{current_user.username} does not have access to player {player.player_id}"
443 raise InsufficientPermissions(msg)
444 return player.state
445 return None
446
447 @api_command("players/player_controls")
448 def player_controls(
449 self,
450 ) -> list[PlayerControl]:
451 """Return all registered playercontrols."""
452 return list(self._controls.values())
453
454 @api_command("players/player_control")
455 def get_player_control(
456 self,
457 control_id: str,
458 ) -> PlayerControl | None:
459 """
460 Return PlayerControl by control_id.
461
462 :param control_id: ID of the player control.
463 :return: PlayerControl object or None.
464 """
465 if control := self._controls.get(control_id):
466 return control
467 return None
468
469 @api_command("players/plugin_sources")
470 def get_plugin_sources(self) -> list[PluginSource]:
471 """Return all available plugin sources."""
472 return [
473 plugin_prov.get_source()
474 for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN)
475 if isinstance(plugin_prov, PluginProvider)
476 and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features
477 ]
478
479 @api_command("players/plugin_source")
480 def get_plugin_source(
481 self,
482 source_id: str,
483 ) -> PluginSource | None:
484 """
485 Return PluginSource by source_id.
486
487 :param source_id: ID of the plugin source.
488 :return: PluginSource object or None.
489 """
490 for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN):
491 assert isinstance(plugin_prov, PluginProvider) # for type checking
492 if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features:
493 continue
494 if (source := plugin_prov.get_source()) and source.id == source_id:
495 return source
496 return None
497
498 # Player commands
499
500 @api_command("players/cmd/stop")
501 @handle_player_command
502 async def cmd_stop(self, player_id: str) -> None:
503 """Send STOP command to given player.
504
505 - player_id: player_id of the player to handle the command.
506 """
507 player = self._get_player_with_redirect(player_id)
508 player.mark_stop_called()
509 # Redirect to queue controller if it is active
510 if active_queue := self.get_active_queue(player):
511 await self.mass.player_queues.stop(active_queue.queue_id)
512 else:
513 # handle command on player directly
514 async with self._player_throttlers[player.player_id]:
515 await player.stop()
516
517 @api_command("players/cmd/play")
518 @handle_player_command
519 async def cmd_play(self, player_id: str) -> None:
520 """Send PLAY (unpause) command to given player.
521
522 - player_id: player_id of the player to handle the command.
523 """
524 player = self._get_player_with_redirect(player_id)
525 if player.playback_state == PlaybackState.PLAYING:
526 self.logger.info(
527 "Ignore PLAY request to player %s: player is already playing", player.display_name
528 )
529 return
530
531 # Check if a plugin source is active with a play callback
532 if plugin_source := self._get_active_plugin_source(player):
533 if plugin_source.can_play_pause and plugin_source.on_play:
534 await plugin_source.on_play()
535 return
536
537 if player.playback_state == PlaybackState.PAUSED:
538 # handle command on player/source directly
539 active_source = next(
540 (x for x in player.source_list if x.id == player.active_source), None
541 )
542 if active_source and not active_source.can_play_pause:
543 raise PlayerCommandFailed(
544 "The active source (%s) on player %s does not support play/pause",
545 active_source.name,
546 player.display_name,
547 )
548 async with self._player_throttlers[player.player_id]:
549 await player.play()
550 else:
551 # try to resume the player
552 await self._handle_cmd_resume(player.player_id)
553
554 @api_command("players/cmd/pause")
555 @handle_player_command
556 async def cmd_pause(self, player_id: str) -> None:
557 """Send PAUSE command to given player.
558
559 - player_id: player_id of the player to handle the command.
560 """
561 player = self._get_player_with_redirect(player_id)
562
563 # Check if a plugin source is active with a pause callback
564 if plugin_source := self._get_active_plugin_source(player):
565 if plugin_source.can_play_pause and plugin_source.on_pause:
566 await plugin_source.on_pause()
567 return
568
569 # Redirect to queue controller if it is active
570 if active_queue := self.get_active_queue(player):
571 await self.mass.player_queues.pause(active_queue.queue_id)
572 return
573
574 # handle command on player/source directly
575 active_source = next((x for x in player.source_list if x.id == player.active_source), None)
576 if active_source and not active_source.can_play_pause:
577 raise PlayerCommandFailed(
578 "The active source (%s) on player %s does not support play/pause",
579 active_source.name,
580 player.display_name,
581 )
582 if PlayerFeature.PAUSE not in player.supported_features:
583 # if player does not support pause, we need to send stop
584 self.logger.debug(
585 "Player %s does not support pause, using STOP instead",
586 player.display_name,
587 )
588 await self.cmd_stop(player.player_id)
589 return
590 # handle command on player directly
591 await player.pause()
592
593 @api_command("players/cmd/play_pause")
594 async def cmd_play_pause(self, player_id: str) -> None:
595 """Toggle play/pause on given player.
596
597 - player_id: player_id of the player to handle the command.
598 """
599 player = self._get_player_with_redirect(player_id)
600 if player.playback_state == PlaybackState.PLAYING:
601 await self.cmd_pause(player.player_id)
602 else:
603 await self.cmd_play(player.player_id)
604
605 @api_command("players/cmd/resume")
606 @handle_player_command
607 async def cmd_resume(
608 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
609 ) -> None:
610 """Send RESUME command to given player.
611
612 Resume (or restart) playback on the player.
613
614 :param player_id: player_id of the player to handle the command.
615 :param source: Optional source to resume.
616 :param media: Optional media to resume.
617 """
618 await self._handle_cmd_resume(player_id, source, media)
619
620 @api_command("players/cmd/seek")
621 async def cmd_seek(self, player_id: str, position: int) -> None:
622 """Handle SEEK command for given player.
623
624 - player_id: player_id of the player to handle the command.
625 - position: position in seconds to seek to in the current playing item.
626 """
627 player = self._get_player_with_redirect(player_id)
628
629 # Check if a plugin source is active with a seek callback
630 if plugin_source := self._get_active_plugin_source(player):
631 if plugin_source.can_seek and plugin_source.on_seek:
632 await plugin_source.on_seek(position)
633 return
634
635 # Redirect to queue controller if it is active
636 if active_queue := self.get_active_queue(player):
637 await self.mass.player_queues.seek(active_queue.queue_id, position)
638 return
639
640 # handle command on player/source directly
641 active_source = next((x for x in player.source_list if x.id == player.active_source), None)
642 if active_source and not active_source.can_seek:
643 raise PlayerCommandFailed(
644 "The active source (%s) on player %s does not support seeking",
645 active_source.name,
646 player.display_name,
647 )
648 if PlayerFeature.SEEK not in player.supported_features:
649 msg = f"Player {player.display_name} does not support seeking"
650 raise UnsupportedFeaturedException(msg)
651 # handle command on player directly
652 await player.seek(position)
653
654 @api_command("players/cmd/next")
655 async def cmd_next_track(self, player_id: str) -> None:
656 """Handle NEXT TRACK command for given player."""
657 player = self._get_player_with_redirect(player_id)
658 active_source_id = player.active_source or player.player_id
659
660 # Check if a plugin source is active with a next callback
661 if plugin_source := self._get_active_plugin_source(player):
662 if plugin_source.can_next_previous and plugin_source.on_next:
663 await plugin_source.on_next()
664 return
665
666 # Redirect to queue controller if it is active
667 if active_queue := self.get_active_queue(player):
668 await self.mass.player_queues.next(active_queue.queue_id)
669 return
670
671 if PlayerFeature.NEXT_PREVIOUS in player.supported_features:
672 # player has some other source active and native next/previous support
673 active_source = next((x for x in player.source_list if x.id == active_source_id), None)
674 if active_source and active_source.can_next_previous:
675 await player.next_track()
676 return
677 msg = "This action is (currently) unavailable for this source."
678 raise PlayerCommandFailed(msg)
679
680 msg = f"Player {player.display_name} does not support skipping to the next track."
681 raise UnsupportedFeaturedException(msg)
682
683 @api_command("players/cmd/previous")
684 async def cmd_previous_track(self, player_id: str) -> None:
685 """Handle PREVIOUS TRACK command for given player."""
686 player = self._get_player_with_redirect(player_id)
687 active_source_id = player.active_source or player.player_id
688
689 # Check if a plugin source is active with a previous callback
690 if plugin_source := self._get_active_plugin_source(player):
691 if plugin_source.can_next_previous and plugin_source.on_previous:
692 await plugin_source.on_previous()
693 return
694
695 # Redirect to queue controller if it is active
696 if active_queue := self.get_active_queue(player):
697 await self.mass.player_queues.previous(active_queue.queue_id)
698 return
699
700 if PlayerFeature.NEXT_PREVIOUS in player.supported_features:
701 # player has some other source active and native next/previous support
702 active_source = next((x for x in player.source_list if x.id == active_source_id), None)
703 if active_source and active_source.can_next_previous:
704 await player.previous_track()
705 return
706 msg = "This action is (currently) unavailable for this source."
707 raise PlayerCommandFailed(msg)
708
709 msg = f"Player {player.display_name} does not support skipping to the previous track."
710 raise UnsupportedFeaturedException(msg)
711
712 @api_command("players/cmd/power")
713 @handle_player_command
714 async def cmd_power(self, player_id: str, powered: bool) -> None:
715 """Send POWER command to given player.
716
717 :param player_id: player_id of the player to handle the command.
718 :param powered: bool if player should be powered on or off.
719 """
720 await self._handle_cmd_power(player_id, powered)
721
722 @api_command("players/cmd/volume_set")
723 @handle_player_command
724 async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
725 """Send VOLUME_SET command to given player.
726
727 :param player_id: player_id of the player to handle the command.
728 :param volume_level: volume level (0..100) to set on the player.
729 """
730 await self._handle_cmd_volume_set(player_id, volume_level)
731
732 @api_command("players/cmd/volume_up")
733 @handle_player_command
734 async def cmd_volume_up(self, player_id: str) -> None:
735 """Send VOLUME_UP command to given player.
736
737 - player_id: player_id of the player to handle the command.
738 """
739 if not (player := self.get(player_id)):
740 return
741 current_volume = player.volume_level or 0
742 if current_volume < 5 or current_volume > 95:
743 step_size = 1
744 elif current_volume < 20 or current_volume > 80:
745 step_size = 2
746 else:
747 step_size = 5
748 new_volume = min(100, current_volume + step_size)
749 await self.cmd_volume_set(player_id, new_volume)
750
751 @api_command("players/cmd/volume_down")
752 @handle_player_command
753 async def cmd_volume_down(self, player_id: str) -> None:
754 """Send VOLUME_DOWN command to given player.
755
756 - player_id: player_id of the player to handle the command.
757 """
758 if not (player := self.get(player_id)):
759 return
760 current_volume = player.volume_level or 0
761 if current_volume < 5 or current_volume > 95:
762 step_size = 1
763 elif current_volume < 20 or current_volume > 80:
764 step_size = 2
765 else:
766 step_size = 5
767 new_volume = max(0, current_volume - step_size)
768 await self.cmd_volume_set(player_id, new_volume)
769
770 @api_command("players/cmd/group_volume")
771 @handle_player_command
772 async def cmd_group_volume(
773 self,
774 player_id: str,
775 volume_level: int,
776 ) -> None:
777 """
778 Handle adjusting the overall/group volume to a playergroup (or synced players).
779
780 Will set a new (overall) volume level to a group player or syncgroup.
781
782 :param group_player: dedicated group player or syncleader to handle the command.
783 :param volume_level: volume level (0..100) to set to the group.
784 """
785 player = self.get(player_id, True)
786 assert player is not None # for type checker
787 if player.type == PlayerType.GROUP or player.group_members:
788 # dedicated group player or sync leader
789 await self.set_group_volume(player, volume_level)
790 return
791 if player.synced_to and (sync_leader := self.get(player.synced_to)):
792 # redirect to sync leader
793 await self.set_group_volume(sync_leader, volume_level)
794 return
795 # treat as normal player volume change
796 await self.cmd_volume_set(player_id, volume_level)
797
798 @api_command("players/cmd/group_volume_up")
799 @handle_player_command
800 async def cmd_group_volume_up(self, player_id: str) -> None:
801 """Send VOLUME_UP command to given playergroup.
802
803 - player_id: player_id of the player to handle the command.
804 """
805 group_player = self.get(player_id, True)
806 assert group_player
807 cur_volume = group_player.group_volume
808 if cur_volume < 5 or cur_volume > 95:
809 step_size = 1
810 elif cur_volume < 20 or cur_volume > 80:
811 step_size = 2
812 else:
813 step_size = 5
814 new_volume = min(100, cur_volume + step_size)
815 await self.cmd_group_volume(player_id, new_volume)
816
817 @api_command("players/cmd/group_volume_down")
818 @handle_player_command
819 async def cmd_group_volume_down(self, player_id: str) -> None:
820 """Send VOLUME_DOWN command to given playergroup.
821
822 - player_id: player_id of the player to handle the command.
823 """
824 group_player = self.get(player_id, True)
825 assert group_player
826 cur_volume = group_player.group_volume
827 if cur_volume < 5 or cur_volume > 95:
828 step_size = 1
829 elif cur_volume < 20 or cur_volume > 80:
830 step_size = 2
831 else:
832 step_size = 5
833 new_volume = max(0, cur_volume - step_size)
834 await self.cmd_group_volume(player_id, new_volume)
835
836 @api_command("players/cmd/group_volume_mute")
837 @handle_player_command
838 async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
839 """Send VOLUME_MUTE command to all players in a group.
840
841 - player_id: player_id of the group player or sync leader.
842 - muted: bool if group should be muted.
843 """
844 player = self.get(player_id, True)
845 assert player is not None # for type checker
846 if player.type == PlayerType.GROUP or player.group_members:
847 # dedicated group player or sync leader
848 coros = []
849 for child_player in self.iter_group_members(
850 player, only_powered=True, exclude_self=False
851 ):
852 coros.append(self.cmd_volume_mute(child_player.player_id, muted))
853 await asyncio.gather(*coros)
854
855 @api_command("players/cmd/volume_mute")
856 @handle_player_command
857 async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
858 """Send VOLUME_MUTE command to given player.
859
860 - player_id: player_id of the player to handle the command.
861 - muted: bool if player should be muted.
862 """
863 player = self.get(player_id, True)
864 assert player
865
866 # Set/clear mute lock for players in a group
867 # This prevents auto-unmute when group volume changes
868 is_in_group = bool(player.synced_to or player.group_members)
869 if muted and is_in_group:
870 player.extra_data[ATTR_MUTE_LOCK] = True
871 elif not muted:
872 player.extra_data.pop(ATTR_MUTE_LOCK, None)
873
874 if player.mute_control == PLAYER_CONTROL_NONE:
875 raise UnsupportedFeaturedException(
876 f"Player {player.display_name} does not support muting"
877 )
878 if player.mute_control == PLAYER_CONTROL_NATIVE:
879 # player supports mute command natively: forward to player
880 async with self._player_throttlers[player_id]:
881 await player.volume_mute(muted)
882 elif player.mute_control == PLAYER_CONTROL_FAKE:
883 # user wants to use fake mute control - so we use volume instead
884 self.logger.debug(
885 "Using volume for muting for player %s",
886 player.display_name,
887 )
888 if muted:
889 player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_level
890 player.extra_data[ATTR_FAKE_MUTE] = True
891 await self._handle_cmd_volume_set(player_id, 0)
892 player.update_state()
893 else:
894 prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
895 player.extra_data[ATTR_FAKE_MUTE] = False
896 player.update_state()
897 await self._handle_cmd_volume_set(player_id, prev_volume)
898 else:
899 # handle external player control
900 player_control = self._controls.get(player.mute_control)
901 control_name = player_control.name if player_control else player.mute_control
902 self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
903 if not player_control or not player_control.supports_mute:
904 raise UnsupportedFeaturedException(
905 f"Player control {control_name} is not available"
906 )
907 async with self._player_throttlers[player_id]:
908 assert player_control.mute_set is not None
909 await player_control.mute_set(muted)
910
911 @api_command("players/cmd/play_announcement")
912 @handle_player_command(lock=True)
913 async def play_announcement(
914 self,
915 player_id: str,
916 url: str,
917 pre_announce: bool | None = None,
918 volume_level: int | None = None,
919 pre_announce_url: str | None = None,
920 ) -> None:
921 """
922 Handle playback of an announcement (url) on given player.
923
924 - player_id: player_id of the player to handle the command.
925 - url: URL of the announcement to play.
926 - pre_announce: optional bool if pre-announce should be used.
927 - volume_level: optional volume level to set for the announcement.
928 - pre_announce_url: optional custom URL to use for the pre-announce chime.
929 """
930 player = self.get(player_id, True)
931 assert player is not None # for type checking
932 if not url.startswith("http"):
933 raise PlayerCommandFailed("Only URLs are supported for announcements")
934 if (
935 pre_announce
936 and pre_announce_url
937 and not validate_announcement_chime_url(pre_announce_url)
938 ):
939 raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
940 try:
941 # mark announcement_in_progress on player
942 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
943 # determine if the player has native announcements support
944 native_announce_support = PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
945 # determine pre-announce from (group)player config
946 if pre_announce is None and "tts" in url:
947 conf_pre_announce = self.mass.config.get_raw_player_config_value(
948 player_id,
949 CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
950 CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
951 )
952 pre_announce = cast("bool", conf_pre_announce)
953 if pre_announce_url is None:
954 if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
955 player_id,
956 CONF_PRE_ANNOUNCE_CHIME_URL,
957 ):
958 # player default custom chime url
959 pre_announce_url = cast("str", conf_pre_announce_url)
960 else:
961 # use global default chime url
962 pre_announce_url = ANNOUNCE_ALERT_FILE
963 # if player type is group with all members supporting announcements,
964 # we forward the request to each individual player
965 if player.type == PlayerType.GROUP and (
966 all(
967 PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features
968 for x in self.iter_group_members(player)
969 )
970 ):
971 # forward the request to each individual player
972 async with TaskManager(self.mass) as tg:
973 for group_member in player.group_members:
974 tg.create_task(
975 self.play_announcement(
976 group_member,
977 url=url,
978 pre_announce=pre_announce,
979 volume_level=volume_level,
980 pre_announce_url=pre_announce_url,
981 )
982 )
983 return
984 self.logger.info(
985 "Playback announcement to player %s (with pre-announce: %s): %s",
986 player.display_name,
987 pre_announce,
988 url,
989 )
990 # create a PlayerMedia object for the announcement so
991 # we can send a regular play-media call downstream
992 announce_data = AnnounceData(
993 announcement_url=url,
994 pre_announce=bool(pre_announce),
995 pre_announce_url=pre_announce_url,
996 )
997 announcement = PlayerMedia(
998 uri=self.mass.streams.get_announcement_url(player_id, announce_data=announce_data),
999 media_type=MediaType.ANNOUNCEMENT,
1000 title="Announcement",
1001 custom_data=dict(announce_data),
1002 )
1003 # handle native announce support
1004 if native_announce_support:
1005 announcement_volume = self.get_announcement_volume(player_id, volume_level)
1006 await player.play_announcement(announcement, announcement_volume)
1007 return
1008 # use fallback/default implementation
1009 await self._play_announcement(player, announcement, volume_level)
1010 finally:
1011 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
1012
1013 @handle_player_command(lock=True)
1014 async def play_media(self, player_id: str, media: PlayerMedia) -> None:
1015 """Handle PLAY MEDIA on given player.
1016
1017 - player_id: player_id of the player to handle the command.
1018 - media: The Media that needs to be played on the player.
1019 """
1020 player = self._get_player_with_redirect(player_id)
1021 # power on the player if needed
1022 if player.powered is False and player.power_control != PLAYER_CONTROL_NONE:
1023 await self._handle_cmd_power(player.player_id, True)
1024 if media.source_id:
1025 player.set_active_mass_source(media.source_id)
1026 await player.play_media(media)
1027
1028 @api_command("players/cmd/select_source")
1029 @handle_player_command
1030 async def select_source(self, player_id: str, source: str | None) -> None:
1031 """
1032 Handle SELECT SOURCE command on given player.
1033
1034 - player_id: player_id of the player to handle the command.
1035 - source: The ID of the source that needs to be activated/selected.
1036 """
1037 if source is None:
1038 source = player_id # default to MA queue source
1039 player = self.get(player_id, True)
1040 assert player is not None # for type checking
1041 if player.synced_to or player.active_group:
1042 raise PlayerCommandFailed(f"Player {player.display_name} is currently grouped")
1043 # check if player is already playing and source is different
1044 # in that case we need to stop the player first
1045 prev_source = player.active_source
1046 if prev_source and source != prev_source:
1047 with suppress(PlayerCommandFailed, RuntimeError):
1048 # just try to stop (regardless of state)
1049 await self.cmd_stop(player_id)
1050 await asyncio.sleep(2) # small delay to allow stop to process
1051 # check if source is a pluginsource
1052 # in that case the source id is the instance_id of the plugin provider
1053 if plugin_prov := self.mass.get_provider(source):
1054 player.set_active_mass_source(source)
1055 await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
1056 return
1057 # check if source is a mass queue
1058 # this can be used to restore the queue after a source switch
1059 if self.mass.player_queues.get(source):
1060 player.set_active_mass_source(source)
1061 return
1062 # basic check if player supports source selection
1063 if PlayerFeature.SELECT_SOURCE not in player.supported_features:
1064 raise UnsupportedFeaturedException(
1065 f"Player {player.display_name} does not support source selection"
1066 )
1067 # basic check if source is valid for player
1068 if not any(x for x in player.source_list if x.id == source):
1069 raise PlayerCommandFailed(
1070 f"{source} is an invalid source for player {player.display_name}"
1071 )
1072 # forward to player
1073 await player.select_source(source)
1074
1075 @handle_player_command(lock=True)
1076 async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
1077 """
1078 Handle enqueuing of a next media item on the player.
1079
1080 :param player_id: player_id of the player to handle the command.
1081 :param media: The Media that needs to be enqueued on the player.
1082 :raises UnsupportedFeaturedException: if the player does not support enqueueing.
1083 :raises PlayerUnavailableError: if the player is not available.
1084 """
1085 player = self.get(player_id, raise_unavailable=True)
1086 assert player is not None # for type checking
1087 if PlayerFeature.ENQUEUE not in player.supported_features:
1088 raise UnsupportedFeaturedException(
1089 f"Player {player.display_name} does not support enqueueing"
1090 )
1091 async with self._player_throttlers[player_id]:
1092 await player.enqueue_next_media(media)
1093
1094 @api_command("players/cmd/set_members")
1095 async def cmd_set_members(
1096 self,
1097 target_player: str,
1098 player_ids_to_add: list[str] | None = None,
1099 player_ids_to_remove: list[str] | None = None,
1100 ) -> None:
1101 """
1102 Join/unjoin given player(s) to/from target player.
1103
1104 Will add the given player(s) to the target player (sync leader or group player).
1105
1106 :param target_player: player_id of the syncgroup leader or group player.
1107 :param player_ids_to_add: List of player_id's to add to the target player.
1108 :param player_ids_to_remove: List of player_id's to remove from the target player.
1109
1110 :raises UnsupportedFeaturedException: if the target player does not support grouping.
1111 :raises PlayerUnavailableError: if the target player is not available.
1112 """
1113 parent_player: Player | None = self.get(target_player, True)
1114 assert parent_player is not None # for type checking
1115 if PlayerFeature.SET_MEMBERS not in parent_player.supported_features:
1116 msg = f"Player {parent_player.name} does not support group commands"
1117 raise UnsupportedFeaturedException(msg)
1118
1119 if parent_player.synced_to:
1120 # guard edge case: player already synced to another player
1121 raise PlayerCommandFailed(
1122 f"Player {parent_player.name} is already synced to another player on its own, "
1123 "you need to ungroup it first before you can join other players to it.",
1124 )
1125
1126 # filter all player ids on compatibility and availability
1127 final_player_ids_to_add: list[str] = []
1128 for child_player_id in player_ids_to_add or []:
1129 if child_player_id == target_player:
1130 continue
1131 if child_player_id in final_player_ids_to_add:
1132 continue
1133 if not (child_player := self.get(child_player_id)) or not child_player.available:
1134 self.logger.warning("Player %s is not available", child_player_id)
1135 continue
1136
1137 # check if player can be synced/grouped with the target player
1138 if not (
1139 child_player_id in parent_player.can_group_with
1140 or child_player.provider.instance_id in parent_player.can_group_with
1141 or "*" in parent_player.can_group_with
1142 ):
1143 raise UnsupportedFeaturedException(
1144 f"Player {child_player.name} can not be grouped with {parent_player.name}"
1145 )
1146
1147 if (
1148 child_player.synced_to
1149 and child_player.synced_to == target_player
1150 and child_player_id in parent_player.group_members
1151 ):
1152 continue # already synced to this target
1153
1154 # Check if player is already part of another group and try to automatically ungroup it
1155 # first. If that fails, power off the group
1156 if child_player.active_group and child_player.active_group != target_player:
1157 if (
1158 other_group := self.get(child_player.active_group)
1159 ) and PlayerFeature.SET_MEMBERS in other_group.supported_features:
1160 self.logger.warning(
1161 "Player %s is already part of another group (%s), "
1162 "removing from that group first",
1163 child_player.name,
1164 child_player.active_group,
1165 )
1166 if child_player.player_id in other_group.static_group_members:
1167 self.logger.warning(
1168 "Player %s is a static member of group %s: removing is not possible, "
1169 "powering the group off instead",
1170 child_player.name,
1171 child_player.active_group,
1172 )
1173 await self._handle_cmd_power(child_player.active_group, False)
1174 else:
1175 await other_group.set_members(player_ids_to_remove=[child_player.player_id])
1176 else:
1177 self.logger.warning(
1178 "Player %s is already part of another group (%s), powering it off first",
1179 child_player.name,
1180 child_player.active_group,
1181 )
1182 await self._handle_cmd_power(child_player.active_group, False)
1183 elif child_player.synced_to and child_player.synced_to != target_player:
1184 self.logger.warning(
1185 "Player %s is already synced to another player, ungrouping first",
1186 child_player.name,
1187 )
1188 await self.cmd_ungroup(child_player.player_id)
1189
1190 # power on the player if needed
1191 if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE:
1192 await self._handle_cmd_power(child_player.player_id, True)
1193 # if we reach here, all checks passed
1194 final_player_ids_to_add.append(child_player_id)
1195
1196 final_player_ids_to_remove: list[str] = []
1197 if player_ids_to_remove:
1198 static_members = set(parent_player.static_group_members)
1199 for child_player_id in player_ids_to_remove:
1200 if child_player_id == target_player:
1201 raise UnsupportedFeaturedException(
1202 f"Cannot remove {parent_player.name} from itself as a member!"
1203 )
1204 if child_player_id not in parent_player.group_members:
1205 continue
1206 if child_player_id in static_members:
1207 raise UnsupportedFeaturedException(
1208 f"Cannot remove {child_player_id} from {parent_player.name} "
1209 "as it is a static member of this group"
1210 )
1211 final_player_ids_to_remove.append(child_player_id)
1212
1213 # forward command to the player after all (base) sanity checks
1214 async with self._player_throttlers[target_player]:
1215 await parent_player.set_members(
1216 player_ids_to_add=final_player_ids_to_add or None,
1217 player_ids_to_remove=final_player_ids_to_remove or None,
1218 )
1219
1220 @api_command("players/cmd/group")
1221 @handle_player_command
1222 async def cmd_group(self, player_id: str, target_player: str) -> None:
1223 """Handle GROUP command for given player.
1224
1225 Join/add the given player(id) to the given (leader) player/sync group.
1226 If the target player itself is already synced to another player, this may fail.
1227 If the player can not be synced with the given target player, this may fail.
1228
1229 :param player_id: player_id of the player to handle the command.
1230 :param target_player: player_id of the syncgroup leader or group player.
1231
1232 :raises UnsupportedFeaturedException: if the target player does not support grouping.
1233 :raises PlayerCommandFailed: if the target player is already synced to another player.
1234 :raises PlayerUnavailableError: if the target player is not available.
1235 :raises PlayerCommandFailed: if the player is already grouped to another player.
1236 """
1237 await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
1238
1239 @api_command("players/cmd/group_many")
1240 async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
1241 """
1242 Join given player(s) to target player.
1243
1244 Will add the given player(s) to the target player (sync leader or group player).
1245 NOTE: This is a (deprecated) alias for cmd_set_members.
1246 """
1247 await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
1248
1249 @api_command("players/cmd/ungroup")
1250 @handle_player_command
1251 async def cmd_ungroup(self, player_id: str) -> None:
1252 """Handle UNGROUP command for given player.
1253
1254 Remove the given player from any (sync)groups it currently is synced to.
1255 If the player is not currently grouped to any other player,
1256 this will silently be ignored.
1257
1258 NOTE: This is a (deprecated) alias for cmd_set_members.
1259 """
1260 if not (player := self.get(player_id)):
1261 self.logger.warning("Player %s is not available", player_id)
1262 return
1263
1264 if (
1265 player.active_group
1266 and (group_player := self.get(player.active_group))
1267 and (PlayerFeature.SET_MEMBERS in group_player.supported_features)
1268 ):
1269 # the player is part of a (permanent) groupplayer and the user tries to ungroup
1270 if player_id in group_player.static_group_members:
1271 raise UnsupportedFeaturedException(
1272 f"Player {player.name} is a static member of group {group_player.name} "
1273 "and cannot be removed from that group!"
1274 )
1275 await group_player.set_members(player_ids_to_remove=[player_id])
1276 return
1277
1278 if player.synced_to and (synced_player := self.get(player.synced_to)):
1279 # player is a sync member
1280 await synced_player.set_members(player_ids_to_remove=[player_id])
1281 return
1282
1283 if not (player.synced_to or player.group_members):
1284 return # nothing to do
1285
1286 if PlayerFeature.SET_MEMBERS not in player.supported_features:
1287 self.logger.warning("Player %s does not support (un)group commands", player.name)
1288 return
1289
1290 # forward command to the player once all checks passed
1291 await player.ungroup()
1292
1293 @api_command("players/cmd/ungroup_many")
1294 async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
1295 """Handle UNGROUP command for all the given players."""
1296 for player_id in list(player_ids):
1297 await self.cmd_ungroup(player_id)
1298
1299 @api_command("players/create_group_player", required_role="admin")
1300 async def create_group_player(
1301 self, provider: str, name: str, members: list[str], dynamic: bool = True
1302 ) -> Player:
1303 """
1304 Create a new (permanent) Group Player.
1305
1306 :param provider: The provider(id) to create the group player for
1307 :param name: Name of the new group player
1308 :param members: List of player ids to add to the group
1309 :param dynamic: Whether the group is dynamic (members can change)
1310 """
1311 if not (provider_instance := self.mass.get_provider(provider)):
1312 raise ProviderUnavailableError(f"Provider {provider} not found")
1313 provider_instance = cast("PlayerProvider", provider_instance)
1314 if ProviderFeature.CREATE_GROUP_PLAYER in provider_instance.supported_features:
1315 return await provider_instance.create_group_player(name, members, dynamic)
1316 if ProviderFeature.SYNC_PLAYERS in provider_instance.supported_features:
1317 # provider supports syncing but not dedicated group players
1318 # create a sync group instead
1319 return await self._sync_groups.create_group_player(
1320 provider_instance, name, members, dynamic=dynamic
1321 )
1322 raise UnsupportedFeaturedException(
1323 f"Provider {provider} does not support creating group players"
1324 )
1325
1326 @api_command("players/remove_group_player", required_role="admin")
1327 async def remove_group_player(self, player_id: str) -> None:
1328 """
1329 Remove a group player.
1330
1331 :param player_id: ID of the group player to remove.
1332 """
1333 if not (player := self.get(player_id)):
1334 # we simply permanently delete the player by wiping its config
1335 self.mass.config.remove(f"players/{player_id}")
1336 return
1337 if player.type != PlayerType.GROUP:
1338 raise UnsupportedFeaturedException(
1339 f"Player {player.display_name} is not a group player"
1340 )
1341 player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
1342 await player.provider.remove_group_player(player_id)
1343
1344 @api_command("players/add_currently_playing_to_favorites")
1345 async def add_currently_playing_to_favorites(self, player_id: str) -> None:
1346 """
1347 Add the currently playing item/track on given player to the favorites.
1348
1349 This tries to resolve the currently playing media to an actual media item
1350 and add that to the favorites in the library.
1351
1352 Will raise an error if the player is not currently playing anything
1353 or if the currently playing media can not be resolved to a media item.
1354 """
1355 player = self._get_player_with_redirect(player_id)
1356 # handle mass player queue active
1357 if mass_queue := self.get_active_queue(player):
1358 if not (current_item := mass_queue.current_item) or not current_item.media_item:
1359 raise PlayerCommandFailed("No current item to add to favorites")
1360 # if we're playing a radio station, try to resolve the currently playing track
1361 if current_item.media_item.media_type == MediaType.RADIO:
1362 if not (
1363 (streamdetails := mass_queue.current_item.streamdetails)
1364 and (stream_title := streamdetails.stream_title)
1365 and " - " in stream_title
1366 ):
1367 # no stream title available, so we can't resolve the track
1368 # this can happen if the radio station does not provide metadata
1369 # or there's a commercial break
1370 # Possible future improvement could be to actually detect the song with a
1371 # shazam-like approach.
1372 raise PlayerCommandFailed("No current item to add to favorites")
1373 # send the streamtitle into a global search query
1374 search_artist, search_title_title = stream_title.split(" - ", 1)
1375 # strip off any additional comments in the title (such as from Radio Paradise)
1376 search_title_title = search_title_title.split(" | ")[0].strip()
1377 if track := await self.mass.music.get_track_by_name(
1378 search_title_title, search_artist
1379 ):
1380 # we found a track, so add it to the favorites
1381 await self.mass.music.add_item_to_favorites(track)
1382 return
1383 # we could not resolve the track, so raise an error
1384 raise PlayerCommandFailed("No current item to add to favorites")
1385
1386 # else: any other media item, just add it to the favorites directly
1387 await self.mass.music.add_item_to_favorites(current_item.media_item)
1388 return
1389
1390 # guard for player with no active source
1391 if not player.active_source:
1392 raise PlayerCommandFailed("Player has no active source")
1393 # handle other source active using the current_media with uri
1394 if current_media := player.current_media:
1395 # prefer the uri of the current media item
1396 if current_media.uri:
1397 with suppress(MusicAssistantError):
1398 await self.mass.music.add_item_to_favorites(current_media.uri)
1399 return
1400 # fallback to search based on artist and title (and album if available)
1401 if current_media.artist and current_media.title:
1402 if track := await self.mass.music.get_track_by_name(
1403 current_media.title,
1404 current_media.artist,
1405 current_media.album,
1406 ):
1407 # we found a track, so add it to the favorites
1408 await self.mass.music.add_item_to_favorites(track)
1409 return
1410 # if we reach here, we could not resolve the currently playing item
1411 raise PlayerCommandFailed("No current item to add to favorites")
1412
1413 async def register(self, player: Player) -> None:
1414 """Register a player on the Player Controller."""
1415 if self.mass.closing:
1416 return
1417 player_id = player.player_id
1418
1419 if player_id in self._players:
1420 msg = f"Player {player_id} is already registered!"
1421 raise AlreadyRegisteredError(msg)
1422
1423 # ignore disabled players
1424 if not player.enabled:
1425 return
1426
1427 # register throttler for this player
1428 self._player_throttlers[player_id] = Throttler(1, 0.05)
1429
1430 # restore 'fake' power state from cache if available
1431 cached_value = await self.mass.cache.get(
1432 key=player.player_id,
1433 provider=self.domain,
1434 category=CACHE_CATEGORY_PLAYER_POWER,
1435 default=False,
1436 )
1437 if cached_value is not None:
1438 player.extra_data[ATTR_FAKE_POWER] = cached_value
1439
1440 # finally actually register it
1441 self._players[player_id] = player
1442
1443 # ensure we fetch and set the latest/full config for the player
1444 player_config = await self.mass.config.get_player_config(player_id)
1445 player.set_config(player_config)
1446 # call hook after the player is registered and config is set
1447 await player.on_config_updated()
1448
1449 self.logger.info(
1450 "Player registered: %s/%s",
1451 player_id,
1452 player.display_name,
1453 )
1454 # signal event that a player was added
1455 # update state without signaling event first (to ensure all attributes are set correctly)
1456 player.update_state(signal_event=False)
1457 self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
1458
1459 # register playerqueue for this player
1460 await self.mass.player_queues.on_player_register(player)
1461 # always call update to fix special attributes like display name, group volume etc.
1462 player.update_state()
1463
1464 async def register_or_update(self, player: Player) -> None:
1465 """Register a new player on the controller or update existing one."""
1466 if self.mass.closing:
1467 return
1468
1469 if player.player_id in self._players:
1470 self._players[player.player_id] = player
1471 player.update_state()
1472 return
1473
1474 await self.register(player)
1475
1476 def trigger_player_update(self, player_id: str, force_update: bool = False) -> None:
1477 """Trigger an update for the given player."""
1478 if self.mass.closing:
1479 return
1480 if not (player := self.get(player_id)):
1481 return
1482 self.mass.loop.call_soon(player.update_state, force_update)
1483
1484 async def unregister(self, player_id: str, permanent: bool = False) -> None:
1485 """
1486 Unregister a player from the player controller.
1487
1488 Called (by a PlayerProvider) when a player is removed
1489 or no longer available (for a longer period of time).
1490
1491 This will remove the player from the player controller and
1492 optionally remove the player's config from the mass config.
1493
1494 - player_id: player_id of the player to unregister.
1495 - permanent: if True, remove the player permanently by deleting
1496 the player's config from the mass config. If False, the player config will not be removed,
1497 allowing for re-registration (with the same config) later.
1498
1499 If the player is not registered, this will silently be ignored.
1500 """
1501 player = self._players.get(player_id)
1502 if player is None:
1503 return
1504 await self._cleanup_player_memberships(player_id)
1505 del self._players[player_id]
1506 self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
1507 await player.on_unload()
1508 if permanent:
1509 # player permanent removal: delete its config
1510 # and signal PLAYER_REMOVED event
1511 self.delete_player_config(player_id)
1512 self.logger.info("Player removed: %s", player.name)
1513 self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
1514 else:
1515 # temporary unavailable: mark player as unavailable
1516 # note: the player will be re-registered later if it comes back online
1517 player.state.available = False
1518 self.logger.info("Player unavailable: %s", player.name)
1519 self.mass.signal_event(
1520 EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
1521 )
1522
1523 @api_command("players/remove", required_role="admin")
1524 async def remove(self, player_id: str) -> None:
1525 """
1526 Remove a player from a provider.
1527
1528 Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
1529 """
1530 player = self.get(player_id)
1531 if player is None:
1532 # we simply permanently delete the player config since it is not registered
1533 self.delete_player_config(player_id)
1534 return
1535 if player.type == PlayerType.GROUP and player_id.startswith(SYNCGROUP_PREFIX):
1536 await self._sync_groups.remove_group_player(player_id)
1537 return
1538 if player.type == PlayerType.GROUP:
1539 # Handle group player removal
1540 player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
1541 await player.provider.remove_group_player(player_id)
1542 return
1543 player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
1544 await player.provider.remove_player(player_id)
1545 # check for group memberships that need to be updated
1546 if player.active_group and (group_player := self.mass.players.get(player.active_group)):
1547 # try to remove from the group
1548 with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
1549 await group_player.set_members(
1550 player_ids_to_remove=[player_id],
1551 )
1552 # We removed the player and can now clean up its config
1553 self.delete_player_config(player_id)
1554
1555 def delete_player_config(self, player_id: str) -> None:
1556 """
1557 Permanently delete a player's configuration.
1558
1559 Should only be called for players that are not registered by the player controller.
1560 """
1561 # we simply permanently delete the player by wiping its config
1562 conf_key = f"{CONF_PLAYERS}/{player_id}"
1563 dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
1564 for key in (conf_key, dsp_conf_key):
1565 self.mass.config.remove(key)
1566
1567 def signal_player_state_update(
1568 self,
1569 player: Player,
1570 changed_values: dict[str, tuple[Any, Any]],
1571 force_update: bool = False,
1572 skip_forward: bool = False,
1573 ) -> None:
1574 """
1575 Signal a player state update.
1576
1577 Called by a Player when its state has changed.
1578 This will update the player state in the controller and signal the event bus.
1579 """
1580 player_id = player.player_id
1581 if self.mass.closing:
1582 return
1583
1584 # ignore updates for disabled players
1585 if not player.enabled and ATTR_ENABLED not in changed_values:
1586 return
1587
1588 if len(changed_values) == 0 and not force_update:
1589 # nothing changed
1590 return
1591
1592 # always signal update to the playerqueue
1593 self.mass.player_queues.on_player_update(player, changed_values)
1594
1595 if changed_values.keys() == {ATTR_ELAPSED_TIME} and not force_update:
1596 # ignore small changes in elapsed time
1597 prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
1598 new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
1599 if abs(prev_value - new_value) < 5:
1600 return
1601
1602 # handle DSP reload of the leader when grouping/ungrouping
1603 if ATTR_GROUP_MEMBERS in changed_values:
1604 prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
1605 self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
1606
1607 if ATTR_GROUP_MEMBERS in changed_values:
1608 # Removed group members also need to be updated since they are no longer part
1609 # of this group and are available for playback again
1610 prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or []
1611 new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or []
1612 removed_members = set(prev_group_members) - set(new_group_members)
1613 for _removed_player_id in removed_members:
1614 if removed_player := self.get(_removed_player_id):
1615 removed_player.update_state()
1616
1617 became_inactive = False
1618 if ATTR_AVAILABLE in changed_values:
1619 became_inactive = changed_values[ATTR_AVAILABLE][1] is False
1620 if not became_inactive and ATTR_ENABLED in changed_values:
1621 became_inactive = changed_values[ATTR_ENABLED][1] is False
1622 if became_inactive and (player.active_group or player.synced_to):
1623 self.mass.create_task(self._cleanup_player_memberships(player.player_id))
1624
1625 # signal player update on the eventbus
1626 self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
1627
1628 if skip_forward and not force_update:
1629 return
1630
1631 # update/signal group player(s) child's when group updates
1632 for child_player in self.iter_group_members(player, exclude_self=True):
1633 child_player.update_state()
1634 # update/signal group player(s) when child updates
1635 for group_player in self._get_player_groups(player, powered_only=False):
1636 group_player.update_state()
1637 # update/signal manually synced to player when child updates
1638 if (synced_to := player.synced_to) and (synced_to_player := self.get(synced_to)):
1639 synced_to_player.update_state()
1640 # update/signal active groups when a group member updates
1641 if (active_group := player.active_group) and (
1642 active_group_player := self.get(active_group)
1643 ):
1644 active_group_player.update_state()
1645
1646 async def register_player_control(self, player_control: PlayerControl) -> None:
1647 """Register a new PlayerControl on the controller."""
1648 if self.mass.closing:
1649 return
1650 control_id = player_control.id
1651
1652 if control_id in self._controls:
1653 msg = f"PlayerControl {control_id} is already registered"
1654 raise AlreadyRegisteredError(msg)
1655
1656 # make sure that the playercontrol's provider is set to the instance_id
1657 prov = self.mass.get_provider(player_control.provider)
1658 if not prov or prov.instance_id != player_control.provider:
1659 raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
1660
1661 self._controls[control_id] = player_control
1662
1663 self.logger.info(
1664 "PlayerControl registered: %s/%s",
1665 control_id,
1666 player_control.name,
1667 )
1668
1669 # always call update to update any attached players etc.
1670 self.update_player_control(player_control.id)
1671
1672 async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
1673 """Register a new playercontrol on the controller or update existing one."""
1674 if self.mass.closing:
1675 return
1676 if player_control.id in self._controls:
1677 self._controls[player_control.id] = player_control
1678 self.update_player_control(player_control.id)
1679 return
1680 await self.register_player_control(player_control)
1681
1682 def update_player_control(self, control_id: str) -> None:
1683 """Update playercontrol state."""
1684 if self.mass.closing:
1685 return
1686 # update all players that are using this control
1687 for player in self._players.values():
1688 if control_id in (player.power_control, player.volume_control, player.mute_control):
1689 self.mass.loop.call_soon(player.update_state)
1690
1691 def remove_player_control(self, control_id: str) -> None:
1692 """Remove a player_control from the player manager."""
1693 control = self._controls.pop(control_id, None)
1694 if control is None:
1695 return
1696 self._controls.pop(control_id, None)
1697 self.logger.info("PlayerControl removed: %s", control.name)
1698
1699 def get_player_provider(self, player_id: str) -> PlayerProvider:
1700 """Return PlayerProvider for given player."""
1701 player = self._players[player_id]
1702 assert player # for type checker
1703 return player.provider
1704
1705 def get_active_queue(self, player: Player) -> PlayerQueue | None:
1706 """Return the current active queue for a player (if any)."""
1707 # account for player that is synced (sync child)
1708 if player.synced_to and player.synced_to != player.player_id:
1709 if sync_leader := self.get(player.synced_to):
1710 return self.get_active_queue(sync_leader)
1711 # handle active group player
1712 if player.active_group and player.active_group != player.player_id:
1713 if group_player := self.get(player.active_group):
1714 return self.get_active_queue(group_player)
1715 # active_source may be filled queue id (or None)
1716 active_source = player.active_source or player.player_id
1717 if active_queue := self.mass.player_queues.get(active_source):
1718 return active_queue
1719 return None
1720
1721 async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
1722 """Handle adjusting the overall/group volume to a playergroup (or synced players)."""
1723 cur_volume = group_player.state.group_volume
1724 volume_dif = volume_level - cur_volume
1725 coros = []
1726 # handle group volume by only applying the volume to powered members
1727 for child_player in self.iter_group_members(
1728 group_player, only_powered=True, exclude_self=False
1729 ):
1730 if child_player.volume_control == PLAYER_CONTROL_NONE:
1731 continue
1732 cur_child_volume = child_player.volume_level or 0
1733 new_child_volume = int(cur_child_volume + volume_dif)
1734 new_child_volume = max(0, new_child_volume)
1735 new_child_volume = min(100, new_child_volume)
1736 # Use private method to skip permission check - already validated on group
1737 # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes
1738 coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume))
1739 await asyncio.gather(*coros)
1740
1741 def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
1742 """Get the (player specific) volume for a announcement."""
1743 volume_strategy = self.mass.config.get_raw_player_config_value(
1744 player_id,
1745 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
1746 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
1747 )
1748 volume_strategy_volume = self.mass.config.get_raw_player_config_value(
1749 player_id,
1750 CONF_ENTRY_ANNOUNCE_VOLUME.key,
1751 CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
1752 )
1753 if volume_strategy == "none":
1754 return None
1755 volume_level = volume_override
1756 if volume_level is None and volume_strategy == "absolute":
1757 volume_level = int(cast("float", volume_strategy_volume))
1758 elif volume_level is None and volume_strategy == "relative":
1759 if (player := self.get(player_id)) and player.volume_level is not None:
1760 volume_level = int(player.volume_level + cast("float", volume_strategy_volume))
1761 elif volume_level is None and volume_strategy == "percentual":
1762 if (player := self.get(player_id)) and player.volume_level is not None:
1763 percentual = (player.volume_level / 100) * cast("float", volume_strategy_volume)
1764 volume_level = int(player.volume_level + percentual)
1765 if volume_level is not None:
1766 announce_volume_min = cast(
1767 "float",
1768 self.mass.config.get_raw_player_config_value(
1769 player_id,
1770 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
1771 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
1772 ),
1773 )
1774 volume_level = max(int(announce_volume_min), volume_level)
1775 announce_volume_max = cast(
1776 "float",
1777 self.mass.config.get_raw_player_config_value(
1778 player_id,
1779 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
1780 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
1781 ),
1782 )
1783 volume_level = min(int(announce_volume_max), volume_level)
1784 return None if volume_level is None else int(volume_level)
1785
1786 def iter_group_members(
1787 self,
1788 group_player: Player,
1789 only_powered: bool = False,
1790 only_playing: bool = False,
1791 active_only: bool = False,
1792 exclude_self: bool = True,
1793 ) -> Iterator[Player]:
1794 """Get (child) players attached to a group player or syncgroup."""
1795 for child_id in list(group_player.group_members):
1796 if child_player := self.get(child_id, False):
1797 if not child_player.available or not child_player.enabled:
1798 continue
1799 if only_powered and child_player.powered is False:
1800 continue
1801 if active_only and child_player.active_group != group_player.player_id:
1802 continue
1803 if exclude_self and child_player.player_id == group_player.player_id:
1804 continue
1805 if only_playing and child_player.playback_state not in (
1806 PlaybackState.PLAYING,
1807 PlaybackState.PAUSED,
1808 ):
1809 continue
1810 yield child_player
1811
1812 async def wait_for_state(
1813 self,
1814 player: Player,
1815 wanted_state: PlaybackState,
1816 timeout: float = 60.0,
1817 minimal_time: float = 0,
1818 ) -> None:
1819 """Wait for the given player to reach the given state."""
1820 start_timestamp = time.time()
1821 self.logger.debug(
1822 "Waiting for player %s to reach state %s", player.display_name, wanted_state
1823 )
1824 try:
1825 async with asyncio.timeout(timeout):
1826 while player.playback_state != wanted_state:
1827 await asyncio.sleep(0.1)
1828
1829 except TimeoutError:
1830 self.logger.debug(
1831 "Player %s did not reach state %s within the timeout of %s seconds",
1832 player.display_name,
1833 wanted_state,
1834 timeout,
1835 )
1836 elapsed_time = round(time.time() - start_timestamp, 2)
1837 if elapsed_time < minimal_time:
1838 self.logger.debug(
1839 "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...",
1840 player.display_name,
1841 wanted_state,
1842 elapsed_time,
1843 minimal_time,
1844 )
1845 await asyncio.sleep(minimal_time - elapsed_time)
1846 else:
1847 self.logger.debug(
1848 "Player %s reached state %s within %s seconds",
1849 player.display_name,
1850 wanted_state,
1851 elapsed_time,
1852 )
1853
1854 async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
1855 """Call (by config manager) when the configuration of a player changes."""
1856 player = self.get(config.player_id)
1857 player_provider = self.mass.get_provider(config.provider)
1858 player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
1859 player_enabled = ATTR_ENABLED in changed_keys and config.enabled
1860
1861 if player_disabled and player and player.available:
1862 # edge case: ensure that the player is powered off if the player gets disabled
1863 if player.power_control != PLAYER_CONTROL_NONE:
1864 await self._handle_cmd_power(config.player_id, False)
1865 elif player.playback_state != PlaybackState.IDLE:
1866 await self.cmd_stop(config.player_id)
1867
1868 # signal player provider that the player got enabled/disabled
1869 if (player_enabled or player_disabled) and player_provider:
1870 assert isinstance(player_provider, PlayerProvider) # for type checking
1871 if player_disabled:
1872 player_provider.on_player_disabled(config.player_id)
1873 elif player_enabled:
1874 player_provider.on_player_enabled(config.player_id)
1875 return # enabling/disabling a player will be handled by the provider
1876
1877 if not player:
1878 return # guard against player not being registered (yet)
1879
1880 resume_queue: PlayerQueue | None = (
1881 self.mass.player_queues.get(player.active_source) if player.active_source else None
1882 )
1883
1884 # ensure player state gets updated with any updated config
1885 player.set_config(config)
1886 await player.on_config_updated()
1887 player.update_state()
1888 # if the PlayerQueue was playing, restart playback
1889 if resume_queue and resume_queue.state == PlaybackState.PLAYING:
1890 requires_restart = any(
1891 v for v in config.values.values() if v.key in changed_keys and v.requires_reload
1892 )
1893 if requires_restart:
1894 # always stop first to ensure the player uses the new config
1895 await self.mass.player_queues.stop(resume_queue.queue_id)
1896 self.mass.call_later(
1897 1, self.mass.player_queues.resume, resume_queue.queue_id, False
1898 )
1899
1900 async def on_player_dsp_change(self, player_id: str) -> None:
1901 """Call (by config manager) when the DSP settings of a player change."""
1902 # signal player provider that the config changed
1903 if not (player := self.get(player_id)):
1904 return
1905 if player.playback_state == PlaybackState.PLAYING:
1906 self.logger.info("Restarting playback of Player %s after DSP change", player_id)
1907 # this will restart the queue stream/playback
1908 if player.mass_queue_active:
1909 self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False)
1910 return
1911 # if the player is not using a queue, we need to stop and start playback
1912 await self.cmd_stop(player_id)
1913 await self.cmd_play(player_id)
1914
1915 async def _cleanup_player_memberships(self, player_id: str) -> None:
1916 """Ensure a player is detached from any groups or syncgroups."""
1917 if not (player := self.get(player_id)):
1918 return
1919
1920 if (
1921 player.active_group
1922 and (group := self.get(player.active_group))
1923 and group.supports_feature(PlayerFeature.SET_MEMBERS)
1924 ):
1925 # Ungroup the player if its part of an active group, this will ignore
1926 # static_group_members since that is only checked when using cmd_set_members
1927 with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
1928 await group.set_members(player_ids_to_remove=[player_id])
1929 elif player.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS):
1930 # Remove the player if it was synced, otherwise it will still show as
1931 # synced to the other player after it gets registered again
1932 with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
1933 await player.ungroup()
1934
1935 def _get_player_with_redirect(self, player_id: str) -> Player:
1936 """Get player with check if playback related command should be redirected."""
1937 player = self.get(player_id, True)
1938 assert player is not None # for type checking
1939 if player.synced_to and (sync_leader := self.get(player.synced_to)):
1940 self.logger.info(
1941 "Player %s is synced to %s and can not accept "
1942 "playback related commands itself, "
1943 "redirected the command to the sync leader.",
1944 player.name,
1945 sync_leader.name,
1946 )
1947 return sync_leader
1948 if player.active_group and (active_group := self.get(player.active_group)):
1949 self.logger.info(
1950 "Player %s is part of a playergroup and can not accept "
1951 "playback related commands itself, "
1952 "redirected the command to the group leader.",
1953 player.name,
1954 )
1955 return active_group
1956 return player
1957
1958 def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
1959 """Get the active PluginSource for a player if any."""
1960 # Check if any plugin source is in use by this player
1961 for plugin_source in self.get_plugin_sources():
1962 if plugin_source.in_use_by == player.player_id:
1963 return plugin_source
1964 if player.active_source == plugin_source.id:
1965 return plugin_source
1966 return None
1967
1968 def _get_player_groups(
1969 self, player: Player, available_only: bool = True, powered_only: bool = False
1970 ) -> Iterator[Player]:
1971 """Return all groupplayers the given player belongs to."""
1972 for _player in self.all(return_unavailable=not available_only):
1973 if _player.player_id == player.player_id:
1974 continue
1975 if _player.type != PlayerType.GROUP:
1976 continue
1977 if powered_only and _player.powered is False:
1978 continue
1979 if player.player_id in _player.group_members:
1980 yield _player
1981
1982 async def _play_announcement( # noqa: PLR0915
1983 self,
1984 player: Player,
1985 announcement: PlayerMedia,
1986 volume_level: int | None = None,
1987 ) -> None:
1988 """Handle (default/fallback) implementation of the play announcement feature.
1989
1990 This default implementation will;
1991 - stop playback of the current media (if needed)
1992 - power on the player (if needed)
1993 - raise the volume a bit
1994 - play the announcement (from given url)
1995 - wait for the player to finish playing
1996 - restore the previous power and volume
1997 - restore playback (if needed and if possible)
1998
1999 This default implementation will only be used if the player
2000 (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
2001 """
2002 prev_state = player.playback_state
2003 prev_power = player.powered or prev_state != PlaybackState.IDLE
2004 prev_synced_to = player.synced_to
2005 prev_group = self.get(player.active_group) if player.active_group else None
2006 prev_source = player.active_source
2007 prev_media = player.current_media
2008 prev_media_name = prev_media.title or prev_media.uri if prev_media else None
2009 if prev_synced_to:
2010 # ungroup player if its currently synced
2011 self.logger.debug(
2012 "Announcement to player %s - ungrouping player from %s...",
2013 player.display_name,
2014 prev_synced_to,
2015 )
2016 await self.cmd_ungroup(player.player_id)
2017 elif prev_group:
2018 # if the player is part of a group player, we need to ungroup it
2019 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
2020 self.logger.debug(
2021 "Announcement to player %s - ungrouping from group player %s...",
2022 player.display_name,
2023 prev_group.display_name,
2024 )
2025 await prev_group.set_members(player_ids_to_remove=[player.player_id])
2026 else:
2027 # if the player is part of a group player that does not support ungrouping,
2028 # we need to power off the groupplayer instead
2029 self.logger.debug(
2030 "Announcement to player %s - turning off group player %s...",
2031 player.display_name,
2032 prev_group.display_name,
2033 )
2034 await self._handle_cmd_power(player.player_id, False)
2035 elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
2036 # normal/standalone player: stop player if its currently playing
2037 self.logger.debug(
2038 "Announcement to player %s - stop existing content (%s)...",
2039 player.display_name,
2040 prev_media_name,
2041 )
2042 await self.cmd_stop(player.player_id)
2043 # wait for the player to stop
2044 await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4)
2045 # adjust volume if needed
2046 # in case of a (sync) group, we need to do this for all child players
2047 prev_volumes: dict[str, int] = {}
2048 async with TaskManager(self.mass) as tg:
2049 for volume_player_id in player.group_members or (player.player_id,):
2050 if not (volume_player := self.get(volume_player_id)):
2051 continue
2052 # catch any players that have a different source active
2053 if (
2054 volume_player.active_source
2055 not in (
2056 player.active_source,
2057 volume_player.player_id,
2058 None,
2059 )
2060 and volume_player.playback_state == PlaybackState.PLAYING
2061 ):
2062 self.logger.warning(
2063 "Detected announcement to playergroup %s while group member %s is playing "
2064 "other content, this may lead to unexpected behavior.",
2065 player.display_name,
2066 volume_player.display_name,
2067 )
2068 tg.create_task(self.cmd_stop(volume_player.player_id))
2069 if volume_player.volume_control == PLAYER_CONTROL_NONE:
2070 continue
2071 if (prev_volume := volume_player.volume_level) is None:
2072 continue
2073 announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
2074 if announcement_volume is None:
2075 continue
2076 temp_volume = announcement_volume or player.volume_level
2077 if temp_volume != prev_volume:
2078 prev_volumes[volume_player_id] = prev_volume
2079 self.logger.debug(
2080 "Announcement to player %s - setting temporary volume (%s)...",
2081 volume_player.display_name,
2082 announcement_volume,
2083 )
2084 tg.create_task(
2085 self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
2086 )
2087 # play the announcement
2088 self.logger.debug(
2089 "Announcement to player %s - playing the announcement on the player...",
2090 player.display_name,
2091 )
2092 await self.play_media(player_id=player.player_id, media=announcement)
2093 # wait for the player(s) to play
2094 await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
2095 # wait for the player to stop playing
2096 if not announcement.duration:
2097 if not announcement.custom_data:
2098 raise ValueError("Announcement missing duration and custom_data")
2099 media_info = await async_parse_tags(
2100 announcement.custom_data["announcement_url"], require_duration=True
2101 )
2102 announcement.duration = int(media_info.duration) if media_info.duration else None
2103
2104 if announcement.duration is None:
2105 raise ValueError("Announcement duration could not be determined")
2106
2107 await self.wait_for_state(
2108 player,
2109 PlaybackState.IDLE,
2110 timeout=announcement.duration + 10,
2111 minimal_time=float(announcement.duration) + 2,
2112 )
2113 self.logger.debug(
2114 "Announcement to player %s - restore previous state...", player.display_name
2115 )
2116 # restore volume
2117 async with TaskManager(self.mass) as tg:
2118 for volume_player_id, prev_volume in prev_volumes.items():
2119 tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
2120 await asyncio.sleep(0.2)
2121 # either power off the player or resume playing
2122 if not prev_power:
2123 if player.power_control != PLAYER_CONTROL_NONE:
2124 self.logger.debug(
2125 "Announcement to player %s - turning player off again...", player.display_name
2126 )
2127 await self._handle_cmd_power(player.player_id, False)
2128 # nothing to do anymore, player was not previously powered
2129 # and does not support power control
2130 return
2131 if prev_synced_to:
2132 self.logger.debug(
2133 "Announcement to player %s - syncing back to %s...",
2134 player.display_name,
2135 prev_synced_to,
2136 )
2137 await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
2138 elif prev_group:
2139 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
2140 self.logger.debug(
2141 "Announcement to player %s - grouping back to group player %s...",
2142 player.display_name,
2143 prev_group.display_name,
2144 )
2145 await prev_group.set_members(player_ids_to_add=[player.player_id])
2146 elif prev_state == PlaybackState.PLAYING:
2147 # if the player is part of a group player that does not support set_members,
2148 # we need to restart the groupplayer
2149 self.logger.debug(
2150 "Announcement to player %s - restarting playback on group player %s...",
2151 player.display_name,
2152 prev_group.display_name,
2153 )
2154 await self.cmd_play(prev_group.player_id)
2155 elif prev_state == PlaybackState.PLAYING:
2156 # player was playing something before the announcement - try to resume that here
2157 await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
2158
2159 async def _poll_players(self) -> None:
2160 """Background task that polls players for updates."""
2161 while True:
2162 for player in list(self._players.values()):
2163 # if the player is playing, update elapsed time every tick
2164 # to ensure the queue has accurate details
2165 player_playing = player.playback_state == PlaybackState.PLAYING
2166 if player_playing:
2167 self.mass.loop.call_soon(
2168 self.mass.player_queues.on_player_update,
2169 player,
2170 {"corrected_elapsed_time": (None, player.corrected_elapsed_time)},
2171 )
2172 # Poll player;
2173 if not player.needs_poll:
2174 continue
2175 try:
2176 last_poll: float = player.extra_data[ATTR_LAST_POLL]
2177 except KeyError:
2178 last_poll = 0.0
2179 if (self.mass.loop.time() - last_poll) < player.poll_interval:
2180 continue
2181 player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
2182 try:
2183 await player.poll()
2184 except Exception as err:
2185 self.logger.warning(
2186 "Error while requesting latest state from player %s: %s",
2187 player.display_name,
2188 str(err),
2189 exc_info=err if self.logger.isEnabledFor(10) else None,
2190 )
2191 # Yield to event loop to prevent blocking
2192 await asyncio.sleep(0)
2193 await asyncio.sleep(1)
2194
2195 async def _handle_select_plugin_source(
2196 self, player: Player, plugin_prov: PluginProvider
2197 ) -> None:
2198 """Handle playback/select of given plugin source on player."""
2199 plugin_source = plugin_prov.get_source()
2200 if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id:
2201 self.logger.debug(
2202 "Plugin source %s is already in use by player %s, stopping playback there first.",
2203 plugin_source.name,
2204 plugin_source.in_use_by,
2205 )
2206 with suppress(PlayerCommandFailed):
2207 await self.cmd_stop(plugin_source.in_use_by)
2208 stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
2209 plugin_source.in_use_by = player.player_id
2210 # Call on_select callback if available
2211 if plugin_source.on_select:
2212 await plugin_source.on_select()
2213 await self.play_media(
2214 player_id=player.player_id,
2215 media=PlayerMedia(
2216 uri=stream_url,
2217 media_type=MediaType.PLUGIN_SOURCE,
2218 title=plugin_source.name,
2219 custom_data={
2220 "provider": plugin_prov.instance_id,
2221 "source_id": plugin_source.id,
2222 "player_id": player.player_id,
2223 "audio_format": plugin_source.audio_format,
2224 },
2225 ),
2226 )
2227 # trigger player update to ensure the source is set
2228 self.trigger_player_update(player.player_id)
2229
2230 def _handle_group_dsp_change(
2231 self, player: Player, prev_group_members: list[str], new_group_members: list[str]
2232 ) -> None:
2233 """Handle DSP reload when group membership changes."""
2234 prev_child_count = len(prev_group_members)
2235 new_child_count = len(new_group_members)
2236 is_player_group = player.type == PlayerType.GROUP
2237
2238 # handle special case for PlayerGroups: since there are no leaders,
2239 # DSP still always work with a single player in the group.
2240 multi_device_dsp_threshold = 1 if is_player_group else 0
2241
2242 prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
2243 new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
2244
2245 if prev_is_multiple_devices == new_is_multiple_devices:
2246 return # no change in multi-device status
2247
2248 supports_multi_device_dsp = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features
2249
2250 dsp_enabled: bool
2251 if player.type == PlayerType.GROUP:
2252 # Since player groups do not have leaders, we will use the only child
2253 # that was in the group before and after the change
2254 if prev_is_multiple_devices:
2255 if childs := new_group_members:
2256 # We shrank the group from multiple players to a single player
2257 # So the now only child will control the DSP
2258 dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
2259 else:
2260 dsp_enabled = False
2261 elif childs := prev_group_members:
2262 # We grew the group from a single player to multiple players,
2263 # let's see if the previous single player had DSP enabled
2264 dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
2265 else:
2266 dsp_enabled = False
2267 else:
2268 dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
2269
2270 if dsp_enabled and not supports_multi_device_dsp:
2271 # We now know that the group configuration has changed so:
2272 # - multi-device DSP is not supported
2273 # - we switched from a group with multiple players to a single player
2274 # (or vice versa)
2275 # - the leader has DSP enabled
2276 self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
2277
2278 # Private command handlers (no permission checks)
2279
2280 async def _handle_cmd_resume(
2281 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
2282 ) -> None:
2283 """
2284 Handle resume playback command.
2285
2286 Skips the permission checks (internal use only).
2287 """
2288 player = self._get_player_with_redirect(player_id)
2289 source = source or player.active_source
2290 media = media or player.current_media
2291 # power on the player if needed
2292 if not player.powered and player.power_control != PLAYER_CONTROL_NONE:
2293 await self._handle_cmd_power(player.player_id, True)
2294 # Redirect to queue controller if it is active
2295 if active_queue := self.mass.player_queues.get(source or player_id):
2296 await self.mass.player_queues.resume(active_queue.queue_id)
2297 return
2298 # try to handle command on player directly
2299 # TODO: check if player has an active source with native resume support
2300 active_source = next((x for x in player.source_list if x.id == source), None)
2301 if (
2302 player.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
2303 and active_source
2304 and active_source.can_play_pause
2305 ):
2306 # player has some other source active and native resume support
2307 await player.play()
2308 return
2309 if active_source and not active_source.passive:
2310 await self.select_source(player_id, active_source.id)
2311 return
2312 if media:
2313 # try to re-play the current media item
2314 await player.play_media(media)
2315 return
2316 # fallback: just send play command - which will fail if nothing can be played
2317 await player.play()
2318
2319 async def _handle_cmd_power(self, player_id: str, powered: bool) -> None:
2320 """
2321 Handle player power on/off command.
2322
2323 Skips the permission checks (internal use only).
2324 """
2325 player = self.get(player_id, True)
2326 assert player is not None # for type checking
2327 player_state = player.state
2328
2329 if player_state.powered == powered:
2330 self.logger.debug(
2331 "Ignoring power %s command for player %s: already in state %s",
2332 "ON" if powered else "OFF",
2333 player_state.name,
2334 "ON" if player_state.powered else "OFF",
2335 )
2336 return # nothing to do
2337
2338 # ungroup player at power off
2339 player_was_synced = player.synced_to is not None
2340 if player.type == PlayerType.PLAYER and not powered:
2341 # ungroup player if it is synced (or is a sync leader itself)
2342 # NOTE: ungroup will be ignored if the player is not grouped or synced
2343 await self.cmd_ungroup(player_id)
2344
2345 # always stop player at power off
2346 if (
2347 not powered
2348 and not player_was_synced
2349 and player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2350 ):
2351 await self.cmd_stop(player_id)
2352 # short sleep: allow the stop command to process and prevent race conditions
2353 await asyncio.sleep(0.2)
2354
2355 # power off all synced childs when player is a sync leader
2356 elif not powered and player.type == PlayerType.PLAYER and player.group_members:
2357 async with TaskManager(self.mass) as tg:
2358 for member in self.iter_group_members(player, True):
2359 if member.power_control == PLAYER_CONTROL_NONE:
2360 continue
2361 # Use private method to skip permission check for child players
2362 tg.create_task(self._handle_cmd_power(member.player_id, False))
2363
2364 # handle actual power command
2365 if player.power_control == PLAYER_CONTROL_NONE:
2366 raise UnsupportedFeaturedException(
2367 f"Player {player.display_name} does not support power control"
2368 )
2369 if player.power_control == PLAYER_CONTROL_NATIVE:
2370 # player supports power command natively: forward to player provider
2371 async with self._player_throttlers[player_id]:
2372 await player.power(powered)
2373 elif player.power_control == PLAYER_CONTROL_FAKE:
2374 # user wants to use fake power control - so we (optimistically) update the state
2375 # and store the state in the cache
2376 player.extra_data[ATTR_FAKE_POWER] = powered
2377 player.update_state() # trigger update of the player state
2378 await self.mass.cache.set(
2379 key=player_id,
2380 data=powered,
2381 provider=self.domain,
2382 category=CACHE_CATEGORY_PLAYER_POWER,
2383 )
2384 else:
2385 # handle external player control
2386 player_control = self._controls.get(player.power_control)
2387 control_name = player_control.name if player_control else player.power_control
2388 self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
2389 if not player_control or not player_control.supports_power:
2390 raise UnsupportedFeaturedException(
2391 f"Player control {control_name} is not available"
2392 )
2393 if powered:
2394 assert player_control.power_on is not None # for type checking
2395 await player_control.power_on()
2396 else:
2397 assert player_control.power_off is not None # for type checking
2398 await player_control.power_off()
2399
2400 # always trigger a state update to update the UI
2401 player.update_state()
2402
2403 # handle 'auto play on power on' feature
2404 if (
2405 not player.active_group
2406 and powered
2407 and player.config.get_value(CONF_AUTO_PLAY)
2408 and player.active_source in (None, player_id)
2409 and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
2410 ):
2411 await self.mass.player_queues.resume(player_id)
2412
2413 async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
2414 """
2415 Handle Player volume set command.
2416
2417 Skips the permission checks (internal use only).
2418 """
2419 player = self.get(player_id, True)
2420 assert player is not None # for type checker
2421 if player.type == PlayerType.GROUP:
2422 # redirect to special group volume control
2423 await self.cmd_group_volume(player_id, volume_level)
2424 return
2425
2426 if player.volume_control == PLAYER_CONTROL_NONE:
2427 raise UnsupportedFeaturedException(
2428 f"Player {player.display_name} does not support volume control"
2429 )
2430
2431 # Check if player has mute lock (set when individually muted in a group)
2432 # If locked, don't auto-unmute when volume changes
2433 has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False)
2434 if (
2435 not has_mute_lock
2436 and player.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE)
2437 and player.volume_muted
2438 ):
2439 # if player is muted and not locked, we unmute it first
2440 # skip this for fake mute since it uses volume to simulate mute
2441 self.logger.debug(
2442 "Unmuting player %s before setting volume",
2443 player.display_name,
2444 )
2445 await self.cmd_volume_mute(player_id, False)
2446
2447 # Check if a plugin source is active with a volume callback
2448 if plugin_source := self._get_active_plugin_source(player):
2449 if plugin_source.on_volume:
2450 await plugin_source.on_volume(volume_level)
2451
2452 if player.volume_control == PLAYER_CONTROL_NATIVE:
2453 # player supports volume command natively: forward to player
2454 async with self._player_throttlers[player_id]:
2455 await player.volume_set(volume_level)
2456 return
2457 if player.volume_control == PLAYER_CONTROL_FAKE:
2458 # user wants to use fake volume control - so we (optimistically) update the state
2459 # and store the state in the cache
2460 player.extra_data[ATTR_FAKE_VOLUME] = volume_level
2461 # trigger update
2462 player.update_state()
2463 return
2464 # else: handle external player control
2465 player_control = self._controls.get(player.volume_control)
2466 control_name = player_control.name if player_control else player.volume_control
2467 self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
2468 if not player_control or not player_control.supports_volume:
2469 raise UnsupportedFeaturedException(f"Player control {control_name} is not available")
2470 async with self._player_throttlers[player_id]:
2471 assert player_control.volume_set is not None
2472 await player_control.volume_set(volume_level)
2473
2474 def __iter__(self) -> Iterator[Player]:
2475 """Iterate over all players."""
2476 return iter(self._players.values())
2477