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