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