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