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