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