/
/
/
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 contextlib
21import time
22import weakref
23from collections.abc import AsyncIterator
24from contextlib import suppress
25from typing import TYPE_CHECKING, Any, cast
26
27from music_assistant_models.auth import Scope
28from music_assistant_models.background_task import TaskSchedule
29from music_assistant_models.config_entries import ConfigEntry
30from music_assistant_models.constants import (
31 PLAYER_CONTROL_FAKE,
32 PLAYER_CONTROL_NATIVE,
33 PLAYER_CONTROL_NONE,
34)
35from music_assistant_models.enums import (
36 ConfigEntryType,
37 EventType,
38 IdentifierType,
39 MediaType,
40 PlaybackState,
41 PlayerFeature,
42 PlayerType,
43 ProviderFeature,
44 ProviderType,
45 RepeatMode,
46 SourceControl,
47)
48from music_assistant_models.errors import (
49 AlreadyRegisteredError,
50 InsufficientPermissions,
51 InvalidCommand,
52 InvalidDataError,
53 MusicAssistantError,
54 PlayerCommandFailed,
55 PlayerUnavailableError,
56 ProviderUnavailableError,
57 UnsupportedFeaturedException,
58)
59from music_assistant_models.media_items import AudioSource
60from music_assistant_models.player import PlayerOptionValueType # noqa: TC002
61from music_assistant_models.player_control import PlayerControl # noqa: TC002
62
63from music_assistant.constants import (
64 ATTR_ACTIVE_SOURCE,
65 ATTR_ANNOUNCEMENT_IN_PROGRESS,
66 ATTR_AVAILABLE,
67 ATTR_ENABLED,
68 ATTR_FAKE_MUTE,
69 ATTR_FAKE_POWER,
70 ATTR_FAKE_VOLUME,
71 ATTR_GROUP_MEMBERS,
72 ATTR_GROUP_VOLUME_SNAPSHOT,
73 ATTR_LAST_POLL,
74 ATTR_MUTE_CONTROL,
75 ATTR_MUTE_LOCK,
76 ATTR_POWER_CONTROL,
77 ATTR_POWERED,
78 ATTR_PREVIOUS_VOLUME,
79 ATTR_SUPPORTED_FEATURES,
80 ATTR_VOLUME_CONTROL,
81 ATTR_VOLUME_TARGET,
82 CONF_ANNOUNCE_TTS_ENGINE,
83 CONF_AUTO_PLAY,
84 CONF_CACHED_ARP_MAC,
85 CONF_ENTRY_MAX_VOLUME,
86 CONF_ENTRY_MIN_VOLUME,
87 CONF_GROUP_MEMBERS,
88 CONF_MAX_VOLUME,
89 CONF_MIN_VOLUME,
90 CONF_MUTE_CONTROL,
91 CONF_PLAY_MEDIA_OVERRIDES_GROUP,
92 CONF_PLAYER_DSP,
93 CONF_PLAYER_QUEUES,
94 CONF_PLAYERS,
95 CONF_POWER_CONTROL,
96 CONF_PROTOCOL_PARENT_ID,
97 CONF_REPORTED_MAC,
98 CONF_VOLUME_CONTROL,
99 CONF_VOLUME_STEP,
100 VERBOSE_LOG_LEVEL,
101)
102from music_assistant.controllers.webserver.helpers.auth_middleware import (
103 get_current_user,
104 get_sendspin_player_id,
105 has_scope,
106)
107from music_assistant.helpers.api import api_command
108from music_assistant.helpers.colors import get_palette_for_url
109from music_assistant.helpers.config_entries import PLAYBACK_TARGET_TYPES
110from music_assistant.helpers.plugin_engines import create_tts_engine_config_entries
111from music_assistant.helpers.util import (
112 TaskManager,
113 enrich_device_mac_address,
114 is_valid_mac_address,
115)
116from music_assistant.models.core_controller import CoreController
117from music_assistant.models.player import Player, PlayerMedia, PlayerState
118from music_assistant.models.player_provider import PlayerProvider
119from music_assistant.models.plugin import PluginProvider, SourceControlValue
120
121from .announcements import AnnouncementsMixin
122from .audio_sources import AudioSourceMixin, AudioSourceSession
123from .constants import PlayerLockPurpose
124from .helpers import handle_player_command, wait_for_power_on
125from .protocol_linking import ProtocolLinkingMixin
126
127if TYPE_CHECKING:
128 from collections.abc import Callable, Iterator
129
130 from music_assistant_models.config_entries import (
131 CoreConfig,
132 PlayerConfig,
133 )
134 from music_assistant_models.player import OutputProtocol
135 from music_assistant_models.player_queue import PlayerQueue
136
137 from music_assistant import MusicAssistant
138 from music_assistant.helpers.json import SerializableType
139
140CACHE_CATEGORY_PLAYER_POWER = 1
141
142# state keys that carry the current_media playback-position anchor; these only
143# change on discrete position events (play/pause/seek/track change/buffer correction)
144POSITION_ANCHOR_KEYS = frozenset(
145 {
146 "current_media.elapsed_time",
147 "current_media.elapsed_time_last_updated",
148 }
149)
150
151# How long the volume level of the last command outranks the level the player reports.
152# Long enough to cover a burst of volume nudges on a player that only reports its volume
153# back some time later, short enough for a change made on the device itself to win again.
154VOLUME_TARGET_EXPIRY = 2.0
155
156# How long a freshly started source session may wait for its first stream request
157# before it is considered never started and released.
158AUDIO_SOURCE_CLAIM_TIMEOUT = 30
159
160# How long a player must stay powered off before the queue it was playing is ended.
161# Home Assistant reports an entity that is (briefly) unavailable or unknown as off, so an
162# external power control can report a power off that comes straight back - which must not
163# stop the music. Long enough to sit out a reloading integration or a device missing a
164# poll, short enough that a real power off is not left streaming.
165EXTERNAL_POWER_OFF_STOP_DELAY = 15
166
167# Player types that must be detached from their (sync)group when they power off.
168# A stereo pair joins a group and leads a sync session exactly like a single speaker does.
169# A GROUP player has no group of its own to leave: its members are released by its own
170# power off, and ungrouping one is defined as powering it off - which is what already
171# brought it here.
172UNGROUP_ON_POWER_OFF_TYPES = {PlayerType.PLAYER, PlayerType.STEREO_PAIR}
173
174# Sentinel used to detect omitted optional arguments where ``None`` is a valid value.
175_SENTINEL: Any = object()
176
177
178class PlayerController(AnnouncementsMixin, AudioSourceMixin, ProtocolLinkingMixin, CoreController):
179 """Controller holding all logic to control registered players."""
180
181 domain: str = "players"
182
183 def __init__(self, mass: MusicAssistant) -> None:
184 """Initialize core controller."""
185 super().__init__(mass)
186 self._players: dict[str, Player] = {}
187 self._controls: dict[str, PlayerControl] = {}
188 self.manifest.name = "Player Controller"
189 self.manifest.description = (
190 "Music Assistant's core controller which manages all players from all providers."
191 )
192 self.manifest.icon = "speaker-multiple"
193 self._poll_task: asyncio.Task[None] | None = None
194 self._player_command_locks: dict[str, asyncio.Lock] = {}
195 # Re-entrancy tracking for get_player_lock, keyed on the task object
196 # (weak ref auto-clears entries if a task is GC'd before its finally runs).
197 self._task_held_locks: weakref.WeakKeyDictionary[asyncio.Task[Any], set[str]] = (
198 weakref.WeakKeyDictionary()
199 )
200 # Lock to prevent race conditions during player registration
201 self._register_lock = asyncio.Lock()
202 # Track pending protocol player evaluations (delayed to allow all protocols to register)
203 self._pending_protocol_evaluations: dict[str, asyncio.TimerHandle] = {}
204 # Serialize delayed evaluations to prevent race conditions
205 self._delayed_evaluation_lock = asyncio.Lock()
206 # Live external AudioSource playing on a player, keyed on player_id
207 self._source_sessions: dict[str, AudioSourceSession] = {}
208 # Subscribers for player state updates (called with player + changed_values)
209 self._state_update_subscribers: list[
210 Callable[[Player, dict[str, tuple[Any, Any]]], None]
211 ] = []
212
213 @contextlib.asynccontextmanager
214 async def get_player_lock(
215 self, player_id: str, purpose: PlayerLockPurpose = PlayerLockPurpose.PLAYBACK
216 ) -> AsyncIterator[None]:
217 """
218 Acquire a purpose-scoped lock for a player, with re-entrant support.
219
220 Tracks lock ownership per asyncio Task so that nested calls within the same
221 task skip re-acquisition (preventing deadlocks), while deferred callbacks
222 (call_later / create_task) correctly acquire a fresh lock.
223
224 If the lock can't be acquired within 30s the body runs anyway, to keep
225 the player responsive when a previous holder is stuck on a hung command.
226
227 :param player_id: The player to lock.
228 :param purpose: Lock category. Commands with different purposes can run
229 concurrently on the same player.
230 """
231 lock_key = f"{purpose.value}_{player_id}"
232 task = asyncio.current_task()
233
234 if task is not None and lock_key in self._task_held_locks.get(task, set()):
235 yield
236 return
237
238 lock = self._player_command_locks.setdefault(lock_key, asyncio.Lock())
239 # Two-stage acquire: a slow-acquire log at 5s and a hard give-up at 30s.
240 # If the previous holder is stuck (e.g. on a dead provider socket), we
241 # proceed without the lock so this player stays responsive.
242 acquired = False
243 try:
244 async with asyncio.timeout(5):
245 await lock.acquire()
246 acquired = True
247 except TimeoutError:
248 self.logger.debug(
249 "Acquiring %s lock for player %s is slow (>5s)", purpose.value, player_id
250 )
251 try:
252 async with asyncio.timeout(25):
253 await lock.acquire()
254 acquired = True
255 except TimeoutError:
256 self.logger.warning(
257 "Timed out (30s) acquiring %s lock for player %s — "
258 "previous holder appears stuck; proceeding without lock",
259 purpose.value,
260 player_id,
261 )
262
263 if acquired and task is not None:
264 self._task_held_locks.setdefault(task, set()).add(lock_key)
265 try:
266 yield
267 finally:
268 if acquired:
269 if task is not None and (held := self._task_held_locks.get(task)) is not None:
270 held.discard(lock_key)
271 if not held:
272 del self._task_held_locks[task]
273 lock.release()
274
275 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
276 """Return Config Entries for the Player Controller."""
277 return (
278 ConfigEntry(
279 key=CONF_VOLUME_STEP,
280 type=ConfigEntryType.INTEGER,
281 default_value=0,
282 range=(0, 10),
283 required=False,
284 category="generic",
285 ),
286 *await create_tts_engine_config_entries(
287 self.mass, CONF_ANNOUNCE_TTS_ENGINE, category="announcements"
288 ),
289 )
290
291 async def setup(self, config: CoreConfig) -> None:
292 """Async initialize of module."""
293 self._repair_protocol_parent_links()
294 self._poll_task = self.mass.create_task(self._poll_players())
295 self.mass.tasks.register_scheduled_task(
296 task_id="fix_group_member_configs",
297 name="Fix sync group member configurations",
298 handler=self._fix_group_member_configs,
299 schedule=TaskSchedule.weekly(
300 days_of_week=[0],
301 hour=4,
302 minute=0,
303 ),
304 initial_delay=300,
305 )
306
307 async def close(self) -> None:
308 """Cleanup on exit."""
309 if self._poll_task and not self._poll_task.done():
310 self._poll_task.cancel()
311 # Cancel all pending protocol evaluations
312 for handle in self._pending_protocol_evaluations.values():
313 handle.cancel()
314 self._pending_protocol_evaluations.clear()
315 for player in self._players.values():
316 if player.sleep_timer_expires_at is not None:
317 self.mass.cancel_timer(self._sleep_timer_task_id(player.player_id))
318
319 async def get_diagnostics(self) -> dict[str, SerializableType]:
320 """Return diagnostics info for this controller to include in diagnostics reports."""
321 players = list(self._players.values())
322 return {
323 "players_synced": sum(player.state.synced_to is not None for player in players),
324 "players_with_active_group": sum(
325 player.state.active_group is not None for player in players
326 ),
327 "announcements_in_progress": sum(
328 bool(player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)) for player in players
329 ),
330 "pending_protocol_evaluations": len(self._pending_protocol_evaluations),
331 }
332
333 async def on_provider_loaded(self, provider: PlayerProvider) -> None:
334 """Handle logic when a provider is loaded."""
335
336 async def on_provider_unload(self, provider: PlayerProvider) -> None:
337 """Handle logic when a provider is (about to get) unloaded."""
338
339 @property
340 def providers(self) -> list[PlayerProvider]:
341 """Return all loaded/running MusicProviders."""
342 return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
343
344 def iter_players(
345 self,
346 return_unavailable: bool = True,
347 return_disabled: bool = False,
348 provider_filter: str | None = None,
349 return_protocol_players: bool = False,
350 ) -> Iterator[Player]:
351 """
352 Iterate over all registered players, regardless of who is asking.
353
354 Use this for internal logic - state derivation, bookkeeping and topology
355 lookups - which must stay correct no matter which user's command happened
356 to trigger it. Use :meth:`all_players` for anything presented to a user.
357
358 :param return_unavailable [bool]: Include unavailable players.
359 :param return_disabled [bool]: Include disabled players.
360 :param provider_filter [str]: Optional filter by provider lookup key.
361 :param return_protocol_players [bool]: Include protocol players (hidden by default).
362 """
363 for player in list(self._players.values()):
364 if not (player.state.available or return_unavailable):
365 continue
366 if not (player.state.enabled or return_disabled):
367 continue
368 if not player.initialized.is_set():
369 continue
370 if provider_filter is not None and player.provider.instance_id != provider_filter:
371 continue
372 if not return_protocol_players and player.state.type == PlayerType.PROTOCOL:
373 continue
374 yield player
375
376 def all_players(
377 self,
378 return_unavailable: bool = True,
379 return_disabled: bool = False,
380 provider_filter: str | None = None,
381 return_protocol_players: bool = False,
382 ) -> list[Player]:
383 """
384 Return the registered players the current user is allowed to see.
385
386 Note that this applies user filters for players (for non admin users),
387 which makes it unsuitable for internal logic - use :meth:`iter_players` there.
388
389 :param return_unavailable [bool]: Include unavailable players.
390 :param return_disabled [bool]: Include disabled players.
391 :param provider_filter [str]: Optional filter by provider lookup key.
392 :param return_protocol_players [bool]: Include protocol players (hidden by default).
393
394 :return: List of Player objects.
395 """
396 current_user = get_current_user()
397 user_filter = (
398 current_user.player_filter
399 if current_user and not has_scope(current_user, Scope.ALL)
400 else None
401 )
402 current_sendspin_player = get_sendspin_player_id()
403 return [
404 player
405 for player in self.iter_players(
406 return_unavailable=return_unavailable,
407 return_disabled=return_disabled,
408 provider_filter=provider_filter,
409 return_protocol_players=return_protocol_players,
410 )
411 if not user_filter
412 or player.player_id in user_filter
413 or player.player_id == current_sendspin_player
414 ]
415
416 @api_command("players/all", required_scope=Scope.PLAYERS_READ)
417 def all_player_states(
418 self,
419 return_unavailable: bool = True,
420 return_disabled: bool = False,
421 provider_filter: str | None = None,
422 return_protocol_players: bool = False,
423 ) -> list[PlayerState]:
424 """
425 Return PlayerState for all registered players.
426
427 :param return_unavailable [bool]: Include unavailable players.
428 :param return_disabled [bool]: Include disabled players.
429 :param provider_filter [str]: Optional filter by provider lookup key.
430 :param return_protocol_players [bool]: Include protocol players (hidden by default).
431
432 :return: List of PlayerState objects.
433 """
434 return [
435 player.state
436 for player in self.all_players(
437 return_unavailable=return_unavailable,
438 return_disabled=return_disabled,
439 provider_filter=provider_filter,
440 return_protocol_players=return_protocol_players,
441 )
442 ]
443
444 def get_player(
445 self,
446 player_id: str,
447 raise_unavailable: bool = False,
448 ) -> Player | None:
449 """
450 Return Player by player_id.
451
452 :param player_id [str]: ID of the player.
453 :param raise_unavailable [bool]: Raise if player is unavailable.
454
455 :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
456 :return: Player object or None.
457 """
458 if player := self._players.get(player_id):
459 if (not player.state.available or not player.state.enabled) and raise_unavailable:
460 msg = f"Player {player_id} is not available"
461 raise PlayerUnavailableError(msg)
462 return player
463 if raise_unavailable:
464 msg = f"Player {player_id} is not available"
465 raise PlayerUnavailableError(msg)
466 return None
467
468 @api_command("players/get", required_scope=Scope.PLAYERS_READ)
469 def get_player_state(
470 self,
471 player_id: str,
472 raise_unavailable: bool = False,
473 ) -> PlayerState | None:
474 """
475 Return PlayerState by player_id.
476
477 :param player_id [str]: ID of the player.
478 :param raise_unavailable [bool]: Raise if player is unavailable.
479
480 :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
481 :return: Player object or None.
482 """
483 current_user = get_current_user()
484 user_filter = (
485 current_user.player_filter
486 if current_user and not has_scope(current_user, Scope.ALL)
487 else None
488 )
489 current_sendspin_player = get_sendspin_player_id()
490 if (
491 current_user
492 and user_filter
493 and player_id not in user_filter
494 and player_id != current_sendspin_player
495 ):
496 msg = f"{current_user.username} does not have access to player {player_id}"
497 raise InsufficientPermissions(msg)
498 if player := self.get_player(player_id, raise_unavailable):
499 return player.state
500 return None
501
502 def get_player_by_name(self, name: str) -> Player | None:
503 """
504 Return Player by name.
505
506 Performs case-insensitive matching against the player's state name
507 (the final name visible in clients and API).
508 If multiple players match, logs a warning and returns the first match.
509
510 :param name: Name of the player.
511 :return: Player object or None.
512 """
513 name_normalized = name.strip().lower()
514 matches: list[Player] = []
515
516 for player in list(self._players.values()):
517 if player.state.name.strip().lower() == name_normalized:
518 matches.append(player)
519
520 if not matches:
521 return None
522
523 if len(matches) > 1:
524 player_ids = [p.player_id for p in matches]
525 self.logger.warning(
526 "players/get_by_name: Multiple players found with name '%s': %s - "
527 "returning first match (%s). "
528 "Consider using the players/get API with player_id instead "
529 "for unambiguous lookups.",
530 name,
531 player_ids,
532 matches[0].player_id,
533 )
534
535 return matches[0]
536
537 @api_command("players/get_by_name", required_scope=Scope.PLAYERS_READ)
538 def get_player_state_by_name(self, name: str) -> PlayerState | None:
539 """
540 Return PlayerState by name.
541
542 :param name: Name of the player.
543 :return: PlayerState object or None.
544 """
545 current_user = get_current_user()
546 user_filter = (
547 current_user.player_filter
548 if current_user and not has_scope(current_user, Scope.ALL)
549 else None
550 )
551 current_sendspin_player = get_sendspin_player_id()
552 if player := self.get_player_by_name(name):
553 if (
554 current_user
555 and user_filter
556 and player.player_id not in user_filter
557 and player.player_id != current_sendspin_player
558 ):
559 msg = f"{current_user.username} does not have access to player {player.player_id}"
560 raise InsufficientPermissions(msg)
561 return player.state
562 return None
563
564 @api_command("players/player_controls", required_scope=Scope.PLAYERS_READ)
565 def player_controls(
566 self,
567 ) -> list[PlayerControl]:
568 """Return all registered playercontrols."""
569 return list(self._controls.values())
570
571 @api_command("players/player_control", required_scope=Scope.PLAYERS_READ)
572 def get_player_control(
573 self,
574 control_id: str,
575 ) -> PlayerControl | None:
576 """
577 Return PlayerControl by control_id.
578
579 :param control_id: ID of the player control.
580 :return: PlayerControl object or None.
581 """
582 if control := self._controls.get(control_id):
583 return control
584 return None
585
586 @api_command("players/sleep_timer/get", required_scope=Scope.PLAYERS_READ)
587 def get_sleep_timer(self, player_id: str) -> float | None:
588 """
589 Return the active sleep timer expiry timestamp for the player.
590
591 :param player_id: Player ID to check.
592 """
593 player = self._get_player_with_redirect(player_id)
594 return player.sleep_timer_expires_at
595
596 @api_command("players/sleep_timer/set", required_scope=Scope.PLAYERS_CONTROL)
597 def set_sleep_timer(self, player_id: str, seconds: int) -> float:
598 """
599 Set a sleep timer for the player.
600
601 :param player_id: Player ID to set the timer for.
602 :param seconds: Delay in seconds before playback is stopped.
603 """
604 if seconds <= 0:
605 msg = "Sleep timer duration must be greater than zero seconds"
606 raise InvalidDataError(msg)
607 player = self._get_player_with_redirect(player_id)
608 try:
609 # guard against absurd durations that overflow the float timestamp math
610 expires_at = time.time() + seconds
611 except OverflowError:
612 msg = "Sleep timer duration is too large to schedule"
613 raise InvalidDataError(msg) from None
614 player.set_sleep_timer_expires_at(expires_at)
615 player.update_state()
616 self._signal_sleep_timer_updated(player, expires_at)
617 self.mass.call_later(
618 seconds,
619 self._handle_sleep_timer_expired,
620 player.player_id,
621 task_id=self._sleep_timer_task_id(player.player_id),
622 )
623 return expires_at
624
625 @api_command("players/sleep_timer/clear", required_scope=Scope.PLAYERS_CONTROL)
626 def clear_sleep_timer(self, player_id: str) -> None:
627 """
628 Clear the active sleep timer for the player.
629
630 :param player_id: Player ID to clear the timer for.
631 """
632 player = self._get_player_with_redirect(player_id)
633 self._clear_sleep_timer(player)
634
635 # Player commands
636
637 @api_command("players/cmd/stop", required_scope=Scope.PLAYERS_CONTROL)
638 @handle_player_command
639 async def cmd_stop(self, player_id: str) -> None:
640 """
641 Send STOP command to given player.
642
643 - player_id: player_id of the player to handle the command.
644 """
645 player = self._get_player_with_redirect(player_id)
646 async with self.get_player_lock(player.player_id, PlayerLockPurpose.PLAYBACK):
647 # Redirect to queue controller if it is active (skip if already in queue command context)
648 if active_queue := self.get_active_queue(player):
649 await self.mass.player_queues.stop(active_queue.queue_id)
650 return
651 # Delegate to internal handler for actual implementation
652 await self._handle_cmd_stop(player.player_id)
653
654 @api_command("players/cmd/play", required_scope=Scope.PLAYERS_CONTROL)
655 @handle_player_command
656 async def cmd_play(self, player_id: str) -> None:
657 """
658 Send PLAY (unpause) command to given player.
659
660 - player_id: player_id of the player to handle the command.
661 """
662 player = self._get_player_with_redirect(player_id)
663 async with self.get_player_lock(player.player_id, PlayerLockPurpose.PLAYBACK):
664 if player.state.playback_state == PlaybackState.PLAYING:
665 self.logger.info(
666 "Ignore PLAY request to player %s: player is already playing",
667 player.state.name,
668 )
669 return
670 # player is not paused: check for queue redirect, then delegate to internal handler
671 if player.state.playback_state != PlaybackState.PAUSED:
672 source = player.state.active_source
673 if active_queue := self.mass.player_queues.get(source or player_id):
674 await self.mass.player_queues.resume(active_queue.queue_id)
675 return
676 # Delegate to internal handler for actual implementation
677 await self._handle_cmd_play(player.player_id)
678
679 @api_command("players/cmd/pause", required_scope=Scope.PLAYERS_CONTROL)
680 @handle_player_command
681 async def cmd_pause(self, player_id: str) -> None:
682 """
683 Send PAUSE command to given player.
684
685 - player_id: player_id of the player to handle the command.
686 """
687 player = self._get_player_with_redirect(player_id)
688 # Redirect to queue controller if it is active (skip if already in queue command context)
689 if active_queue := self.get_active_queue(player):
690 await self.mass.player_queues.pause(active_queue.queue_id)
691 return
692 # Delegate to internal handler for actual implementation
693 await self._handle_cmd_pause(player.player_id)
694
695 @api_command("players/cmd/play_pause", required_scope=Scope.PLAYERS_CONTROL)
696 async def cmd_play_pause(self, player_id: str) -> None:
697 """
698 Toggle play/pause on given player.
699
700 - player_id: player_id of the player to handle the command.
701 """
702 player = self._get_player_with_redirect(player_id)
703 if player.state.playback_state == PlaybackState.PLAYING:
704 await self.cmd_pause(player.player_id)
705 else:
706 await self.cmd_play(player.player_id)
707
708 @api_command("players/cmd/resume", required_scope=Scope.PLAYERS_CONTROL)
709 @handle_player_command
710 async def cmd_resume(
711 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
712 ) -> None:
713 """
714 Send RESUME command to given player.
715
716 Resume (or restart) playback on the player.
717
718 :param player_id: player_id of the player to handle the command.
719 :param source: Optional source to resume.
720 :param media: Optional media to resume.
721 """
722 player = self._get_player_with_redirect(player_id)
723 async with self.get_player_lock(player.player_id, PlayerLockPurpose.PLAYBACK):
724 await self._handle_cmd_resume(player.player_id, source, media)
725
726 @api_command("players/cmd/seek", required_scope=Scope.PLAYERS_CONTROL)
727 @handle_player_command
728 async def cmd_seek(self, player_id: str, position: int) -> None:
729 """
730 Handle SEEK command for given player.
731
732 - player_id: player_id of the player to handle the command.
733 - position: position in seconds to seek to in the current playing item.
734 """
735 player = self._get_player_with_redirect(player_id)
736 if await self._forward_to_external_source(player, SourceControl.SEEK, position):
737 return
738 # Redirect to queue controller if it is active
739 if active_queue := self.get_active_queue(player):
740 await self.mass.player_queues.seek(active_queue.queue_id, position)
741 return
742 # handle command on player/source directly
743 active_source = next(
744 (x for x in player.state.source_list if x.id == player.state.active_source), None
745 )
746 if active_source and not active_source.can_seek:
747 msg = (
748 f"The active source ({active_source.name}) on player "
749 f"{player.display_name} does not support seeking"
750 )
751 raise PlayerCommandFailed(msg)
752 if PlayerFeature.SEEK not in player.state.supported_features:
753 msg = f"Player {player.display_name} does not support seeking"
754 raise UnsupportedFeaturedException(msg)
755 # handle command on player directly
756 await player.seek(position)
757
758 @api_command("players/cmd/shuffle", required_scope=Scope.PLAYERS_CONTROL)
759 @handle_player_command
760 async def cmd_shuffle(
761 self, player_id: str, shuffle_enabled: bool, source_id: str | None = None
762 ) -> None:
763 """
764 Handle SHUFFLE command for given player.
765
766 Applies to whatever the player is playing: a live external source orders its
767 own session, a source the device runs itself orders its own content, and
768 Music Assistant's queue orders its own items.
769
770 :param player_id: player_id of the player to handle the command.
771 :param shuffle_enabled: Whether to play the current content shuffled.
772 :param source_id: Optional source (id) the command is aimed at, as listed in the
773 player's source_list. Given one, the command is refused when that source is
774 no longer playing, so it can never land on whatever took the player since.
775 """
776 player = self._get_player_with_redirect(player_id)
777 active_source_id = self._resolve_command_target(player, source_id)
778 if await self._forward_to_external_source(player, SourceControl.SHUFFLE, shuffle_enabled):
779 return
780 if active_queue := self.get_active_queue(player):
781 await self.mass.player_queues.set_shuffle(active_queue.queue_id, shuffle_enabled)
782 return
783 if active_source := next(
784 (x for x in player.state.source_list if x.id == active_source_id), None
785 ):
786 # the source belongs to the player itself (its own Spotify Connect, a device input)
787 if not active_source.can_shuffle:
788 msg = "This action is (currently) unavailable for this source."
789 raise PlayerCommandFailed(msg)
790 await player.set_shuffle(shuffle_enabled)
791 return
792 msg = f"There is nothing playing on {player.state.name} to shuffle."
793 raise PlayerCommandFailed(msg)
794
795 @api_command("players/cmd/repeat", required_scope=Scope.PLAYERS_CONTROL)
796 @handle_player_command
797 async def cmd_repeat(
798 self, player_id: str, repeat_mode: RepeatMode, source_id: str | None = None
799 ) -> None:
800 """
801 Handle REPEAT command for given player.
802
803 Applies to whatever the player is playing: a live external source repeats
804 within its own session, a source the device runs itself repeats its own
805 content, and Music Assistant's queue repeats its own items.
806
807 :param player_id: player_id of the player to handle the command.
808 :param repeat_mode: The repeat mode to apply.
809 :param source_id: Optional source (id) the command is aimed at, as listed in the
810 player's source_list. Given one, the command is refused when that source is
811 no longer playing, so it can never land on whatever took the player since.
812 """
813 if repeat_mode == RepeatMode.UNKNOWN:
814 # not a mode to set: it is what a source reports when it cannot say
815 raise InvalidCommand("Cannot set an unknown repeat mode")
816 player = self._get_player_with_redirect(player_id)
817 active_source_id = self._resolve_command_target(player, source_id)
818 if await self._forward_to_external_source(player, SourceControl.REPEAT, repeat_mode):
819 return
820 if active_queue := self.get_active_queue(player):
821 await self.mass.player_queues.set_repeat(active_queue.queue_id, repeat_mode)
822 return
823 if active_source := next(
824 (x for x in player.state.source_list if x.id == active_source_id), None
825 ):
826 # the source belongs to the player itself (its own Spotify Connect, a device input)
827 if not active_source.can_repeat:
828 msg = "This action is (currently) unavailable for this source."
829 raise PlayerCommandFailed(msg)
830 await player.set_repeat(repeat_mode)
831 return
832 msg = f"There is nothing playing on {player.state.name} to repeat."
833 raise PlayerCommandFailed(msg)
834
835 @api_command("players/cmd/next", required_scope=Scope.PLAYERS_CONTROL)
836 @handle_player_command
837 async def cmd_next_track(self, player_id: str) -> None:
838 """Handle NEXT TRACK command for given player."""
839 player = self._get_player_with_redirect(player_id)
840 active_source_id = player.state.active_source or player.player_id
841 if await self._forward_to_external_source(player, SourceControl.NEXT):
842 return
843 # Redirect to queue controller if it is active
844 if active_queue := self.get_active_queue(player):
845 await self.mass.player_queues.next(active_queue.queue_id)
846 return
847 if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
848 # player has some other source active and native next/previous support
849 active_source = next(
850 (x for x in player.state.source_list if x.id == active_source_id), None
851 )
852 if active_source and active_source.can_next_previous:
853 await player.next_track()
854 return
855 msg = "This action is (currently) unavailable for this source."
856 raise PlayerCommandFailed(msg)
857 # Player does not support next/previous feature
858 msg = f"Player {player.state.name} does not support skipping to the next track."
859 raise UnsupportedFeaturedException(msg)
860
861 @api_command("players/cmd/previous", required_scope=Scope.PLAYERS_CONTROL)
862 @handle_player_command
863 async def cmd_previous_track(self, player_id: str) -> None:
864 """Handle PREVIOUS TRACK command for given player."""
865 player = self._get_player_with_redirect(player_id)
866 active_source_id = player.state.active_source or player.player_id
867 if await self._forward_to_external_source(player, SourceControl.PREVIOUS):
868 return
869 # Redirect to queue controller if it is active
870 if active_queue := self.get_active_queue(player):
871 await self.mass.player_queues.previous(active_queue.queue_id)
872 return
873 if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
874 # player has some other source active and native next/previous support
875 active_source = next(
876 (x for x in player.state.source_list if x.id == active_source_id), None
877 )
878 if active_source and active_source.can_next_previous:
879 await player.previous_track()
880 return
881 msg = "This action is (currently) unavailable for this source."
882 raise PlayerCommandFailed(msg)
883 # Player does not support next/previous feature
884 msg = f"Player {player.state.name} does not support skipping to the previous track."
885 raise UnsupportedFeaturedException(msg)
886
887 @api_command("players/cmd/power", required_scope=Scope.PLAYERS_CONTROL)
888 @handle_player_command(lock=PlayerLockPurpose.PLAYBACK)
889 async def cmd_power(self, player_id: str, powered: bool) -> None:
890 """
891 Send POWER command to given player.
892
893 :param player_id: player_id of the player to handle the command.
894 :param powered: bool if player should be powered on or off.
895 """
896 # Power is serialized with PLAYBACK because powering on a sync/group player
897 # forms the group (and powering off dissolves it) - this must not race with
898 # play_media / cmd_resume / cmd_set_members on the same player.
899 await self._handle_cmd_power(player_id, powered)
900
901 @api_command("players/cmd/volume_set", required_scope=Scope.PLAYERS_CONTROL)
902 @handle_player_command
903 async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
904 """
905 Send VOLUME_SET command to given player.
906
907 :param player_id: player_id of the player to handle the command.
908 :param volume_level: volume level (0..100) to set on the player.
909 """
910 volume_level = max(0, min(100, volume_level))
911 # record the level and invalidate the group volume state up front, before waiting
912 # for the volume lock: a command that is still queued would otherwise undo what a
913 # command issued after it already recorded.
914 # skip for group players since _handle_cmd_volume_set redirects those to
915 # set_group_volume which creates/uses the snapshot itself
916 if (player := self.get_player(player_id)) and player.type != PlayerType.GROUP:
917 self._record_volume_target(player, volume_level)
918 self._invalidate_group_volume_snapshot(player_id)
919 async with self.get_player_lock(player_id, PlayerLockPurpose.VOLUME):
920 await self._handle_cmd_volume_set(player_id, volume_level, record_target=False)
921
922 @api_command("players/cmd/volume_up", required_scope=Scope.PLAYERS_CONTROL)
923 @handle_player_command
924 async def cmd_volume_up(self, player_id: str) -> None:
925 """
926 Send VOLUME_UP command to given player.
927
928 - player_id: player_id of the player to handle the command.
929 """
930 if not (player := self.get_player(player_id)):
931 return
932 if player.type == PlayerType.GROUP:
933 await self.cmd_group_volume_up(player_id)
934 return
935 current_volume = self._volume_nudge_base(player) or 0
936 new_volume = min(100, current_volume + self._get_volume_step(current_volume))
937 await self.cmd_volume_set(player_id, new_volume)
938
939 @api_command("players/cmd/volume_down", required_scope=Scope.PLAYERS_CONTROL)
940 @handle_player_command
941 async def cmd_volume_down(self, player_id: str) -> None:
942 """
943 Send VOLUME_DOWN command to given player.
944
945 - player_id: player_id of the player to handle the command.
946 """
947 if not (player := self.get_player(player_id)):
948 return
949 if player.type == PlayerType.GROUP:
950 await self.cmd_group_volume_down(player_id)
951 return
952 current_volume = self._volume_nudge_base(player) or 0
953 new_volume = max(0, current_volume - self._get_volume_step(current_volume))
954 await self.cmd_volume_set(player_id, new_volume)
955
956 @api_command("players/cmd/group_volume", required_scope=Scope.PLAYERS_CONTROL)
957 @handle_player_command
958 async def cmd_group_volume(
959 self,
960 player_id: str,
961 volume_level: int,
962 ) -> None:
963 """
964 Handle adjusting the overall/group volume to a playergroup (or synced players).
965
966 Will set a new (overall) volume level to a group player or syncgroup.
967
968 :param player_id: Player ID of group player or syncleader to handle the command.
969 :param volume_level: Volume level (0..100) to set to the group.
970 """
971 player = self.get_player(player_id, True)
972 assert player is not None # for type checker
973 group_player = self._resolve_group_volume_player(player)
974 if group_player is None:
975 # treat as normal player volume change
976 await self.cmd_volume_set(player_id, volume_level)
977 return
978 async with self.get_player_lock(group_player.player_id, PlayerLockPurpose.GROUP_VOLUME):
979 await self.set_group_volume(group_player, volume_level)
980
981 @api_command("players/cmd/group_volume_up", required_scope=Scope.PLAYERS_CONTROL)
982 @handle_player_command
983 async def cmd_group_volume_up(self, player_id: str) -> None:
984 """
985 Send VOLUME_UP command to given playergroup.
986
987 - player_id: player_id of the player to handle the command.
988 """
989 player = self.get_player(player_id, True)
990 assert player is not None # for type checker
991 # step from the volume of the group as a whole, which is not the volume of the
992 # addressed player when the command is addressed to one of its synced members
993 group_player = self._resolve_group_volume_player(player) or player
994 async with self.get_player_lock(group_player.player_id, PlayerLockPurpose.GROUP_VOLUME):
995 cur_volume = self._group_volume_nudge_base(group_player)
996 if cur_volume is None:
997 return
998 new_volume = min(100, cur_volume + self._get_volume_step(cur_volume))
999 await self.cmd_group_volume(player_id, new_volume)
1000
1001 @api_command("players/cmd/group_volume_down", required_scope=Scope.PLAYERS_CONTROL)
1002 @handle_player_command
1003 async def cmd_group_volume_down(self, player_id: str) -> None:
1004 """
1005 Send VOLUME_DOWN command to given playergroup.
1006
1007 - player_id: player_id of the player to handle the command.
1008 """
1009 player = self.get_player(player_id, True)
1010 assert player is not None # for type checker
1011 group_player = self._resolve_group_volume_player(player) or player
1012 async with self.get_player_lock(group_player.player_id, PlayerLockPurpose.GROUP_VOLUME):
1013 cur_volume = self._group_volume_nudge_base(group_player)
1014 if cur_volume is None:
1015 return
1016 new_volume = max(0, cur_volume - self._get_volume_step(cur_volume))
1017 await self.cmd_group_volume(player_id, new_volume)
1018
1019 @api_command("players/cmd/group_volume_mute", required_scope=Scope.PLAYERS_CONTROL)
1020 @handle_player_command
1021 async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
1022 """
1023 Handle muting a playergroup (or synced players) as a whole.
1024
1025 A group player or syncleader mutes all of its members, a synced player is
1026 redirected to its syncleader and an ungrouped player is muted on its own.
1027
1028 :param player_id: Player ID of the player to handle the command.
1029 :param muted: bool if the group should be muted.
1030 """
1031 player = self.get_player(player_id, True)
1032 assert player is not None # for type checker
1033 if player.state.type == PlayerType.GROUP or player.state.group_members:
1034 # dedicated group player or sync leader
1035 await self._mute_group_members(player, muted)
1036 return
1037 if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
1038 # redirect to sync leader
1039 await self._mute_group_members(sync_leader, muted)
1040 return
1041 # treat as normal player mute
1042 await self.cmd_volume_mute(player_id, muted)
1043
1044 @api_command("players/cmd/volume_mute", required_scope=Scope.PLAYERS_CONTROL)
1045 @handle_player_command(lock=PlayerLockPurpose.VOLUME)
1046 async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
1047 """
1048 Send VOLUME_MUTE command to given player.
1049
1050 - player_id: player_id of the player to handle the command.
1051 - muted: bool if player should be muted.
1052 """
1053 player = self.get_player(player_id, True)
1054 assert player
1055
1056 if player.type == PlayerType.GROUP:
1057 # redirect to special group mute control
1058 await self.cmd_group_volume_mute(player_id, muted)
1059 return
1060
1061 # clearing the mute lock may not depend on mute support, otherwise a lock set
1062 # while the player still had a mute control would outlive a control change
1063 if not muted:
1064 player.extra_data.pop(ATTR_MUTE_LOCK, None)
1065
1066 mute_control = player.mute_control
1067 if mute_control == PLAYER_CONTROL_NONE:
1068 raise UnsupportedFeaturedException(
1069 f"Player {player.state.name} does not support muting"
1070 )
1071
1072 # Set mute lock for players in a group
1073 # This prevents auto-unmute when group volume changes
1074 had_mute_lock = ATTR_MUTE_LOCK in player.extra_data
1075 if muted and self._is_in_group(player.state):
1076 player.extra_data[ATTR_MUTE_LOCK] = True
1077
1078 try:
1079 await self._handle_cmd_volume_mute(player, mute_control, muted)
1080 except Exception:
1081 # a mute that did not happen may not leave a lock behind, but a lock
1082 # earned by an earlier successful mute must survive
1083 if not had_mute_lock:
1084 player.extra_data.pop(ATTR_MUTE_LOCK, None)
1085 raise
1086
1087 @handle_player_command
1088 async def play_media(self, player_id: str, media: PlayerMedia) -> None:
1089 """
1090 Handle PLAY MEDIA on given player.
1091
1092 :param player_id: player_id of the player to handle the command.
1093 :param media: The Media that needs to be played on the player.
1094 """
1095 # An explicit play_media on a captured player honors the player's
1096 # CONF_PLAY_MEDIA_OVERRIDES_GROUP preference (default: True) — the
1097 # player is released from its group/sync first, then plays the media
1098 # standalone. With the preference off, behavior falls back to the
1099 # legacy "redirect to group leader" path below.
1100 # Note: the release step runs outside the PLAYBACK lock to avoid an
1101 # AB-BA cycle with cmd_set_members(group), which acquires lock(group)
1102 # then lock(sync_leader) via the sync_group provider.
1103 target_player = self.get_player(player_id, True)
1104 if target_player is not None and (
1105 target_player.state.synced_to or target_player.state.active_group
1106 ):
1107 override = bool(
1108 self.mass.config.get_raw_player_config_value(
1109 target_player.player_id,
1110 CONF_PLAY_MEDIA_OVERRIDES_GROUP,
1111 True,
1112 )
1113 )
1114 if override:
1115 await self._release_player_for_play_media(target_player)
1116 async with self.get_player_lock(
1117 target_player.player_id, PlayerLockPurpose.PLAYBACK
1118 ):
1119 await self._handle_play_media(target_player.player_id, media)
1120 return
1121 player = self._get_player_with_redirect(player_id)
1122 async with self.get_player_lock(player.player_id, PlayerLockPurpose.PLAYBACK):
1123 await self._handle_play_media(player.player_id, media)
1124
1125 @api_command("players/cmd/select_sound_mode", required_scope=Scope.PLAYERS_CONTROL)
1126 @handle_player_command
1127 async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
1128 """
1129 Handle SELECT SOUND MODE command on given player.
1130
1131 - player_id: player_id of the player to handle the command
1132 - sound_mode: The ID of the sound mode that needs to be activated/selected.
1133 """
1134 player = self.get_player(player_id, True)
1135 assert player is not None # for type checking
1136
1137 if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
1138 raise UnsupportedFeaturedException(
1139 f"Player {player.display_name} does not support sound mode selection"
1140 )
1141
1142 prev_sound_mode = player.active_sound_mode
1143 if sound_mode == prev_sound_mode:
1144 return
1145
1146 # basic check if sound mode is valid for player
1147 if not any(x for x in player.sound_mode_list if x.id == sound_mode):
1148 raise PlayerCommandFailed(
1149 f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
1150 )
1151
1152 # forward to player
1153 await player.select_sound_mode(sound_mode)
1154
1155 @api_command("players/cmd/set_option", required_scope=Scope.PLAYERS_CONTROL)
1156 @handle_player_command
1157 async def set_option(
1158 self, player_id: str, option_key: str, option_value: PlayerOptionValueType
1159 ) -> None:
1160 """
1161 Handle SET_OPTION command on given player.
1162
1163 - player_id: player_id of the player to handle the command
1164 - option_key: The key of the player option that needs to be activated/selected.
1165 - option_value: The new value of the player option.
1166 """
1167 player = self.get_player(player_id, True)
1168 assert player is not None # for type checking
1169
1170 if PlayerFeature.OPTIONS not in player.supported_features:
1171 raise UnsupportedFeaturedException(
1172 f"Player {player.display_name} does not support set_option"
1173 )
1174
1175 prev_player_option = next((x for x in player.options if x.key == option_key), None)
1176 if not prev_player_option:
1177 return
1178 if prev_player_option.value == option_value:
1179 return
1180
1181 if prev_player_option.read_only:
1182 raise UnsupportedFeaturedException(
1183 f"Player {player.display_name} option {option_key} is read-only"
1184 )
1185
1186 # forward to player
1187 await player.set_option(option_key=option_key, option_value=option_value)
1188
1189 @api_command("players/cmd/select_source", required_scope=Scope.PLAYERS_CONTROL)
1190 @handle_player_command
1191 async def select_source(self, player_id: str, source: str | None) -> None:
1192 """
1193 Handle SELECT SOURCE command on given player.
1194
1195 - player_id: player_id of the player to handle the command.
1196 - source: The ID of the source that needs to be activated/selected.
1197 """
1198 if source is None:
1199 source = player_id # default to MA queue source
1200 player = self.get_player(player_id, True)
1201 assert player is not None # for type checking
1202 # If player is currently grouped, handle it so the source switch can proceed.
1203 # This allows external sources (e.g. Spotify Connect, AirPlay) to take over a grouped player.
1204 if player.state.active_group and (
1205 group_player := self.get_player(player.state.active_group)
1206 ):
1207 if player_id in group_player.state.static_group_members:
1208 # player is a static member of a permanent group - stop the group
1209 # and power it off if supported, rather than removing the member
1210 await self._handle_cmd_stop(group_player.player_id)
1211 if group_player.state.power_control != PLAYER_CONTROL_NONE:
1212 await self._handle_cmd_power(group_player.player_id, False)
1213 else:
1214 await self.cmd_ungroup(player_id)
1215 elif player.state.synced_to:
1216 await self.cmd_ungroup(player_id)
1217 # Delegate to internal handler for actual implementation
1218 async with self.get_player_lock(player_id, PlayerLockPurpose.PLAYBACK):
1219 await self._handle_select_source(player_id, source)
1220
1221 async def deselect_source(
1222 self,
1223 player_id: str,
1224 stop_playback: bool = True,
1225 provider_instance_id: str | None = None,
1226 source_id: str | None = None,
1227 playback_session_id: str | None = None,
1228 ) -> None:
1229 """
1230 Give up the source a player was playing, and stop it.
1231
1232 Call this from a plugin when its session ends — the player has nothing to play
1233 any more, so it goes back to reporting its own queue rather than a source that
1234 has gone. Pausing is not this: a paused source keeps the player, so that its
1235 session survives being resumed.
1236
1237 :param player_id: player_id of the player to give the source up on.
1238 :param stop_playback: Whether to stop the player as well. Pass False when the
1239 caller has already stopped it, or is about to.
1240 :param provider_instance_id: Optional provider instance that owns the source session.
1241 :param source_id: Optional provider-scoped source id that owns the source session.
1242 :param playback_session_id: Optional playback session expected to own the player.
1243 """
1244 async with self.get_player_lock(player_id, PlayerLockPurpose.PLAYBACK):
1245 player = self.get_player(player_id, raise_unavailable=False)
1246 if not player:
1247 return
1248 session = self._source_sessions.get(player_id)
1249 active_provider_instance_id = session.provider_instance_id if session else None
1250 active_source_id = session.source_id if session else None
1251 active_playback_session_id = session.playback_session_id if session else None
1252 if provider_instance_id is not None and (
1253 active_provider_instance_id != provider_instance_id
1254 or (source_id is not None and active_source_id != source_id)
1255 or playback_session_id is None
1256 or active_playback_session_id != playback_session_id
1257 ):
1258 self.logger.debug(
1259 "Ignoring source release for provider %s source %s session %s on player %s: "
1260 "active source is provider %s source %s session %s",
1261 provider_instance_id,
1262 source_id,
1263 playback_session_id,
1264 player_id,
1265 active_provider_instance_id,
1266 active_source_id,
1267 active_playback_session_id,
1268 )
1269 return
1270 try:
1271 if stop_playback:
1272 with suppress(PlayerCommandFailed, PlayerUnavailableError, RuntimeError):
1273 await self._handle_cmd_stop(player_id)
1274 finally:
1275 if session is not None:
1276 current_session = self._source_sessions.get(player_id)
1277 if (
1278 current_session is session
1279 and current_session.playback_session_id == active_playback_session_id
1280 ):
1281 await self._release_audio_source(player_id)
1282 else:
1283 self.logger.debug(
1284 "Not releasing provider %s source %s session %s on player %s: "
1285 "the source changed while playback was stopping",
1286 provider_instance_id,
1287 source_id,
1288 playback_session_id,
1289 player_id,
1290 )
1291
1292 async def release_provider_sources(self, provider_instance_id: str) -> None:
1293 """
1294 Give up the sources a plugin owns on every player playing one.
1295
1296 Call this when the plugin goes away: a session outliving its provider leaves
1297 the player naming a source that can no longer be streamed nor handed back,
1298 with its own queue held inactive behind it.
1299
1300 :param provider_instance_id: Instance id of the plugin that is going away.
1301 """
1302 sessions = [
1303 (player_id, session.source_id, session.playback_session_id)
1304 for player_id, session in self._source_sessions.items()
1305 if session.provider_instance_id == provider_instance_id
1306 ]
1307 for player_id, source_id, playback_session_id in sessions:
1308 self.logger.debug(
1309 "Provider %s is unloading, releasing its source on player %s",
1310 provider_instance_id,
1311 player_id,
1312 )
1313 await self.deselect_source(
1314 player_id,
1315 provider_instance_id=provider_instance_id,
1316 source_id=source_id,
1317 playback_session_id=playback_session_id,
1318 )
1319
1320 @handle_player_command(lock=PlayerLockPurpose.PLAYBACK)
1321 async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
1322 """
1323 Handle enqueuing of a next media item on the player.
1324
1325 :param player_id: player_id of the player to handle the command.
1326 :param media: The Media that needs to be enqueued on the player.
1327 :raises UnsupportedFeaturedException: if the player does not support enqueueing.
1328 :raises PlayerUnavailableError: if the player is not available.
1329 """
1330 # Note: No group redirect needed here as enqueue doesn't use _get_player_with_redirect
1331 # Delegate to internal handler for actual implementation
1332 await self._handle_enqueue_next_media(player_id, media)
1333
1334 @api_command("players/cmd/set_members", required_scope=Scope.PLAYERS_CONTROL)
1335 async def cmd_set_members(
1336 self,
1337 target_player: str,
1338 player_ids_to_add: list[str] | None = None,
1339 player_ids_to_remove: list[str] | None = None,
1340 ) -> None:
1341 """
1342 Join/unjoin given player(s) to/from target player.
1343
1344 Will add the given player(s) to the target player (sync leader or group player).
1345
1346 :param target_player: player_id of the syncgroup leader or group player.
1347 :param player_ids_to_add: List of player_id's to add to the target player.
1348 :param player_ids_to_remove: List of player_id's to remove from the target player.
1349
1350 :raises UnsupportedFeaturedException: if the target player does not support grouping.
1351 :raises PlayerUnavailableError: if the target player is not available.
1352 """
1353 parent_player: Player | None = self.get_player(target_player, True)
1354 assert parent_player is not None # for type checking
1355 if PlayerFeature.SET_MEMBERS not in parent_player.state.supported_features:
1356 msg = f"Player {parent_player.name} does not support group commands"
1357 raise UnsupportedFeaturedException(msg)
1358
1359 # if the target player is a member of an active group player (e.g. a syncgroup),
1360 # redirect the command to that group player so it can manage the member change
1361 if (
1362 parent_player.type != PlayerType.GROUP
1363 and parent_player.state.active_group
1364 and (group_player := self.get_player(parent_player.state.active_group))
1365 and group_player.type == PlayerType.GROUP
1366 and PlayerFeature.SET_MEMBERS in group_player.state.supported_features
1367 ):
1368 self.logger.debug(
1369 "Redirecting set_members from %s to its group player %s",
1370 parent_player.name,
1371 group_player.name,
1372 )
1373 await self.cmd_set_members(
1374 parent_player.state.active_group, player_ids_to_add, player_ids_to_remove
1375 )
1376 return
1377
1378 if parent_player.synced_to:
1379 # handle edge case: target player is already synced itself to another player
1380 # automatically ungroup it first and wait for state to propagate
1381 await self._auto_ungroup_if_synced(parent_player, "setting members")
1382
1383 # Use lock for playback commands to prevent protocol switches from
1384 # racing with concurrent play_media / play_index / resume calls.
1385 async with self.get_player_lock(parent_player.player_id, PlayerLockPurpose.PLAYBACK):
1386 await self._handle_set_members(parent_player, player_ids_to_add, player_ids_to_remove)
1387
1388 @api_command("players/cmd/group", required_scope=Scope.PLAYERS_CONTROL)
1389 @handle_player_command
1390 async def cmd_group(self, player_id: str, target_player: str) -> None:
1391 """
1392 Handle GROUP command for given player.
1393
1394 Join/add the given player(id) to the given (leader) player/sync group.
1395 If the target player itself is already synced to another player, this may fail.
1396 If the player can not be synced with the given target player, this may fail.
1397
1398 NOTE: This is a convenience helper for cmd_set_members.
1399
1400 :param player_id: player_id of the player to handle the command.
1401 :param target_player: player_id of the syncgroup leader or group player.
1402
1403 :raises UnsupportedFeaturedException: if the target player does not support grouping.
1404 :raises PlayerCommandFailed: if the target player is already synced to another player.
1405 :raises PlayerUnavailableError: if the target player is not available.
1406 :raises PlayerCommandFailed: if the player is already grouped to another player.
1407 """
1408 await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
1409
1410 @api_command("players/cmd/group_many", required_scope=Scope.PLAYERS_CONTROL)
1411 async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
1412 """
1413 Join given player(s) to target player.
1414
1415 Will add the given player(s) to the target player (sync leader or group player).
1416 This is a (deprecated) alias for cmd_set_members.
1417 """
1418 await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
1419
1420 @api_command("players/cmd/ungroup", required_scope=Scope.PLAYERS_CONTROL)
1421 @handle_player_command
1422 async def cmd_ungroup(self, player_id: str) -> None:
1423 """
1424 Handle UNGROUP command for given player.
1425
1426 Remove the given player from any (sync)groups it currently is synced to.
1427 If the player is not currently grouped to any other player,
1428 this will silently be ignored.
1429 """
1430 if not (player := self.get_player(player_id)):
1431 self.logger.warning("Player %s is not available", player_id)
1432 return
1433
1434 # Ungroup on a group player is interpreted as 'release the captured
1435 # session entirely'. This avoids the "Cannot remove static member"
1436 # error path when transfer_queue or HA's unjoin asks us to release a
1437 # group that has static members.
1438 if player.state.type == PlayerType.GROUP:
1439 if player.state.power_control != PLAYER_CONTROL_NONE:
1440 await self._handle_cmd_power(player.player_id, False)
1441 else:
1442 await self._handle_cmd_stop(player.player_id)
1443 return
1444
1445 if player.state.active_group:
1446 group = self.get_player(player.state.active_group)
1447 is_static_member = group is not None and player_id in group.state.static_group_members
1448 if is_static_member:
1449 # Static members can't be released individually — recurse so
1450 # the group-player branch above stops/dissolves the session.
1451 if group is not None:
1452 await self.cmd_ungroup(group.player_id)
1453 return
1454 # dynamic or non-static member — remove just this player
1455 await self.cmd_set_members(player.state.active_group, player_ids_to_remove=[player_id])
1456 return
1457
1458 if player.state.synced_to:
1459 # player is a sync member
1460 await self.cmd_set_members(player.state.synced_to, player_ids_to_remove=[player_id])
1461 return
1462
1463 if player.state.group_members:
1464 # player is a sync leader (a non-group player with synced followers).
1465 # Remove only the leader itself: _handle_set_members will either transfer
1466 # leadership to a remaining member (keeping playback alive) or, when no
1467 # members remain / nothing is playing, dissolve the group and stop.
1468 await self.cmd_set_members(player.player_id, player_ids_to_remove=[player.player_id])
1469 return
1470 # unjoin from any dynamic sync groups if we're currently in one (edge case)
1471 # this is in particular used for the Home Assistant integration which does
1472 # not have a set_members command and only supports a single unjoin command
1473 for player in self.iter_players(False):
1474 if not player.state.group_members or player.state.synced_to:
1475 continue
1476 if PlayerFeature.SET_MEMBERS not in player.state.supported_features:
1477 continue
1478 if player_id in player.state.static_group_members:
1479 continue
1480 if player_id in player.state.group_members:
1481 await self.cmd_set_members(player.player_id, player_ids_to_remove=[player_id])
1482 return
1483
1484 @api_command("players/cmd/ungroup_many", required_scope=Scope.PLAYERS_CONTROL)
1485 async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
1486 """Handle UNGROUP command for all the given players."""
1487 for player_id in list(player_ids):
1488 await self.cmd_ungroup(player_id)
1489
1490 @api_command("players/create_group_player", required_scope=Scope.CONFIG_PLAYERS_WRITE)
1491 async def create_group_player(
1492 self, provider: str, name: str, members: list[str], dynamic: bool = True
1493 ) -> Player:
1494 """
1495 Create a new (permanent) Group Player.
1496
1497 :param provider: The provider (id) to create the group player for.
1498 :param name: Name of the new group player.
1499 :param members: List of player ids to add to the group.
1500 :param dynamic: Whether the group is dynamic (members can change).
1501 """
1502 if not (provider_instance := self.mass.get_provider(provider)):
1503 raise ProviderUnavailableError(f"Provider {provider} not found")
1504 provider_instance = cast("PlayerProvider", provider_instance)
1505 if ProviderFeature.CREATE_GROUP_PLAYER not in provider_instance.supported_features:
1506 raise UnsupportedFeaturedException(
1507 f"Provider {provider} does not support creating group players"
1508 )
1509 return await provider_instance.create_group_player(name, members, dynamic)
1510
1511 @api_command("players/remove_group_player", required_scope=Scope.CONFIG_PLAYERS_WRITE)
1512 async def remove_group_player(self, player_id: str) -> None:
1513 """Remove a group player."""
1514 if not (player := self.get_player(player_id)):
1515 # we simply permanently delete the player by wiping its config
1516 self.mass.config.remove(f"players/{player_id}")
1517 return
1518 if player.state.type != PlayerType.GROUP:
1519 raise UnsupportedFeaturedException(f"Player {player.state.name} is not a group player")
1520 player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
1521 await player.provider.remove_group_player(player_id)
1522
1523 @api_command("players/add_currently_playing_to_favorites", required_scope=Scope.LIBRARY_WRITE)
1524 async def add_currently_playing_to_favorites(self, player_id: str) -> None:
1525 """
1526 Add the currently playing item/track on given player to the favorites.
1527
1528 This tries to resolve the currently playing media to an actual media item
1529 and add that to the favorites in the library. Will raise an error if the
1530 player is not currently playing anything or if the currently playing media
1531 can not be resolved to a media item.
1532 """
1533 player = self._get_player_with_redirect(player_id)
1534 # handle mass player queue active
1535 if mass_queue := self.get_active_queue(player):
1536 if not (current_item := mass_queue.current_item) or not current_item.media_item:
1537 raise PlayerCommandFailed("No current item to add to favorites")
1538 # if we're playing a radio station, try to resolve the currently playing track
1539 if current_item.media_item.media_type == MediaType.RADIO:
1540 if not (
1541 (streamdetails := mass_queue.current_item.streamdetails)
1542 and (stream_title := streamdetails.stream_title)
1543 and " - " in stream_title
1544 ):
1545 # no stream title available, so we can't resolve the track
1546 # this can happen if the radio station does not provide metadata
1547 # or there's a commercial break
1548 # Possible future improvement could be to actually detect the song with a
1549 # shazam-like approach.
1550 raise PlayerCommandFailed("No current item to add to favorites")
1551 # send the streamtitle into a global search query
1552 search_artist, search_title_title = stream_title.split(" - ", 1)
1553 # strip off any additional comments in the title (such as from Radio Paradise)
1554 search_title_title = search_title_title.split(" | ")[0].strip()
1555 if track := await self.mass.music.get_track_by_name(
1556 search_title_title, search_artist
1557 ):
1558 # we found a track, so add it to the favorites
1559 await self.mass.music.add_item_to_favorites(track)
1560 return
1561 # we could not resolve the track, so raise an error
1562 raise PlayerCommandFailed("No current item to add to favorites")
1563
1564 # else: any other media item, just add it to the favorites directly
1565 await self.mass.music.add_item_to_favorites(current_item.media_item)
1566 return
1567
1568 # guard for player with no active source
1569 if not player.state.active_source:
1570 raise PlayerCommandFailed("Player has no active source")
1571 # handle other source active using the current_media with uri
1572 if current_media := player.state.current_media:
1573 # prefer the uri of the current media item
1574 if current_media.uri:
1575 with suppress(MusicAssistantError):
1576 await self.mass.music.add_item_to_favorites(current_media.uri)
1577 return
1578 # fallback to search based on artist and title (and album if available)
1579 if current_media.artist and current_media.title:
1580 if track := await self.mass.music.get_track_by_name(
1581 current_media.title,
1582 current_media.artist,
1583 current_media.album,
1584 ):
1585 # we found a track, so add it to the favorites
1586 await self.mass.music.add_item_to_favorites(track)
1587 return
1588 # if we reach here, we could not resolve the currently playing item
1589 raise PlayerCommandFailed("No current item to add to favorites")
1590
1591 async def register(self, player: Player) -> None:
1592 """Register a player on the Player Controller."""
1593 if self._teardown_in_progress(player):
1594 return
1595
1596 # Use lock to prevent race conditions during concurrent player registrations
1597 async with self._register_lock:
1598 player_id = player.player_id
1599
1600 if player_id in self._players:
1601 msg = f"Player {player_id} is already registered!"
1602 raise AlreadyRegisteredError(msg)
1603
1604 # ignore disabled players
1605 if not player.state.enabled:
1606 return
1607
1608 if player.type not in (PlayerType.GROUP, PlayerType.STEREO_PAIR):
1609 await self._resolve_mac_addresses(player)
1610
1611 # restore 'fake' power state from cache if available.
1612 # Group players intentionally do NOT restore their fake-power
1613 # state across restarts: at boot there is no sync session yet, so
1614 # a restored 'powered=True' would put the group in an inconsistent
1615 # 'active without captured session' state where children appear
1616 # owned by a group that has no leader. Users who want their
1617 # 'group captured' state preserved across restarts would need
1618 # explicit session restoration which is out of scope here.
1619 if player.type != PlayerType.GROUP:
1620 cached_value = await self.mass.cache.get(
1621 key=player.player_id,
1622 provider=self.domain,
1623 category=CACHE_CATEGORY_PLAYER_POWER,
1624 default=False,
1625 )
1626 if cached_value is not None:
1627 player.extra_data[ATTR_FAKE_POWER] = cached_value
1628
1629 # _registration_aborted below only works once the player is in the registry;
1630 # until then the unregister pass of a provider unload cannot see it, so re-check
1631 # the guard from the top of this method, which the awaits above may have staled
1632 if self._teardown_in_progress(player):
1633 return
1634
1635 # finally actually register it
1636
1637 # Despite the fact that the player is not fully ready yet
1638 # (config not loaded, protocol links not evaluated),
1639 # we already add it to the _players dict here because we
1640 # want to make sure the player is available in the controller
1641 # during the rest of the registration process
1642 # (such as when fetching config or evaluating protocol links).
1643 # We use the 'initialized' attribute to indicate that the player
1644 # is still in the process of being registered so we can filter it out where needed.
1645 self._players[player_id] = player
1646 try:
1647 # update state to ensure player.state reflects the final attributes
1648 # (e.g. player type) set after super().__init__() in the player subclass,
1649 # before we fetch config (which relies on state.type for entry resolution)
1650 player.update_state(signal_event=False)
1651 # ensure we fetch and set the latest/full config for the player
1652 player_config = await self.mass.config.get_player_config(player_id)
1653 if self._registration_aborted(player):
1654 return
1655 player.set_config(player_config)
1656 # update state again now that config is loaded
1657 player.update_state(signal_event=False)
1658 self._save_underlying_player_id(player)
1659 # call hook after the player is registered and config is set
1660 await player.on_config_updated()
1661 if self._registration_aborted(player):
1662 return
1663
1664 # Handle protocol linking
1665 self._evaluate_protocol_links(player)
1666 except Exception, asyncio.CancelledError:
1667 # a player whose setup failed never becomes initialized, which hides it
1668 # everywhere while it keeps blocking every later registration of the same id.
1669 # Cancellation counts too: a re-triggered provider discovery aborts the task
1670 # this runs in. Only roll back while the player is still ours: an unregister
1671 # may have dropped it already, and it unloads the player itself.
1672 if self._players.get(player_id) is player:
1673 del self._players[player_id]
1674 # players claim resources in their constructor (event subscriptions,
1675 # connections) that only on_unload releases. Best-effort, so a failing
1676 # teardown cannot mask the error that got us here.
1677 try:
1678 await player.on_unload()
1679 except Exception:
1680 self.logger.exception("Error unloading player %s", player.name)
1681 raise
1682
1683 # now we're ready to signal the player is added and available
1684 player.set_initialized()
1685 self.logger.info(
1686 "Player (type %s) registered: %s/%s",
1687 player.state.type.value,
1688 player_id,
1689 player.state.name,
1690 )
1691 # signal event that a player was added
1692 if player.state.type != PlayerType.PROTOCOL:
1693 self.mass.signal_event(
1694 EventType.PLAYER_ADDED, object_id=player.player_id, data=player
1695 )
1696 # register playerqueue for this player (if not a protocol player)
1697 if player.state.type != PlayerType.PROTOCOL:
1698 await self.mass.player_queues.on_player_register(player)
1699 if self._registration_aborted(player):
1700 # the queue restore outlived the unregister that already cleaned it up,
1701 # so drop the queue we just recreated for a player that is gone
1702 self.mass.player_queues.on_player_remove(player_id, permanent=False)
1703
1704 # Schedule debounced update of all players since can_group_with values may change
1705 # when a new player is added (provider IDs expand to include the new player)
1706 self._schedule_update_all_players(2)
1707
1708 async def register_or_update(self, player: Player) -> None:
1709 """Register a new player on the controller or update existing one."""
1710 if self._teardown_in_progress(player):
1711 return
1712
1713 # the register lock ensures a replacement is never swapped in while register()
1714 # is still setting the player up
1715 async with self._register_lock:
1716 if (existing := self._players.get(player.player_id)) is not None:
1717 # a protocol player is hidden behind its parent and owns no queue, every
1718 # other player does. Reading the role the player is leaving off that
1719 # published reality keeps it independent of when the player's state was
1720 # last recalculated, which providers cannot control (they flip the type
1721 # before this call).
1722 was_protocol = self.mass.player_queues.get(player.player_id) is None
1723 becomes_protocol = player.type == PlayerType.PROTOCOL
1724 role_changed = becomes_protocol != was_protocol
1725 if role_changed:
1726 # release the topology of the role the player is leaving
1727 self._cleanup_player_type_transition(
1728 existing, becomes_protocol=becomes_protocol
1729 )
1730 self._players[player.player_id] = player
1731 if existing is not player:
1732 # a fresh instance starts out with a base config only, so it needs
1733 # the config the registration resolved before it can be used
1734 player.set_config(existing.config)
1735 await player.on_config_updated()
1736 if self._registration_aborted(player):
1737 return
1738 # the replacement takes over the identity of an already registered
1739 # player, so it must be marked initialized as well
1740 player.set_initialized()
1741 player.update_state()
1742 # the derived-transport edge may have been set/revoked after the
1743 # initial registration (e.g. via a bridge claim)
1744 self._save_underlying_player_id(player)
1745 if role_changed:
1746 await self._finish_player_type_transition(player)
1747 # Also schedule update when replacing existing player
1748 self._schedule_update_all_players()
1749 return
1750
1751 await self.register(player)
1752
1753 def trigger_player_update(
1754 self, player_id: str, force_update: bool = False, debounce_delay: float = 0.25
1755 ) -> None:
1756 """Trigger a (debounced) update for the given player."""
1757 if self.mass.closing:
1758 return
1759 if not (player := self.get_player(player_id)):
1760 return
1761 # mark dirty right away (not at execution): a trigger means state the player
1762 # derives from changed, and a direct update_state call may come in before
1763 # the debounced one runs
1764 player.mark_state_dirty()
1765 task_id = f"player_update_state_{player_id}"
1766 self.mass.call_later(
1767 debounce_delay,
1768 player.update_state,
1769 force_update=force_update,
1770 task_id=task_id,
1771 )
1772
1773 async def unregister(
1774 self,
1775 player_id: str,
1776 permanent: bool = False,
1777 replacement_player_id: str | None = None,
1778 ) -> None:
1779 """
1780 Unregister a player from the player controller.
1781
1782 Called (by a PlayerProvider) when a player is removed or no longer available
1783 (for a longer period of time). This will remove the player from the player
1784 controller and optionally remove the player's config from the mass config.
1785 If the player is not registered, this will silently be ignored.
1786
1787 :param player_id: Player ID of the player to unregister.
1788 :param permanent: If True, remove the player permanently by deleting its config.
1789 If False, the player config will not be removed.
1790 :param replacement_player_id: Player ID that takes this player's place, only
1791 used for a permanent removal.
1792 """
1793 player = self._players.get(player_id)
1794 if player is None:
1795 return
1796 # a player that is going away is done with any live source it was playing,
1797 # so let the owning plugin release an upstream session pointing at us
1798 await self._release_audio_source(player_id)
1799 del self._players[player_id]
1800 # clean up all lock entries for this player
1801 for prefix in [p.value for p in PlayerLockPurpose]:
1802 self._player_command_locks.pop(f"{prefix}_{player_id}", None)
1803 if handle := self._pending_protocol_evaluations.pop(player_id, None):
1804 handle.cancel()
1805 self._clear_sleep_timer(player)
1806 self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
1807 # teardown is best-effort: a provider that fails to release its player must not
1808 # strand the other players of that provider, nor the provider unload itself
1809 try:
1810 await player.on_unload()
1811 except Exception:
1812 self.logger.exception("Error unloading player %s", player.name)
1813 if permanent:
1814 # player permanent removal: cleanup protocol links, delete config
1815 # and signal PLAYER_REMOVED event.
1816 # No group detach is issued here: the player is already out of the registry,
1817 # so it is filtered out of every group's live member list, and its persisted
1818 # membership is settled by delete_player_config below.
1819 self._cleanup_protocol_links(player)
1820 self.delete_player_config(player_id, replacement_player_id)
1821 self.logger.info("Player removed: %s", player.name)
1822 if player.state.type != PlayerType.PROTOCOL:
1823 self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
1824 else:
1825 # temporary unavailable: mark player as unavailable
1826 # note: the player will be re-registered later if it comes back online
1827 player.state.available = False
1828 self.logger.info("Player unavailable: %s", player.name)
1829 if player.state.type != PlayerType.PROTOCOL:
1830 self.mass.signal_event(
1831 EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
1832 )
1833 # Schedule debounced update of all players since can_group_with values may change
1834 self._schedule_update_all_players()
1835
1836 @api_command("players/remove", required_scope=Scope.CONFIG_PLAYERS_WRITE)
1837 async def remove(self, player_id: str) -> None:
1838 """
1839 Remove a player from a provider.
1840
1841 Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
1842 """
1843 player = self.get_player(player_id)
1844 if player is None:
1845 # we simply permanently delete the player config since it is not registered
1846 self.delete_player_config(player_id)
1847 return
1848 if player.state.type == PlayerType.GROUP:
1849 # Handle group player removal
1850 player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
1851 await player.provider.remove_group_player(player_id)
1852 return
1853 player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
1854 await player.provider.remove_player(player_id)
1855 # check for group memberships that need to be updated
1856 if player.state.active_group and (
1857 group_player := self.mass.players.get_player(player.state.active_group)
1858 ):
1859 # try to remove from the group
1860 with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
1861 await group_player.set_members(
1862 player_ids_to_remove=[player_id],
1863 )
1864 # We removed the player and can now clean up its config
1865 self.delete_player_config(player_id)
1866
1867 def delete_player_config(
1868 self, player_id: str, replacement_player_id: str | None = None
1869 ) -> None:
1870 """
1871 Permanently delete a player's configuration, including its DSP and queue settings.
1872
1873 The saved queue of a player that is no longer registered is dropped along with it,
1874 so a device that returns under the same id starts out fresh. The player itself is
1875 not unregistered.
1876 The config of a linked protocol player is wiped along with it, so the device
1877 returns as a brand new player once it is discovered again. Protocol players that
1878 are still registered or that already moved to another parent keep their config;
1879 registered ones are detached from the removed player and re-evaluated.
1880 Any group that lists the player as a member follows the replacement, or loses
1881 the member when there is none.
1882
1883 :param player_id: Player ID of the player to delete the configuration of.
1884 :param replacement_player_id: Player ID that takes this player's place, so users
1885 restricted to it and groups it belongs to follow
1886 the replacement.
1887 """
1888 self._detach_protocol_children(player_id)
1889 self._update_group_memberships(player_id, replacement_player_id)
1890 player_ids = [
1891 protocol_id
1892 for protocol_id in self.mass.config.get(CONF_PLAYERS, {})
1893 if self._get_cached_protocol_parent_id(protocol_id) == player_id
1894 and self.get_player(protocol_id) is None
1895 ]
1896 player_ids.append(player_id)
1897 for pid in player_ids:
1898 for key in (
1899 f"{CONF_PLAYERS}/{pid}",
1900 f"{CONF_PLAYER_DSP}/{pid}",
1901 f"{CONF_PLAYER_QUEUES}/{pid}",
1902 ):
1903 self.mass.config.remove(key)
1904 if self.get_player(pid) is None:
1905 self.mass.player_queues.purge_saved_queue(pid)
1906 # a user access filter is an allow-list of player ids, so it must not be left
1907 # pointing at a player whose config was just wiped: a replaced player hands its
1908 # entries over to its replacement, a removed one has them dropped
1909 if replacement_player_id:
1910 self.mass.create_task(
1911 self.mass.webserver.auth.replace_player_in_user_filters(
1912 player_id, replacement_player_id, removed_player_ids=player_ids
1913 )
1914 )
1915 else:
1916 self.mass.create_task(
1917 self.mass.webserver.auth.remove_from_user_filters(player_ids=player_ids)
1918 )
1919
1920 def scale_volume_to_device(self, player_id: str, logical_volume: int) -> int:
1921 """Scale logical volume (0-100) to device volume (min_volume-max_volume)."""
1922 min_volume, max_volume = self._get_volume_limits(player_id)
1923 if min_volume == 0 and max_volume == 100:
1924 return logical_volume
1925 # Scale: logical 0 -> min_volume, logical 100 -> max_volume
1926 return min_volume + (logical_volume * (max_volume - min_volume)) // 100
1927
1928 def scale_volume_from_device(self, player_id: str, device_volume: int) -> int:
1929 """Scale device volume (min_volume-max_volume) to logical volume (0-100)."""
1930 min_volume, max_volume = self._get_volume_limits(player_id)
1931 if min_volume == 0 and max_volume == 100:
1932 return device_volume
1933 volume_range = max_volume - min_volume
1934 if volume_range == 0:
1935 return 0
1936 # Scale to 0-100 without clamping so that out-of-range device volumes
1937 # produce distinct logical values, ensuring state change detection triggers
1938 # volume limit enforcement
1939 return ((device_volume - min_volume) * 100) // volume_range
1940
1941 def on_player_position_jumped(self, player: Player) -> None:
1942 """
1943 Handle a discrete jump of a player's corrected playback position.
1944
1945 Called by a Player when its corrected position moved significantly
1946 outside regular playback progression (seek or buffer correction). This
1947 is not an event by itself: it re-bases the active queue's timing on the
1948 fresh position and nudges related players so derived positions stay in
1949 sync; current_media then re-anchors from the corrected queue time on
1950 the follow-up update, which emits the actual update event.
1951 """
1952 if self.mass.closing:
1953 return
1954 self.mass.player_queues.on_player_elapsed_time_corrected(player)
1955 self.trigger_player_update(player.player_id)
1956 self._forward_state_update(player, {})
1957
1958 def signal_player_state_update(
1959 self,
1960 player: Player,
1961 changed_values: dict[str, tuple[Any, Any]],
1962 force_update: bool = False,
1963 skip_forward: bool = False,
1964 media_position_jumped: bool = False,
1965 ) -> None:
1966 """
1967 Signal a player state update.
1968
1969 Called by a Player when its state has changed.
1970 This will update the player state in the controller and signal the event bus.
1971 """
1972 player_id = player.player_id
1973 if self.mass.closing:
1974 return
1975
1976 # ignore updates for disabled players
1977 if not player.state.enabled and ATTR_ENABLED not in changed_values:
1978 return
1979
1980 # The current_media position anchor only changes on discrete events
1981 # (play/pause/seek/track change/buffer correction), so a change set holding
1982 # only anchor keys represents a position correction rather than a regular
1983 # state change.
1984 non_anchor_keys = changed_values.keys() - POSITION_ANCHOR_KEYS
1985 if len(non_anchor_keys) == 0 and not force_update:
1986 if not media_position_jumped:
1987 # anchor adoption without a significant corrected-position change
1988 return
1989 # current_media's corrected position jumped (seek or buffer correction
1990 # reached the current media): emit the full player update below so
1991 # consumers see the fresh position
1992
1993 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
1994 self.logger.log(
1995 VERBOSE_LOG_LEVEL,
1996 "Player state updated for %s: changed fields: %s",
1997 player.name,
1998 ", ".join(changed_values.keys()),
1999 )
2000
2001 # signal update to the playerqueue
2002 if player.state.type != PlayerType.PROTOCOL:
2003 self.mass.call_later(
2004 0.5,
2005 self.mass.player_queues.on_player_update,
2006 player,
2007 changed_values,
2008 task_id=f"queue_on_player_update_{player.player_id}",
2009 )
2010
2011 # Kick async palette extraction on cold cache. On transition prefetch
2012 # the next queue item too. Skip players that mirror another player's media
2013 # (grouped/synced members, protocol children): their current_media - palette
2014 # included - is taken wholesale from the owner, so resolving it per member is
2015 # wasted work that also produces duplicate state updates across the group.
2016 if (
2017 not self._mirrors_parent_media(player)
2018 and (current_media := player.state.current_media)
2019 and current_media.image_url
2020 ):
2021 if current_media.palette is None:
2022 self._schedule_palette_fetch(player_id, current_media.image_url)
2023 if "current_media.image_url" in changed_values or "current_media" in changed_values:
2024 self._schedule_next_queue_item_palette_prefetch(player_id, current_media)
2025
2026 # handle DSP reload of the leader when grouping/ungrouping
2027 if ATTR_GROUP_MEMBERS in changed_values:
2028 prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
2029 self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
2030 # Removed group members also need to be updated since they are no longer part
2031 # of this group and are available for playback again
2032 removed_members = set(prev_group_members or []) - set(new_group_members or [])
2033 for _removed_player_id in removed_members:
2034 if removed_player := self.get_player(_removed_player_id):
2035 removed_player.refresh_state()
2036
2037 # detect when active_source changes to
2038 # something external while we have a grouped protocol active
2039 if ATTR_ACTIVE_SOURCE in changed_values:
2040 task_id = f"external_source_takeover_{player_id}"
2041 self.mass.call_later(
2042 5,
2043 self._check_external_source_takeover,
2044 player,
2045 task_id=task_id,
2046 )
2047 # only steer into the (relatively expensive) membership cleanup when a field
2048 # that can require an unsync actually changed - this runs on every state tick
2049 if changed_values.keys() & {ATTR_AVAILABLE, ATTR_ENABLED, ATTR_POWERED}:
2050 self._handle_membership_cleanup_on_state_change(player, changed_values)
2051 self._handle_external_power_off(player, changed_values)
2052
2053 # enforce volume limits when volume changes externally
2054 if "volume_level" in changed_values:
2055 corrected = self._enforce_volume_limits(player)
2056 # a level set on the device itself makes the reference a group volume change
2057 # interpolates from obsolete. a member on its way to a level we did send
2058 # reports levels too, and a group only ever reports what its members are at,
2059 # so neither of those counts. a correction always is the device's own doing:
2060 # the levels we command never fall outside the configured range
2061 if player.state.type != PlayerType.GROUP and (
2062 corrected or self._unexpired_volume_target(player) is None
2063 ):
2064 self._invalidate_group_volume_snapshot(player_id)
2065 # dispatch to internal state update subscribers (with changed_values)
2066 self._dispatch_state_update_subscribers(player, changed_values)
2067
2068 # signal player update on the eventbus
2069 if player.state.type != PlayerType.PROTOCOL:
2070 self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
2071
2072 # signal a separate PlayerOptionsUpdated event
2073 if options := changed_values.get("options"):
2074 self.mass.signal_event(
2075 EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
2076 )
2077 # signal player config update event if playerfeatures changed
2078 # this is temporary needed for the Home Assistant integration which only
2079 # re-evalues the entity's supported features on a PLAYER_CONFIG_UPDATED event.
2080 # TODO: Remove this temporary workaround once the HA integration is updated to
2081 # also re-evaluate supported features on PLAYER_UPDATED events.
2082 if changed_values.keys() & {
2083 ATTR_SUPPORTED_FEATURES,
2084 ATTR_MUTE_CONTROL,
2085 ATTR_VOLUME_CONTROL,
2086 ATTR_POWER_CONTROL,
2087 }:
2088 self.mass.signal_event(
2089 EventType.PLAYER_CONFIG_UPDATED, object_id=player_id, data=player.config
2090 )
2091
2092 if not skip_forward or force_update:
2093 self._forward_state_update(player, changed_values)
2094
2095 # trigger update of all players in a provider if group related fields changed
2096 # this ensures that calculated fields like can_group_with are updated on all players
2097 if any(key in changed_values for key in ("group_members", "synced_to", "available")):
2098 for prov_player in player.provider.players:
2099 self.trigger_player_update(prov_player.player_id, debounce_delay=2)
2100
2101 async def register_player_control(self, player_control: PlayerControl) -> None:
2102 """Register a new PlayerControl on the controller."""
2103 if self.mass.closing:
2104 return
2105 control_id = player_control.id
2106
2107 if control_id in self._controls:
2108 msg = f"PlayerControl {control_id} is already registered"
2109 raise AlreadyRegisteredError(msg)
2110
2111 # make sure that the playercontrol's provider is set to the instance_id
2112 prov = self.mass.get_provider(player_control.provider)
2113 if not prov or prov.instance_id != player_control.provider:
2114 raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
2115
2116 self._controls[control_id] = player_control
2117
2118 self.logger.info(
2119 "PlayerControl registered: %s/%s",
2120 control_id,
2121 player_control.name,
2122 )
2123
2124 # always call update to update any attached players etc.
2125 self.update_player_control(player_control.id, include_configured=True)
2126
2127 async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
2128 """Register a new playercontrol on the controller or update existing one."""
2129 if self.mass.closing:
2130 return
2131 if player_control.id in self._controls:
2132 self._controls[player_control.id] = player_control
2133 self.update_player_control(player_control.id, include_configured=True)
2134 return
2135 await self.register_player_control(player_control)
2136
2137 def update_player_control(self, control_id: str, include_configured: bool = False) -> None:
2138 """
2139 Refresh the players that use the given player control.
2140
2141 :param control_id: The control whose state or availability changed.
2142 :param include_configured: Also refresh the players that select this control in their
2143 config but do not currently resolve to it. Needed when a control (re)appears,
2144 because such a player has already fallen back to another control and would
2145 otherwise never pick this one back up.
2146 """
2147 if self.mass.closing:
2148 return
2149 # update all players that are using this control
2150 for player in list(self._players.values()):
2151 if control_id in (
2152 player.state.power_control,
2153 player.state.volume_control,
2154 player.state.mute_control,
2155 ) or (
2156 include_configured and control_id in self._configured_control_ids(player.player_id)
2157 ):
2158 self.mass.loop.call_soon(player.refresh_state)
2159
2160 def remove_player_control(self, control_id: str) -> None:
2161 """Remove a player_control from the player manager."""
2162 control = self._controls.pop(control_id, None)
2163 if control is None:
2164 return
2165 self.logger.info("PlayerControl removed: %s", control.name)
2166 # players configured to use this control still resolve to it until they are
2167 # refreshed, so let them fall back to their remaining options right away
2168 self.update_player_control(control_id)
2169
2170 def get_player_provider(self, player_id: str) -> PlayerProvider:
2171 """Return PlayerProvider for given player."""
2172 player = self._players[player_id]
2173 assert player # for type checker
2174 return player.provider
2175
2176 def get_active_queue(self, player: Player) -> PlayerQueue | None:
2177 """Return the current active queue for a player (if any)."""
2178 # account for player that is synced (sync child)
2179 if player.state.synced_to and player.state.synced_to != player.player_id:
2180 if sync_leader := self.get_player(player.state.synced_to):
2181 return self.get_active_queue(sync_leader)
2182 # handle active group player
2183 if player.state.active_group and player.state.active_group != player.player_id:
2184 if group_player := self.get_player(player.state.active_group):
2185 return self.get_active_queue(group_player)
2186 # active_source may be filled queue id (or None)
2187 active_source = player.state.active_source or player.player_id
2188 if active_queue := self.mass.player_queues.get(active_source):
2189 return active_queue
2190 # handle active protocol player with parent player queue
2191 if player.type == PlayerType.PROTOCOL and player.protocol_parent_id:
2192 if parent_player := self.mass.players.get_player(player.protocol_parent_id):
2193 return self.get_active_queue(parent_player)
2194 return None
2195
2196 async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
2197 """
2198 Set the overall volume for a player group or synced players.
2199
2200 Uses interpolation to adjust all child volumes while preserving their
2201 relative balance. A snapshot of child volumes is cached on first call and
2202 used as the reference point for subsequent adjustments.
2203
2204 :param group_player: The group player or sync leader.
2205 :param volume_level: Target volume level (0..100).
2206 """
2207 cur_volume = group_player.state.group_volume
2208 if cur_volume is None:
2209 return
2210
2211 children: list[Player] = []
2212 for child_player in self.iter_group_members(
2213 group_player, only_powered=True, exclude_self=False
2214 ):
2215 if child_player.state.volume_control == PLAYER_CONTROL_NONE:
2216 continue
2217 children.append(child_player)
2218 if not children:
2219 return
2220
2221 # cache a snapshot of child volumes on the group player as reference for interpolation.
2222 # scaling up: each child interpolates from its snapshot value toward 100.
2223 # scaling down: each child interpolates from its snapshot value toward 0.
2224 # this ensures the relative balance is preserved and all children converge
2225 # to 0 and 100 at the extremes. the snapshot is invalidated when a child's
2226 # individual volume or the group membership changes, and rebuilt when the
2227 # children it holds are no longer the ones being adjusted.
2228 # the levels a nudge steps from are the ones the members were last commanded, so
2229 # the snapshot has to read the same source, or a change a member has not confirmed
2230 # yet puts the reference above the level being set and turns a step up into one down
2231 snapshot: dict[str, int] | None = group_player.extra_data.get(ATTR_GROUP_VOLUME_SNAPSHOT)
2232 if snapshot is None or snapshot.keys() != {c.player_id for c in children}:
2233 snapshot = {c.player_id: self._volume_nudge_base(c) or 0 for c in children}
2234 group_player.extra_data[ATTR_GROUP_VOLUME_SNAPSHOT] = snapshot
2235
2236 base_group = max(snapshot.values())
2237
2238 coros = []
2239 for child_player in children:
2240 child_base = snapshot.get(child_player.player_id, 0)
2241 if volume_level >= base_group:
2242 # scaling up: interpolate each child from snapshot toward 100
2243 if base_group >= 100:
2244 new_child_volume = child_base
2245 else:
2246 progress = (volume_level - base_group) / (100 - base_group)
2247 new_child_volume = round(child_base + (100 - child_base) * progress)
2248 elif base_group == 0:
2249 new_child_volume = 0
2250 else:
2251 # scaling down: interpolate each child from snapshot toward 0
2252 progress = volume_level / base_group
2253 new_child_volume = round(child_base * progress)
2254 new_child_volume = max(0, min(100, new_child_volume))
2255 coros.append(self._set_member_volume(child_player.player_id, new_child_volume))
2256 await asyncio.gather(*coros)
2257
2258 # notify active AudioSource once at the group level to prevent
2259 # feedback loops from per-child callbacks with different volume values
2260 await self._notify_source_volume_change(group_player, volume_level)
2261
2262 def iter_group_members(
2263 self,
2264 group_player: Player,
2265 only_powered: bool = False,
2266 only_playing: bool = False,
2267 active_only: bool = False,
2268 exclude_self: bool = True,
2269 ) -> Iterator[Player]:
2270 """Get (child) players attached to a group player or syncgroup."""
2271 for child_id in list(group_player.state.group_members):
2272 if child_player := self.get_player(child_id, False):
2273 if not child_player.state.available or not child_player.state.enabled:
2274 continue
2275 if only_powered and child_player.state.powered is False:
2276 continue
2277 if active_only and child_player.state.active_group != group_player.player_id:
2278 continue
2279 if exclude_self and child_player.player_id == group_player.player_id:
2280 continue
2281 if only_playing and child_player.state.playback_state not in (
2282 PlaybackState.PLAYING,
2283 PlaybackState.PAUSED,
2284 ):
2285 continue
2286 yield child_player
2287
2288 def subscribe_player_state_update(
2289 self,
2290 callback: Callable[[Player, dict[str, tuple[Any, Any]]], None],
2291 ) -> Callable[[], None]:
2292 """
2293 Subscribe to player state update notifications.
2294
2295 The callback receives the Player and a dict of changed values
2296 (mapping attribute name to a (previous, new) tuple).
2297
2298 :param callback: Function to invoke for each player state update.
2299 :return: An unsubscribe function.
2300 """
2301 self._state_update_subscribers.append(callback)
2302
2303 def _unsub() -> None:
2304 with suppress(ValueError):
2305 self._state_update_subscribers.remove(callback)
2306
2307 return _unsub
2308
2309 @contextlib.asynccontextmanager
2310 async def wait_for_player_update(
2311 self,
2312 player_id: str,
2313 attribute_name: str | None = None,
2314 attribute_value: Any = _SENTINEL,
2315 timeout: float = 5.0,
2316 ) -> AsyncIterator[None]:
2317 """
2318 Async context manager that waits for a player state update.
2319
2320 Subscribes to player state updates on entry, runs the body (typically
2321 the action that triggers the expected update), then waits for a
2322 matching update on exit. If ``attribute_name`` and ``attribute_value``
2323 are both provided and the current value already matches at entry, the
2324 wait is skipped.
2325
2326 Example::
2327
2328 async with mass.players.wait_for_player_update(
2329 player_id, attribute_name="playback_state",
2330 attribute_value=PlaybackState.IDLE, timeout=5,
2331 ):
2332 await mass.players._handle_cmd_stop(player_id)
2333
2334 :param player_id: The player ID to wait for.
2335 :param attribute_name: Optional state attribute to watch for changes
2336 (e.g. ``"playback_state"``). If omitted, any state change satisfies
2337 the wait.
2338 :param attribute_value: Optional value the watched attribute must reach.
2339 Only meaningful in combination with ``attribute_name``.
2340 :param timeout: Maximum time to wait in seconds.
2341 """
2342 update_event = asyncio.Event()
2343
2344 def _on_state_update(player: Player, changed_values: dict[str, tuple[Any, Any]]) -> None:
2345 if player.player_id != player_id:
2346 return
2347 if attribute_name is None:
2348 update_event.set()
2349 return
2350 if attribute_name not in changed_values:
2351 return
2352 if attribute_value is _SENTINEL:
2353 update_event.set()
2354 return
2355 _prev, new_val = changed_values[attribute_name]
2356 if new_val == attribute_value:
2357 update_event.set()
2358
2359 # short-circuit when the desired value is already the current state
2360 already_satisfied = (
2361 attribute_name is not None
2362 and attribute_value is not _SENTINEL
2363 and (player := self.get_player(player_id)) is not None
2364 and getattr(player.state, attribute_name, _SENTINEL) == attribute_value
2365 )
2366
2367 unsub = self.subscribe_player_state_update(_on_state_update)
2368 try:
2369 yield
2370 if already_satisfied:
2371 return
2372 try:
2373 async with asyncio.timeout(timeout):
2374 await update_event.wait()
2375 except TimeoutError:
2376 self.logger.debug(
2377 "Timed out waiting for player update on %s (attr=%s value=%s)",
2378 player_id,
2379 attribute_name,
2380 attribute_value,
2381 )
2382 finally:
2383 unsub()
2384
2385 async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
2386 """Call (by config manager) when the configuration of a player changes."""
2387 min_vol_changed = f"values/{CONF_MIN_VOLUME}" in changed_keys
2388 max_vol_changed = f"values/{CONF_MAX_VOLUME}" in changed_keys
2389 if min_vol_changed or max_vol_changed:
2390 raw_min = config.get_value(CONF_MIN_VOLUME)
2391 raw_max = config.get_value(CONF_MAX_VOLUME)
2392 min_vol = int(cast("int", raw_min)) if raw_min is not None else 0
2393 max_vol = int(cast("int", raw_max)) if raw_max is not None else 100
2394 if min_vol > max_vol:
2395 msg = "Minimum volume cannot exceed maximum volume"
2396 raise InvalidDataError(msg)
2397 player = self.get_player(config.player_id)
2398 player_provider = self.mass.get_provider(config.provider)
2399 player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
2400 player_enabled = ATTR_ENABLED in changed_keys and config.enabled
2401
2402 if player_disabled and player and player.state.available:
2403 # edge case: ensure that the player is powered off if the player gets disabled
2404 if player.state.power_control != PLAYER_CONTROL_NONE:
2405 await self._handle_cmd_power(config.player_id, False)
2406 elif player.state.playback_state != PlaybackState.IDLE:
2407 await self.cmd_stop(config.player_id)
2408
2409 # signal player provider that the player got enabled/disabled
2410 if (player_enabled or player_disabled) and player_provider:
2411 assert isinstance(player_provider, PlayerProvider) # for type checking
2412 # Collect linked protocol IDs to cascade the enable/disable to.
2413 # Without this, a disabled native parent leaves its linked protocols
2414 # registered after restart; they then fail to find their parent and
2415 # get wrapped in a fresh Universal Player.
2416 cascade_protocol_ids: list[str] = []
2417 parent_is_protocol = player.state.type == PlayerType.PROTOCOL if player else False
2418 if not parent_is_protocol:
2419 if player and player.linked_output_protocols:
2420 cascade_protocol_ids = [
2421 link.output_protocol_id for link in player.linked_output_protocols
2422 ]
2423 else:
2424 cascade_protocol_ids = self._get_cached_protocol_ids(config.player_id)
2425 if player_disabled:
2426 player_provider.on_player_disabled(config.player_id)
2427 elif player_enabled:
2428 player_provider.on_player_enabled(config.player_id)
2429 for protocol_id in cascade_protocol_ids:
2430 protocol_raw = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
2431 if not protocol_raw:
2432 continue
2433 if bool(protocol_raw.get("enabled", True)) == bool(player_enabled):
2434 continue
2435 self.mass.create_task(
2436 self.mass.config.save_player_config(
2437 protocol_id, {ATTR_ENABLED: bool(player_enabled)}
2438 )
2439 )
2440 return # enabling/disabling a player will be handled by the provider
2441
2442 if not player:
2443 return # guard against player not being registered (yet)
2444
2445 resume_queue: PlayerQueue | None = (
2446 self.mass.player_queues.get(player.state.active_source)
2447 if player.state.active_source
2448 else None
2449 )
2450
2451 # ensure player state gets updated with any updated config
2452 player.set_config(config)
2453 await player.on_config_updated()
2454 player.update_state()
2455 # if the PlayerQueue was playing, restart playback
2456 if resume_queue and resume_queue.state == PlaybackState.PLAYING:
2457 requires_restart = any(
2458 v.requires_reload
2459 for v in config.values.values()
2460 if f"values/{v.key}" in changed_keys
2461 )
2462 if requires_restart:
2463 # always stop first to ensure the player uses the new config
2464 await self.mass.player_queues.stop(resume_queue.queue_id)
2465 self.mass.call_later(
2466 1, self.mass.player_queues.resume, resume_queue.queue_id, False
2467 )
2468
2469 async def on_player_dsp_change(self, player_id: str) -> None:
2470 """Call (by config manager) when the DSP settings of a player change."""
2471 # signal player provider that the config changed
2472 if not (player := self.get_player(player_id)):
2473 return
2474 if player.state.playback_state == PlaybackState.PLAYING:
2475 self.logger.info("Restarting playback of Player %s after DSP change", player_id)
2476 # this will restart the queue stream/playback
2477 if self.get_active_queue(player):
2478 self.mass.call_later(
2479 0, self.mass.player_queues.resume, player.state.active_source, False
2480 )
2481 return
2482 # if the player is not using a queue, we need to stop and start playback
2483 await self.cmd_stop(player_id)
2484 await self.cmd_play(player_id)
2485
2486 def schedule_active_output_protocol_clear(self, player: Player) -> None:
2487 """
2488 Clear the player's active output protocol once it stops playing.
2489
2490 A device may keep reporting PLAYING for a short while after a stop
2491 command, so the clear is deferred until the player reports IDLE (with a
2492 timeout as fallback). Starting a new session cancels the pending clear
2493 (see Player.set_active_output_protocol).
2494
2495 :param player: The player whose active output protocol must be cleared.
2496 """
2497 # Deduplicated per player via task_id: if a clear is already pending we
2498 # keep it, so the single tracked task stays cancellable by a new session.
2499 self.mass.create_task(
2500 self._clear_active_output_protocol_when_idle(player),
2501 task_id=f"clear_active_protocol_{player.player_id}",
2502 )
2503
2504 def __iter__(self) -> Iterator[Player]:
2505 """Iterate over all players."""
2506 return iter(self._players.values())
2507
2508 async def _resolve_mac_addresses(self, player: Player) -> None:
2509 """
2510 Resolve and persist the MAC addresses used to match the player against protocols.
2511
2512 :param player: The player to resolve the MAC address(es) for.
2513 """
2514 conf_base = f"{CONF_PLAYERS}/{player.player_id}/values"
2515 # Save the original MAC reported by the provider (before ARP enrichment)
2516 reported_mac = player.device_info.identifiers.get(IdentifierType.MAC_ADDRESS)
2517
2518 # Try to use cached ARP MAC from config for fast matching on restart.
2519 # This allows protocol linking to work immediately even if ARP is slow/fails.
2520 cached_arp_mac: str | None = self.mass.config.get(
2521 f"{conf_base}/{CONF_CACHED_ARP_MAC}", None
2522 )
2523 if cached_arp_mac and is_valid_mac_address(cached_arp_mac):
2524 player.device_info.add_identifier(IdentifierType.MAC_ADDRESS, cached_arp_mac)
2525
2526 # Enrich device MAC address via ARP if needed
2527 # (handles invalid MACs, locally-administered MACs, and missing MACs)
2528 await enrich_device_mac_address(player.device_info, self.logger)
2529
2530 # Cache the resolved MAC for fast matching on subsequent restarts
2531 current_mac = player.device_info.identifiers.get(IdentifierType.MAC_ADDRESS)
2532 if current_mac and is_valid_mac_address(current_mac) and current_mac != cached_arp_mac:
2533 self.mass.config.set(f"{conf_base}/{CONF_CACHED_ARP_MAC}", current_mac)
2534
2535 # Store original reported MAC if it differs from the resolved MAC.
2536 # This enables multi-MAC matching for devices with multiple interfaces
2537 # (e.g., WiFi + Ethernet) where ARP resolves one interface but the
2538 # protocol reports the other.
2539 if reported_mac and is_valid_mac_address(reported_mac) and current_mac:
2540 if reported_mac.upper() != current_mac.upper():
2541 player.extra_data["reported_mac"] = reported_mac
2542 self.mass.config.set(f"{conf_base}/{CONF_REPORTED_MAC}", reported_mac)
2543 else:
2544 # Provider's reported MAC matches the resolved MAC; clear any stale
2545 # stored reported MAC to avoid false-positive multi-MAC matches.
2546 self.mass.config.set(f"{conf_base}/{CONF_REPORTED_MAC}", None)
2547 elif not reported_mac or not is_valid_mac_address(reported_mac):
2548 # Restore reported MAC from config on restart only when the provider
2549 # did not supply a usable MAC address.
2550 cached_reported_mac: str | None = self.mass.config.get(
2551 f"{conf_base}/{CONF_REPORTED_MAC}", None
2552 )
2553 if cached_reported_mac and is_valid_mac_address(cached_reported_mac):
2554 if current_mac and cached_reported_mac.upper() == current_mac.upper():
2555 # Cached value matches the resolved MAC; clear stale entry.
2556 self.mass.config.set(f"{conf_base}/{CONF_REPORTED_MAC}", None)
2557 else:
2558 player.extra_data["reported_mac"] = cached_reported_mac
2559
2560 def _teardown_in_progress(self, player: Player) -> bool:
2561 """
2562 Return True if the server or this player's provider is shutting down.
2563
2564 :param player: The player that is in the process of being registered.
2565 """
2566 return self.mass.closing or player.provider.unloading
2567
2568 def _registration_aborted(self, player: Player) -> bool:
2569 """
2570 Return True if the given player is no longer the registered player for its ID.
2571
2572 :param player: The player that is in the process of being registered.
2573 """
2574 # registration awaits provider I/O while the player is already in the registry,
2575 # so an unregister (e.g. a provider unload or a device disconnect) can drop or
2576 # replace it in the meantime, after which registration must stop
2577 if self._players.get(player.player_id) is player:
2578 return False
2579 self.logger.debug(
2580 "Registration of player %s aborted: it was unregistered while setting up",
2581 player.player_id,
2582 )
2583 return True
2584
2585 async def _finish_player_type_transition(self, player: Player) -> None:
2586 """
2587 Publish a registered player that moved in or out of the protocol role.
2588
2589 :param player: The player, with its new type already applied to its state.
2590 """
2591 self._evaluate_protocol_links(player)
2592 if player.state.type == PlayerType.PROTOCOL:
2593 # the player is hidden behind its parent from now on and no longer owns a queue.
2594 # only the queue is dropped, never the playback: the player either just became a
2595 # (hidden) bridge client with nothing playing on it, or is already serving its
2596 # parent, where a stop would cut that parent's stream short. A protocol player
2597 # has no active group of its own either, so there is nothing to detach here.
2598 self.mass.signal_event(EventType.PLAYER_REMOVED, player.player_id)
2599 self.mass.player_queues.on_player_remove(player.player_id, permanent=False)
2600 return
2601 # the player surfaces on its own, which leaves it unusable without a queue
2602 self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
2603 await self.mass.player_queues.on_player_register(player)
2604 if self._registration_aborted(player):
2605 # the queue restore outlived the unregister that already cleaned it up,
2606 # so drop the queue we just recreated for a player that is gone
2607 self.mass.player_queues.on_player_remove(player.player_id, permanent=False)
2608
2609 async def _release_player_for_play_media(self, player: Player) -> None:
2610 """
2611 Release a captured player so a play_media command can target it directly.
2612
2613 :param player: The captured player to release.
2614 """
2615 # Strategy is picked from how the player is currently captured:
2616 # synced_to → unsync this player (cmd_ungroup)
2617 # dynamic group member → remove from group via cmd_set_members
2618 # static group member → dissolve the whole group (power off if it
2619 # has a real power control, otherwise stop)
2620 # In every branch we wait for the relevant state attribute to actually
2621 # clear before returning. Providers (Sonos in particular) reject a
2622 # play_media on a player whose synced_to/active_group is still set
2623 # locally even though the release command has been acknowledged.
2624 if player.state.synced_to:
2625 self.logger.debug(
2626 "Unsyncing %s from %s to honor explicit play_media target",
2627 player.state.name,
2628 player.state.synced_to,
2629 )
2630 async with self.wait_for_player_update(
2631 player.player_id,
2632 attribute_name="synced_to",
2633 attribute_value=None,
2634 timeout=5,
2635 ):
2636 await self.cmd_ungroup(player.player_id)
2637 return
2638 if not player.state.active_group:
2639 return
2640 group = self.get_player(player.state.active_group)
2641 if group is None:
2642 return
2643 is_dynamic_member = (
2644 PlayerFeature.SET_MEMBERS in group.state.supported_features
2645 and player.player_id not in group.state.static_group_members
2646 )
2647 if is_dynamic_member:
2648 self.logger.debug(
2649 "Removing %s from dynamic group %s to honor explicit play_media target",
2650 player.state.name,
2651 group.state.name,
2652 )
2653 async with self.wait_for_player_update(
2654 player.player_id,
2655 attribute_name="active_group",
2656 attribute_value=None,
2657 timeout=5,
2658 ):
2659 await self.cmd_set_members(group.player_id, player_ids_to_remove=[player.player_id])
2660 return
2661 # static member: a single member can't be released, so the whole
2662 # group must dissolve. Prefer cmd_power when an explicit power
2663 # control is set so the user-visible state stays consistent.
2664 async with self.wait_for_player_update(
2665 player.player_id,
2666 attribute_name="active_group",
2667 attribute_value=None,
2668 timeout=5,
2669 ):
2670 if group.state.power_control != PLAYER_CONTROL_NONE and group.state.powered:
2671 self.logger.debug(
2672 "Powering off %s to honor explicit play_media target on %s",
2673 group.state.name,
2674 player.state.name,
2675 )
2676 await self._handle_cmd_power(group.player_id, False)
2677 else:
2678 self.logger.debug(
2679 "Stopping %s to honor explicit play_media target on %s",
2680 group.state.name,
2681 player.state.name,
2682 )
2683 await self._handle_cmd_stop(group.player_id)
2684
2685 def _mirrors_parent_media(self, player: Player) -> bool:
2686 """
2687 Return True if the player's current_media is taken from another player.
2688
2689 Grouped/synced members and protocol children mirror their parent's
2690 current_media (palette included), so they must not resolve it themselves.
2691
2692 :param player: The player to check.
2693 """
2694 state = player.state
2695 # a self-referential active_group/synced_to is not a real parent (mirror the
2696 # != self guard in Player.__final_current_media), so it must not skip resolution
2697 parent_id = state.active_group or state.synced_to
2698 if parent_id and parent_id != player.player_id:
2699 return True
2700 return state.type == PlayerType.PROTOCOL and player.protocol_parent_id is not None
2701
2702 def _schedule_palette_fetch(
2703 self, player_id: str, image_url: str | None, *, trigger_update: bool = True
2704 ) -> None:
2705 """
2706 Kick off an async palette extraction for an image URL.
2707
2708 :param player_id: Player the palette is scoped to (used for task dedup).
2709 :param image_url: Image URL to extract from. No-op when empty or already cached.
2710 :param trigger_update: When True, re-emit player state once palette is ready
2711 (current track). When False, only warm the cache (prefetch).
2712 """
2713 if not image_url:
2714 return
2715 # Key the task on the image (not just the player) so a track change always
2716 # schedules a fetch for the new image instead of being dropped by an in-flight
2717 # fetch for the previous one; repeated schedules for the same image still dedupe.
2718 slot = "current" if trigger_update else "next"
2719 self.mass.create_task(
2720 self._fetch_palette(player_id, image_url, trigger_update=trigger_update),
2721 task_id=f"palette_fetch_{player_id}_{slot}_{image_url}",
2722 abort_existing=False,
2723 )
2724
2725 async def _fetch_palette(self, player_id: str, image_url: str, *, trigger_update: bool) -> None:
2726 palette = await get_palette_for_url(self.mass, image_url)
2727 if palette is None or not trigger_update:
2728 return # prefetch only warms the cache controller; nothing to attach
2729 player = self.get_player(player_id)
2730 if player is None:
2731 return
2732 current = player.state.current_media
2733 if current is None or current.image_url != image_url:
2734 return # media changed while fetching
2735 # Carry the palette on player state so the (sync) serialization reads it back.
2736 player.set_resolved_palette(image_url, palette)
2737 # Avoid trigger_player_update so a concurrent state-change debounce
2738 # doesn't cancel our timer via the shared player_update_state task_id.
2739 self.mass.call_later(
2740 0,
2741 player.update_state,
2742 force_update=True,
2743 task_id=f"palette_player_update_{player_id}",
2744 )
2745
2746 def _schedule_next_queue_item_palette_prefetch(
2747 self, player_id: str, current_media: PlayerMedia
2748 ) -> None:
2749 """Warm the palette cache for the next queue item so it's hot at transition."""
2750 queue_id, item_id = current_media.source_id, current_media.queue_item_id
2751 if not queue_id or not item_id:
2752 return
2753 next_item = self.mass.player_queues.get_next_item(queue_id, item_id)
2754 if next_item is None or not next_item.image:
2755 return
2756 next_url = self.mass.metadata.get_image_url(
2757 next_item.image, size=512, prefer_stream_server=True
2758 )
2759 self._schedule_palette_fetch(player_id, next_url, trigger_update=False)
2760
2761 def _configured_control_ids(self, player_id: str) -> set[str]:
2762 """Return the player control ids the given player's config selects."""
2763 return {
2764 str(value)
2765 for conf_key in (CONF_POWER_CONTROL, CONF_VOLUME_CONTROL, CONF_MUTE_CONTROL)
2766 if (value := self.mass.config.get_raw_player_config_value(player_id, conf_key))
2767 }
2768
2769 def _get_volume_step(self, current_volume: int) -> int:
2770 """
2771 Return the step size for a single volume increment at the given level.
2772
2773 A configured (non-zero) `volume_step` is a flat step. The default of 0 keeps the
2774 adaptive ladder, which takes finer steps near the ends of the range.
2775 """
2776 if configured := self.get_config_value(CONF_VOLUME_STEP, 0, return_type=int):
2777 return configured
2778 if current_volume < 10 or current_volume > 90:
2779 return 1
2780 if current_volume < 30 or current_volume > 70:
2781 return 2
2782 return 3
2783
2784 def _get_volume_limits(self, player_id: str) -> tuple[int, int]:
2785 """Get the configured min/max volume limits for a player."""
2786 min_volume = int(
2787 cast(
2788 "int",
2789 self.mass.config.get_raw_player_config_value(
2790 player_id, CONF_MIN_VOLUME, CONF_ENTRY_MIN_VOLUME.default_value
2791 ),
2792 )
2793 )
2794 max_volume = int(
2795 cast(
2796 "int",
2797 self.mass.config.get_raw_player_config_value(
2798 player_id, CONF_MAX_VOLUME, CONF_ENTRY_MAX_VOLUME.default_value
2799 ),
2800 )
2801 )
2802 return min_volume, max_volume
2803
2804 def _enforce_volume_limits(self, player: Player) -> bool:
2805 """
2806 Clamp device volume to min/max range when changed externally.
2807
2808 :param player: The player to check the volume of.
2809 :return: True if the volume was outside the configured range and got corrected.
2810 """
2811 player_id = player.player_id
2812 min_volume, max_volume = self._get_volume_limits(player_id)
2813 if min_volume == 0 and max_volume == 100:
2814 return False
2815 # state.volume_level is the resolved logical volume, available for all
2816 # volume control types; a device volume outside the configured range
2817 # surfaces here as a value outside 0-100 (scaling does not clamp)
2818 logical_volume = player.state.volume_level
2819 if logical_volume is None or 0 <= logical_volume <= 100:
2820 return False
2821 clamped = max(0, min(100, logical_volume))
2822 # correct via the regular volume-set path so scaling and redirection apply
2823 self.mass.create_task(self._handle_cmd_volume_set(player_id, clamped))
2824 return True
2825
2826 def _forward_state_update(
2827 self, player: Player, changed_values: dict[str, tuple[Any, Any]]
2828 ) -> None:
2829 """Forward a player state update to related players (groups, sync parent, protocols)."""
2830 # TODO: make this fan-out change-aware (skip relatives that derive nothing from
2831 # the changed fields) once reverse indexes for synced_to/active_group exist.
2832 # Propagate group or sync-leader updates to child players.
2833 if player.state.group_members:
2834 for child_player in self.iter_group_members(player, exclude_self=True):
2835 if player.type == PlayerType.GROUP:
2836 child_player.on_group_updated(player, changed_values)
2837 else:
2838 child_player.on_sync_parent_updated(player, changed_values)
2839 # update/signal group player(s) when a member updates. A sync leader is a member of the
2840 # group player that formed the sync group and gaining members of its own does not change
2841 # that: a group player mirrors its leader, so it depends on exactly these updates.
2842 for group_player in self._get_player_groups(player):
2843 group_player.on_group_member_updated(player, changed_values)
2844
2845 # update/signal manually sync-parent player when child updates
2846 if (_sync_parent_id := player.state.synced_to) and (
2847 _sync_parent := self.get_player(_sync_parent_id)
2848 ):
2849 self.trigger_player_update(_sync_parent.player_id)
2850 # If this is a protocol player, forward the state update to the parent player
2851 if (
2852 player.type == PlayerType.PROTOCOL
2853 and player.protocol_parent_id
2854 and (_protocol_parent := self.mass.players.get_player(player.protocol_parent_id))
2855 ):
2856 _protocol_parent.on_protocol_player_updated(player, changed_values)
2857 # If this is a parent player with linked protocols, forward state updates
2858 # to linked protocol players so their state reflects parent dependencies
2859 if player.state.type != PlayerType.PROTOCOL and player.linked_output_protocols:
2860 for linked in player.linked_output_protocols:
2861 if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
2862 protocol_player.on_protocol_parent_updated(player, changed_values)
2863
2864 def _invalidate_group_volume_snapshot(self, player_id: str) -> None:
2865 """Clear the cached group volume snapshot for all groups this player belongs to."""
2866 player = self.get_player(player_id)
2867 if not player:
2868 return
2869 if player.state.group_members:
2870 player.extra_data.pop(ATTR_GROUP_VOLUME_SNAPSHOT, None)
2871 for group_player in self._get_player_groups(player):
2872 group_player.extra_data.pop(ATTR_GROUP_VOLUME_SNAPSHOT, None)
2873 if player.state.synced_to and (leader := self.get_player(player.state.synced_to)):
2874 leader.extra_data.pop(ATTR_GROUP_VOLUME_SNAPSHOT, None)
2875
2876 def _record_volume_target(self, player: Player, volume_level: int) -> None:
2877 """Remember the volume level just commanded, as the base for the next nudge."""
2878 if self._stays_silent_on_volume_change(player):
2879 volume_level = 0
2880 player.extra_data[ATTR_VOLUME_TARGET] = (volume_level, time.monotonic())
2881
2882 def _volume_nudge_base(self, player: Player) -> int | None:
2883 """Return the volume level a volume nudge for the given player steps from."""
2884 target = self._unexpired_volume_target(player)
2885 if target is not None:
2886 return target
2887 return player.state.volume_level
2888
2889 def _group_volume_nudge_base(self, group_player: Player) -> int | None:
2890 """Return the volume level a group volume nudge for the given group steps from."""
2891 if not group_player.state.group_members:
2892 # an ungrouped player is stepped through its own volume, so it is that
2893 # volume the command lands on and that a following nudge steps from
2894 return self._volume_nudge_base(group_player)
2895 # mirrors Player.group_volume, but steps from the level last commanded to each
2896 # member instead of the level it reports, so the group is not held back by a
2897 # member that has not confirmed the previous nudge yet
2898 base: int | None = None
2899 for child_player in self.iter_group_members(
2900 group_player, only_powered=True, exclude_self=group_player.type != PlayerType.PLAYER
2901 ):
2902 if child_player.state.volume_control == PLAYER_CONTROL_NONE:
2903 continue
2904 if (child_volume := self._volume_nudge_base(child_player)) is None:
2905 continue
2906 if base is None or child_volume > base:
2907 base = child_volume
2908 return base
2909
2910 def _unexpired_volume_target(self, player: Player) -> int | None:
2911 """Return the volume level last commanded, or None once it is too old to trust."""
2912 if (target := player.extra_data.get(ATTR_VOLUME_TARGET)) is None:
2913 return None
2914 volume_level, issued_at = target
2915 if time.monotonic() - issued_at < VOLUME_TARGET_EXPIRY:
2916 return cast("int", volume_level)
2917 del player.extra_data[ATTR_VOLUME_TARGET]
2918 return None
2919
2920 def _dispatch_state_update_subscribers(
2921 self, player: Player, changed_values: dict[str, tuple[Any, Any]]
2922 ) -> None:
2923 """Notify all internal subscribers of a player state update."""
2924 for subscriber in list(self._state_update_subscribers):
2925 try:
2926 subscriber(player, changed_values)
2927 except Exception:
2928 self.logger.exception(
2929 "Error in player state update subscriber for %s", player.player_id
2930 )
2931
2932 async def _wait_for_playback_state(
2933 self,
2934 player: Player,
2935 wanted_state: PlaybackState,
2936 timeout: float,
2937 minimal_time: float = 0,
2938 ) -> None:
2939 """Wait for a player to reach a playback state, with optional minimum wait time."""
2940 start_timestamp = time.time()
2941 async with self.wait_for_player_update(
2942 player.player_id,
2943 attribute_name="playback_state",
2944 attribute_value=wanted_state,
2945 timeout=timeout,
2946 ):
2947 pass
2948 elapsed = time.time() - start_timestamp
2949 if elapsed < minimal_time:
2950 await asyncio.sleep(minimal_time - elapsed)
2951
2952 async def _clear_active_output_protocol_when_idle(self, player: Player) -> None:
2953 """Wait for the player to stop playing, then clear its active output protocol."""
2954 await self._wait_for_playback_state(player, PlaybackState.IDLE, timeout=10)
2955 player.set_active_output_protocol(None)
2956
2957 def _handle_membership_cleanup_on_state_change(
2958 self, player: Player, changed_values: dict[str, tuple[Any, Any]]
2959 ) -> None:
2960 """Detach a player from its (sync)groups when a state change requires it."""
2961 # A player that became unavailable or disabled can no longer be commanded,
2962 # so we drop it from its parent group/leader directly.
2963 became_inactive = (
2964 ATTR_AVAILABLE in changed_values and changed_values[ATTR_AVAILABLE][1] is False
2965 ) or (ATTR_ENABLED in changed_values and changed_values[ATTR_ENABLED][1] is False)
2966 if became_inactive and (player.state.active_group or player.state.synced_to):
2967 self.mass.create_task(self._cleanup_player_memberships(player.player_id))
2968
2969 # A player whose power was turned off outside of an MA power command (e.g. its
2970 # linked power control was switched off directly) must be unsynced too. We act
2971 # only on an explicit on->off transition, leaving players without power control
2972 # (powered == None) untouched. The player is still reachable here, so we route
2973 # through cmd_ungroup which also transfers leadership when it is a sync leader.
2974 if (
2975 changed_values.get(ATTR_POWERED) == (True, False)
2976 and player.state.type in UNGROUP_ON_POWER_OFF_TYPES
2977 and (player.state.synced_to or player.state.active_group or player.state.group_members)
2978 ):
2979 self.mass.create_task(self.cmd_ungroup(player.player_id))
2980
2981 def _handle_external_power_off(
2982 self, player: Player, changed_values: dict[str, tuple[Any, Any]]
2983 ) -> None:
2984 """End the queue of a player whose power was turned off outside of MA."""
2985 if (
2986 changed_values.get(ATTR_POWERED) != (True, False)
2987 or player.state.type not in PLAYBACK_TARGET_TYPES
2988 ):
2989 return
2990 if player.state.synced_to or player.state.active_group or player.state.group_members:
2991 # a grouped player is detached from its group instead, which ends the
2992 # group's queue through the group's own power off
2993 return
2994 # a device that powers itself off may report its stop in this very update, so
2995 # judge on the playback state as it was before it - which is also the snapshot
2996 # an MA power off works from
2997 prev_playback_state = (
2998 changed_values["playback_state"][0]
2999 if "playback_state" in changed_values
3000 else player.state.playback_state
3001 )
3002 if prev_playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
3003 return
3004 self.mass.call_later(
3005 EXTERNAL_POWER_OFF_STOP_DELAY,
3006 self._stop_queue_on_external_power_off,
3007 player.player_id,
3008 task_id=f"external_power_off_stop_{player.player_id}",
3009 )
3010
3011 async def _stop_queue_on_external_power_off(self, player_id: str) -> None:
3012 """
3013 End the queue of a player that is still powered off.
3014
3015 :param player_id: The player whose power was turned off outside of MA.
3016 """
3017 # hold the playback lock across the checks: a power on that overlaps the wait
3018 # holds it while it resumes the queue, and reading the power state before that
3019 # completes would stop the playback it just started (the lock is re-entrant per
3020 # task, so the stop below re-acquiring it is a no-op)
3021 async with self.get_player_lock(player_id, PlayerLockPurpose.PLAYBACK):
3022 if not (player := self.get_player(player_id)) or player.state.powered is not False:
3023 # the power came back on while we were waiting it out
3024 return
3025 # only the player's own queue: get_active_queue resolves to another player's
3026 # queue as soon as this one is hearing someone else's audio
3027 active_queue = self.get_active_queue(player)
3028 if active_queue is None or active_queue.queue_id != player_id:
3029 return
3030 # a player gone unavailable along with its power cannot be told to stop,
3031 # but the queue teardown that matters here runs regardless
3032 with suppress(PlayerUnavailableError):
3033 await self.mass.player_queues._handle_stop(player_id)
3034
3035 async def _cleanup_player_memberships(self, player_id: str) -> None:
3036 """Ensure a player is detached from any groups or syncgroups."""
3037 if not (player := self.get_player(player_id)):
3038 return
3039 with suppress(UnsupportedFeaturedException, PlayerCommandFailed, PlayerUnavailableError):
3040 if parent_id := (player.state.active_group or player.state.synced_to):
3041 # the player is part of a (permanent) groupplayer and the user tries to ungroup
3042 if parent_player := self.get_player(parent_id):
3043 await self._handle_set_members(parent_player, player_ids_to_remove=[player_id])
3044 return
3045
3046 def _get_player_with_redirect(self, player_id: str) -> Player:
3047 """Get player with check if playback related command should be redirected."""
3048 player = self.get_player(player_id, True)
3049 assert player is not None # for type checking
3050 if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
3051 self.logger.info(
3052 "Player %s is synced to %s and can not accept "
3053 "playback related commands itself, "
3054 "redirected the command to the sync leader.",
3055 player.name,
3056 sync_leader.name,
3057 )
3058 return sync_leader
3059 if player.state.active_group and (
3060 active_group := self.get_player(player.state.active_group)
3061 ):
3062 self.logger.info(
3063 "Player %s is part of a playergroup and can not accept "
3064 "playback related commands itself, "
3065 "redirected the command to the group leader.",
3066 player.name,
3067 )
3068 return active_group
3069 return player
3070
3071 def _get_active_audio_source(self, player: Player) -> tuple[AudioSource, PluginProvider] | None:
3072 """
3073 Return the live AudioSource a player is playing, and its owning PluginProvider.
3074
3075 A player hearing its group's or sync leader's audio is playing that player's
3076 source, so the owner is resolved the same way its active queue is. Returns
3077 None when no source is playing on it, or when the owning plugin is gone.
3078
3079 :param player: The player whose source to resolve.
3080 """
3081 return self.get_player_audio_source(self._audio_source_owner(player).player_id)
3082
3083 def _audio_source_owner(self, player: Player) -> Player:
3084 """
3085 Return the player whose source the given player is hearing.
3086
3087 Mirrors ``get_active_queue``: a sync child hears its leader, a group member
3088 hears its group, and a protocol player hears its parent.
3089
3090 :param player: The player to resolve the owner for.
3091 """
3092 if player.state.synced_to and player.state.synced_to != player.player_id:
3093 if sync_leader := self.get_player(player.state.synced_to):
3094 return self._audio_source_owner(sync_leader)
3095 if player.state.active_group and player.state.active_group != player.player_id:
3096 if group_player := self.get_player(player.state.active_group):
3097 return self._audio_source_owner(group_player)
3098 if player.type == PlayerType.PROTOCOL and player.protocol_parent_id:
3099 if parent_player := self.get_player(player.protocol_parent_id):
3100 return self._audio_source_owner(parent_player)
3101 return player
3102
3103 def _get_player_groups(self, player: Player) -> Iterator[Player]:
3104 """
3105 Return all group players the given player is a member of.
3106
3107 :param player: The player to look up the group memberships for.
3108 """
3109 # A group player mirrors its members, so it is also included while unavailable -
3110 # skipping it there is exactly how its state goes stale.
3111 player_id = player.player_id
3112 for _player in self.iter_players():
3113 if _player.player_id == player_id:
3114 continue
3115 if _player.state.type != PlayerType.GROUP:
3116 continue
3117 if player_id in _player.state.group_members:
3118 yield _player
3119
3120 # Protocol linking methods are provided by ProtocolLinkingMixin (protocol_linking.py)
3121
3122 def _repair_protocol_parent_links(self) -> None:
3123 """
3124 Repair protocol parent links in player configs on startup.
3125
3126 Scans player configs with a protocol_parent_id set and clears parent_ids
3127 that point to player configs that no longer exist (e.g., deleted universal
3128 players). A valid parent link also proves the player is a protocol child,
3129 so a stale player_type (left behind by an aborted registration) is healed.
3130 """
3131 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
3132 for player_id, player_config in all_player_configs.items():
3133 values = player_config.get("values") or {}
3134 parent_id = values.get(CONF_PROTOCOL_PARENT_ID)
3135 if not parent_id:
3136 continue
3137 # Check if parent config still exists
3138 parent_config = all_player_configs.get(parent_id)
3139 if not parent_config:
3140 self.logger.debug(
3141 "Clearing stale protocol_parent_id %s for %s (parent config deleted)",
3142 parent_id,
3143 player_id,
3144 )
3145 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
3146 self.mass.config.set(conf_key, None)
3147 continue
3148 if player_config.get("player_type") != PlayerType.PROTOCOL.value:
3149 self.logger.info(
3150 "Repairing player type of %s - linked as protocol child of %s",
3151 player_id,
3152 parent_id,
3153 )
3154 self.mass.config.set_player_type(player_id, PlayerType.PROTOCOL)
3155
3156 async def _fix_group_member_configs(self) -> None:
3157 """
3158 Fix stale protocol player IDs in sync group member configs.
3159
3160 When a sync group references a protocol player ID instead of
3161 the parent player ID, correct it using the cached protocol parent mapping.
3162 """
3163 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
3164 total_fixes = 0
3165 fixed_groups: list[str] = []
3166
3167 for group_id, group_config in list(all_player_configs.items()):
3168 if group_config.get("provider") != "sync_group":
3169 continue
3170 old_members: list[str] = group_config.get("values", {}).get(CONF_GROUP_MEMBERS, [])
3171 if not old_members:
3172 continue
3173
3174 new_members: list[str] = []
3175 changes = 0
3176 for member_id in old_members:
3177 parent_id = self._get_cached_protocol_parent_id(member_id)
3178 corrected_id = parent_id or member_id
3179 if corrected_id != member_id:
3180 changes += 1
3181 self.logger.debug(
3182 "Sync group %s: corrected member %s -> %s",
3183 group_id,
3184 member_id,
3185 corrected_id,
3186 )
3187 if corrected_id not in new_members:
3188 new_members.append(corrected_id)
3189
3190 if changes:
3191 self.mass.config.set_raw_player_config_value(
3192 group_id, CONF_GROUP_MEMBERS, new_members
3193 )
3194 total_fixes += changes
3195 fixed_groups.append(group_id)
3196
3197 for group_id in fixed_groups:
3198 if (group_player := self.get_player(group_id)) and group_player.available:
3199 await group_player.on_config_updated()
3200
3201 if total_fixes:
3202 self.logger.info(
3203 "Fixed %d stale member reference(s) across %d sync group(s)",
3204 total_fixes,
3205 len(fixed_groups),
3206 )
3207
3208 async def _poll_players(self) -> None:
3209 """Background task that polls players for updates."""
3210 while True:
3211 for player in list(self._players.values()):
3212 # if the player is playing, update elapsed time every tick
3213 # to ensure the queue has accurate details
3214 player_playing = player.state.playback_state == PlaybackState.PLAYING
3215 if player_playing and player.type != PlayerType.PROTOCOL:
3216 self.mass.call_later(
3217 0.5,
3218 self.mass.player_queues.on_player_update,
3219 player,
3220 {"corrected_elapsed_time": (None, player.state.corrected_elapsed_time)},
3221 task_id=f"queue_on_player_update_{player.player_id}",
3222 )
3223 # Poll player;
3224 if not player.needs_poll:
3225 continue
3226 try:
3227 last_poll: float = player.extra_data[ATTR_LAST_POLL]
3228 except KeyError:
3229 last_poll = 0.0
3230 if (self.mass.loop.time() - last_poll) < player.poll_interval:
3231 continue
3232 player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
3233 try:
3234 await player.poll()
3235 except Exception as err:
3236 self.logger.warning(
3237 "Error while requesting latest state from player %s: %s",
3238 player.state.name,
3239 str(err),
3240 exc_info=err if self.logger.isEnabledFor(10) else None,
3241 )
3242 # Yield to event loop to prevent blocking
3243 await asyncio.sleep(0)
3244 await asyncio.sleep(1)
3245
3246 def _handle_group_dsp_change(
3247 self, player: Player, prev_group_members: list[str], new_group_members: list[str]
3248 ) -> None:
3249 """Handle DSP reload when group membership changes."""
3250 # reset cached group volume snapshot since membership changed
3251 player.extra_data.pop(ATTR_GROUP_VOLUME_SNAPSHOT, None)
3252 prev_child_count = len(prev_group_members)
3253 new_child_count = len(new_group_members)
3254 is_player_group = player.state.type == PlayerType.GROUP
3255
3256 # handle special case for PlayerGroups: since there are no leaders,
3257 # DSP still always work with a single player in the group.
3258 multi_device_dsp_threshold = 1 if is_player_group else 0
3259
3260 prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
3261 new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
3262
3263 if prev_is_multiple_devices == new_is_multiple_devices:
3264 return # no change in multi-device status
3265
3266 supports_multi_device_dsp = (
3267 PlayerFeature.MULTI_DEVICE_DSP in player.state.supported_features
3268 )
3269
3270 dsp_enabled: bool
3271 if player.state.type == PlayerType.GROUP:
3272 # Since player groups do not have leaders, we will use the only child
3273 # that was in the group before and after the change
3274 if prev_is_multiple_devices:
3275 if childs := new_group_members:
3276 # We shrank the group from multiple players to a single player
3277 # So the now only child will control the DSP
3278 dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
3279 else:
3280 dsp_enabled = False
3281 elif childs := prev_group_members:
3282 # We grew the group from a single player to multiple players,
3283 # let's see if the previous single player had DSP enabled
3284 dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
3285 else:
3286 dsp_enabled = False
3287 else:
3288 dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
3289
3290 if dsp_enabled and not supports_multi_device_dsp:
3291 # We now know that the group configuration has changed so:
3292 # - multi-device DSP is not supported
3293 # - we switched from a group with multiple players to a single player
3294 # (or vice versa)
3295 # - the leader has DSP enabled
3296 self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
3297
3298 def _check_external_source_takeover(self, player: Player) -> None:
3299 """
3300 Handle when an external source takes over playback on a player.
3301
3302 When a player has an active grouped output protocol (e.g., AirPlay group) and
3303 an external source (e.g., Spotify Connect, TV input) takes over playback,
3304 we need to clear the active output protocol and ungroup the protocol players.
3305
3306 This prevents the situation where the player appears grouped via protocol
3307 but is actually playing from a different source.
3308
3309 :param player: The player whose active_source changed.
3310 """
3311 # Only relevant for non-protocol players
3312 if player.type == PlayerType.PROTOCOL:
3313 return
3314
3315 # Not a takeover if the player is not actively playing
3316 if player.playback_state != PlaybackState.PLAYING:
3317 return
3318
3319 # Only relevant if we have an active output protocol (not native)
3320 if not player.active_output_protocol or player.active_output_protocol == "native":
3321 return
3322
3323 new_source = player.state.active_source
3324
3325 # Check if new source is external (not MA-managed)
3326 if self._is_ma_managed_source(player, new_source):
3327 return
3328
3329 # Get the active protocol player
3330 protocol_player = self.get_player(player.active_output_protocol)
3331 if not protocol_player:
3332 return
3333
3334 # If the source matches the active protocol's domain, it's expected - not a takeover
3335 # e.g., source "airplay" when using AirPlay protocol is normal
3336 if new_source and new_source.lower() == protocol_player.provider.domain.lower():
3337 return
3338
3339 if (
3340 new_source
3341 and new_source.lower() in ("airplay", "cast", "chromecast", "network")
3342 and protocol_player.provider.domain.lower() == "sendspin"
3343 ):
3344 # Special case for Sendspin bridge: if the new source matches cast or airplay and the
3345 # active protocol is Sendspin, we consider this a normal behavior and not a takeover
3346 return
3347
3348 # Confirmed external source takeover
3349 self.logger.info(
3350 "External source '%s' took over on %s while playing via protocol %s - "
3351 "clearing active output protocol and ungrouping",
3352 new_source,
3353 player.display_name,
3354 protocol_player.provider.domain,
3355 )
3356
3357 # Set active output protocol to native
3358 player.set_active_output_protocol("native")
3359
3360 # Ungroup the protocol player (async task)
3361 self.mass.create_task(protocol_player.ungroup())
3362
3363 def _is_ma_managed_source(self, player: Player, source: str | None) -> bool:
3364 """
3365 Check if a source is managed by Music Assistant.
3366
3367 MA-managed sources include:
3368 - None (=autodetect, no source explicitly set by player)
3369 - The player's own ID (MA queue)
3370 - Any active queue ID
3371 - Any live AudioSource session
3372
3373 :param player: The player to check.
3374 :param source: The source ID to check.
3375 :return: True if the source is MA-managed, False if external.
3376 """
3377 if source is None:
3378 return True
3379
3380 # Player's own ID means MA queue is active
3381 if source == player.player_id:
3382 return True
3383
3384 # A live AudioSource (e.g. Spotify Connect) is streamed by MA itself, so it is
3385 # MA that put it on the player rather than something taking the player over
3386 if self.is_live_audio_source(source):
3387 return True
3388
3389 # Check if it's a known queue ID
3390 return self.mass.player_queues.get(source) is not None
3391
3392 def _schedule_update_all_players(self, delay: float = 2.0) -> None:
3393 """
3394 Schedule a debounced update of all players' state.
3395
3396 Used when a new player is registered to ensure all existing players
3397 update their dynamic properties (like can_group_with) that may have changed.
3398
3399 :param delay: Delay in seconds before triggering updates (default 2.0).
3400 """
3401 if self.mass.closing:
3402 return
3403
3404 for player in self.all_players(
3405 return_unavailable=True,
3406 return_disabled=False,
3407 return_protocol_players=True,
3408 ):
3409 self.trigger_player_update(player.player_id, debounce_delay=delay)
3410
3411 async def _auto_ungroup_if_synced(self, player: Player, log_context: str) -> None:
3412 """
3413 Automatically ungroup a player if it's synced to another player.
3414
3415 :param player: The player to check and potentially ungroup.
3416 :param log_context: Additional context for the log message (e.g., target player name).
3417 """
3418 if not player.state.synced_to and not player.state.active_group:
3419 return
3420 self.logger.info(
3421 "Player %s is already synced to %s, ungrouping it first before %s",
3422 player.name,
3423 player.state.synced_to or player.state.active_group,
3424 log_context,
3425 )
3426 # Use internal _handle_set_members to avoid deadlocking on the play lock
3427 # (we're already inside a cmd_set_members chain that holds a play lock).
3428 synced_to = player.state.synced_to or player.state.active_group
3429 if synced_to and (parent := self.get_player(synced_to)):
3430 try:
3431 async with self.wait_for_player_update(player.player_id, timeout=5):
3432 await self._handle_set_members(parent, player_ids_to_remove=[player.player_id])
3433 except asyncio.CancelledError:
3434 raise
3435 except Exception:
3436 self.logger.warning(
3437 "Failed to auto-ungroup %s from %s, proceeding anyway",
3438 player.name,
3439 synced_to,
3440 )
3441
3442 async def _dissolve_own_group(self, player: Player, target_name: str) -> bool:
3443 """
3444 Dissolve the group a player leads before it joins another group.
3445
3446 Only a player whose members ride its own stream has a group to give up: it stops
3447 rendering that stream the moment it joins the other group, which would leave its
3448 members silent while they still show up as grouped. A group that is still playing
3449 is refused instead, so the player stays out rather than silencing its members.
3450
3451 :param player: The player about to join another group.
3452 :param target_name: Name of the group being joined, for the log messages.
3453 :return: False when the group is still in place, so the player must not join.
3454 """
3455 if not player.native_grouping_requires_own_stream:
3456 return True
3457 members = [
3458 member_id for member_id in player.state.group_members if member_id != player.player_id
3459 ]
3460 if not members:
3461 return True
3462 if player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
3463 # Grouping never offers a rendering leader as a target, so reaching this means
3464 # the caller worked from a can_group_with snapshot taken before the playback
3465 # started. Tearing the group down now would cut its members off mid-track.
3466 self.logger.warning(
3467 "Player %s is still serving its own group, leaving it out of %s",
3468 player.name,
3469 target_name,
3470 )
3471 return False
3472 self.logger.info(
3473 "Player %s leads a group of its own, dissolving it before it joins %s",
3474 player.name,
3475 target_name,
3476 )
3477 # Removing the members rather than the leader keeps this out of the leadership
3478 # transfer path, and the internal handler avoids deadlocking on the play lock
3479 # (we're already inside a cmd_set_members chain that holds it).
3480 try:
3481 await self._handle_set_members(player, player_ids_to_remove=members)
3482 except asyncio.CancelledError:
3483 raise
3484 except Exception:
3485 # Joining on top of a group it still leads is the silent-member state this
3486 # dissolve exists to prevent, and one the player can no longer be talked out of.
3487 self.logger.warning(
3488 "Could not dissolve the group of %s, leaving it out of %s",
3489 player.name,
3490 target_name,
3491 )
3492 return False
3493 return True
3494
3495 async def _handle_set_members(
3496 self,
3497 parent_player: Player,
3498 player_ids_to_add: list[str] | None = None,
3499 player_ids_to_remove: list[str] | None = None,
3500 ) -> None:
3501 """
3502 Handle the actual set_members logic.
3503
3504 Skips permission checks and locking (internal use only).
3505
3506 :param parent_player: The parent player to add/remove members to/from.
3507 :param player_ids_to_add: List of player_id's to add to the parent player.
3508 :param player_ids_to_remove: List of player_id's to remove from the parent player.
3509 """
3510 target_player = parent_player.player_id
3511 # handle the sync leader being removed from itself: either transfer leadership
3512 # to a remaining member (keeping playback alive) or dissolve the group entirely
3513 should_stop = False
3514 if player_ids_to_remove and target_player in player_ids_to_remove:
3515 remaining_members = [
3516 m
3517 for m in parent_player.state.group_members
3518 if m != target_player
3519 and m not in player_ids_to_remove
3520 and (member := self.get_player(m))
3521 and member.state.available
3522 ]
3523 # a new leader must be able to render audio: a group with only display,
3524 # visualizer or lighting members left has no playback heir and dissolves
3525 has_playback_heir = any(
3526 (member := self.get_player(m)) and member.state.type in PLAYBACK_TARGET_TYPES
3527 for m in remaining_members
3528 )
3529 active_queue = self.get_active_queue(parent_player)
3530 if has_playback_heir and active_queue and active_queue.state != PlaybackState.IDLE:
3531 # transfer leadership to a remaining member instead of dissolving
3532 await self._transfer_ad_hoc_leadership(parent_player, remaining_members)
3533 return
3534 self.logger.info(
3535 "Dissolving sync group of player %s as it is being removed from itself",
3536 parent_player.name,
3537 )
3538 player_ids_to_add = None
3539 player_ids_to_remove = [
3540 x for x in parent_player.state.group_members if x != target_player
3541 ]
3542 should_stop = True
3543 # filter all player ids on compatibility and availability
3544 final_player_ids_to_add: list[str] = []
3545 for child_player_id in player_ids_to_add or []:
3546 if child_player_id == target_player:
3547 continue
3548 if child_player_id in final_player_ids_to_add:
3549 continue
3550 if (
3551 not (child_player := self.get_player(child_player_id))
3552 or not child_player.state.available
3553 ):
3554 self.logger.warning("Player %s is not available", child_player_id)
3555 continue
3556
3557 # check if player can be synced/grouped with the target player
3558 # state.can_group_with already handles all expansion and translation
3559 if child_player_id not in parent_player.state.can_group_with:
3560 self.logger.warning(
3561 "Player %s can not be grouped with %s",
3562 child_player.name,
3563 parent_player.name,
3564 )
3565 continue
3566
3567 if (
3568 child_player.state.synced_to
3569 and child_player.state.synced_to == target_player
3570 and child_player_id in parent_player.state.group_members
3571 ):
3572 continue # already synced to this target
3573
3574 # also skip if the child is part of this group via its sync leader
3575 # (e.g. synced to the sync leader of this syncgroup)
3576 if (
3577 child_player.state.active_group == target_player
3578 and child_player_id in parent_player.state.group_members
3579 ):
3580 continue
3581
3582 # handle edge case: child player is synced to a different player
3583 # automatically ungroup it first and wait for state to propagate
3584 # but not if the child is already part of this group (via its sync leader)
3585 if child_player.state.synced_to and target_player not in {
3586 child_player.state.synced_to,
3587 child_player.state.active_group,
3588 }:
3589 await self._auto_ungroup_if_synced(child_player, f"joining {parent_player.name}")
3590
3591 # handle edge case: the child leads a native group of its own, which it cannot
3592 # keep serving from inside this one
3593 if not await self._dissolve_own_group(child_player, parent_player.state.name):
3594 continue
3595
3596 # power on the player if needed
3597 if (
3598 not child_player.state.powered
3599 and child_player.state.power_control != PLAYER_CONTROL_NONE
3600 ):
3601 await self._handle_cmd_power(child_player.player_id, True)
3602 # if we reach here, all checks passed
3603 final_player_ids_to_add.append(child_player_id)
3604
3605 # process player ids to remove and filter out invalid/unavailable players and edge cases
3606 final_player_ids_to_remove: list[str] = []
3607 if player_ids_to_remove:
3608 for child_player_id in player_ids_to_remove:
3609 if child_player_id in parent_player.state.group_members:
3610 final_player_ids_to_remove.append(child_player_id)
3611 continue
3612 # also accept the removal if the child player itself reports
3613 # being synced to this parent - handles race conditions where the
3614 # parent's group_members state is stale/not yet updated. The
3615 # native synced_to is checked as well: a protocol child's state
3616 # value is translated to the visible parent, which would reject
3617 # a removal correctly addressed at its native sync leader.
3618 child_player = self.get_player(child_player_id)
3619 if child_player and target_player in (
3620 child_player.state.synced_to,
3621 child_player.synced_to,
3622 ):
3623 final_player_ids_to_remove.append(child_player_id)
3624 continue
3625
3626 # Forward command to the appropriate player after all (base) sanity checks
3627 # GROUP players (sync_group, universal_group) manage their own members internally
3628 # and don't need protocol translation - call their set_members directly
3629 if (
3630 parent_player.type == PlayerType.GROUP
3631 and PlayerFeature.SET_MEMBERS in parent_player.state.supported_features
3632 ):
3633 await parent_player.set_members(
3634 player_ids_to_add=final_player_ids_to_add,
3635 player_ids_to_remove=final_player_ids_to_remove,
3636 )
3637 return
3638 # For regular players, handle protocol selection and translation
3639 await self._handle_set_members_with_protocols(
3640 parent_player, final_player_ids_to_add, final_player_ids_to_remove
3641 )
3642
3643 if should_stop:
3644 await self._stop_player_or_its_queue(parent_player)
3645
3646 async def _handle_set_members_with_protocols(
3647 self,
3648 parent_player: Player,
3649 player_ids_to_add: list[str],
3650 player_ids_to_remove: list[str],
3651 ) -> None:
3652 """
3653 Handle set_members considering protocol and native members.
3654
3655 Skips permission checks, locking, and all redirect logic (internal use only).
3656 Translates visible player IDs to protocol player IDs when appropriate,
3657 and forwards to the correct player's set_members.
3658
3659 :param parent_player: The parent player to add/remove members to/from.
3660 :param player_ids_to_add: List of visible player IDs to add as members.
3661 :param player_ids_to_remove: List of visible player IDs to remove from members.
3662 """
3663 # Get parent's active protocol domain and player if available
3664 parent_protocol_domain = None
3665 parent_protocol_player = None
3666 if (
3667 parent_player.active_output_protocol
3668 and parent_player.active_output_protocol != "native"
3669 ):
3670 parent_protocol_player = self.get_player(parent_player.active_output_protocol)
3671 if parent_protocol_player:
3672 parent_protocol_domain = parent_protocol_player.provider.domain
3673
3674 self.logger.debug(
3675 "set_members on %s: active_protocol=%s, adding=%s, removing=%s",
3676 parent_player.state.name,
3677 parent_protocol_domain or "none",
3678 player_ids_to_add,
3679 player_ids_to_remove,
3680 )
3681
3682 # Translate members to add
3683 (
3684 protocol_members_to_add,
3685 native_members_to_add,
3686 parent_protocol_player,
3687 parent_protocol_domain,
3688 ) = self._translate_members_for_protocols(
3689 parent_player, player_ids_to_add, parent_protocol_player, parent_protocol_domain
3690 )
3691
3692 self.logger.debug(
3693 "Translated members: protocol=%s (domain=%s), native=%s",
3694 protocol_members_to_add,
3695 parent_protocol_domain,
3696 native_members_to_add,
3697 )
3698
3699 # Translate members to remove
3700 protocol_members_to_remove, native_members_to_remove = (
3701 self._translate_members_to_remove_for_protocols(
3702 parent_player, player_ids_to_remove, parent_protocol_player, parent_protocol_domain
3703 )
3704 )
3705
3706 # Forward protocol members to protocol player's set_members
3707 if (protocol_members_to_add or protocol_members_to_remove) and parent_protocol_player:
3708 await self._forward_protocol_set_members(
3709 parent_player,
3710 parent_protocol_player,
3711 protocol_members_to_add,
3712 protocol_members_to_remove,
3713 )
3714
3715 # Forward native members to parent player's set_members
3716 if native_members_to_add or native_members_to_remove:
3717 filtered_native_add = self._filter_native_members(native_members_to_add, parent_player)
3718 # For removal, allow protocol players if they're actually in the parent's group_members
3719 # This handles native protocol players (e.g., native AirPlay) where group_members
3720 # contains protocol player IDs
3721 filtered_native_remove = [
3722 pid
3723 for pid in native_members_to_remove
3724 if (p := self.get_player(pid))
3725 and (p.type != PlayerType.PROTOCOL or pid in parent_player.group_members)
3726 ]
3727 self.logger.debug(
3728 "Native grouping on %s: filtered_add=%s, filtered_remove=%s",
3729 parent_player.state.name,
3730 filtered_native_add,
3731 filtered_native_remove,
3732 )
3733 if filtered_native_add or filtered_native_remove:
3734 if PlayerFeature.SET_MEMBERS not in parent_player.state.supported_features:
3735 return
3736 self.logger.info(
3737 "Calling set_members on native player %s with add=%s, remove=%s",
3738 parent_player.state.name,
3739 filtered_native_add,
3740 filtered_native_remove,
3741 )
3742 await parent_player.set_members(
3743 player_ids_to_add=filtered_native_add or None,
3744 player_ids_to_remove=filtered_native_remove or None,
3745 )
3746
3747 async def _transfer_ad_hoc_leadership(
3748 self, leader: Player, remaining_members: list[str]
3749 ) -> None:
3750 """
3751 Transfer leadership of an ad-hoc sync group to a remaining member.
3752
3753 Called when the sync leader of an ad-hoc group is unjoined while other
3754 members remain and playback is active. The queue is moved to a newly
3755 selected leader, the remaining members are regrouped under it and playback
3756 resumes at the saved position (accepting a brief audio gap).
3757
3758 :param leader: The current sync leader being removed from the group.
3759 :param remaining_members: Available group members (excluding the leader)
3760 that should keep playing under a new leader.
3761 """
3762 active_queue = self.get_active_queue(leader)
3763 was_playing = active_queue is not None and active_queue.state == PlaybackState.PLAYING
3764 new_leader_id = self._select_ad_hoc_leader(leader, remaining_members)
3765 self.logger.info(
3766 "Transferring leadership of %s to %s (%s remaining member(s))",
3767 leader.name,
3768 new_leader_id,
3769 len(remaining_members),
3770 )
3771 # Move the queue to the new leader. transfer_queue frees the new leader from
3772 # the old leader's group and stops the old leader; the playback position
3773 # survives because stop() stores it in resume_pos.
3774 await self.mass.player_queues.transfer_queue(
3775 leader.player_id, new_leader_id, auto_play=False
3776 )
3777 # regroup the other remaining members under the new leader
3778 other_members = [m for m in remaining_members if m != new_leader_id]
3779 if other_members:
3780 await self.cmd_set_members(new_leader_id, player_ids_to_add=other_members)
3781 if was_playing:
3782 await self.mass.player_queues.resume(new_leader_id)
3783
3784 def _select_ad_hoc_leader(self, leader: Player, remaining_members: list[str]) -> str:
3785 """
3786 Pick the new leader for an ad-hoc sync group leadership transfer.
3787
3788 Prefers a remaining member that can currently be reached on the protocol the
3789 group is playing on, so the other members can be regrouped under it; falls back
3790 to the first remaining member. The members' own ``can_group_with`` is unusable
3791 here because it is empty while they are still synced to the old leader.
3792
3793 :param leader: The current sync leader being removed.
3794 :param remaining_members: Candidate member player_ids, already filtered for
3795 availability. Must contain at least one audio-capable member.
3796 """
3797 # non-audio members (display/visualizer/lighting) stay in the group as followers
3798 # but can never inherit the queue
3799 candidates = [
3800 m
3801 for m in remaining_members
3802 if (member := self.get_player(m)) and member.state.type in PLAYBACK_TARGET_TYPES
3803 ]
3804 active_domain: str | None = None
3805 if leader.active_output_protocol and leader.active_output_protocol != "native":
3806 if protocol_player := self.get_player(leader.active_output_protocol):
3807 active_domain = protocol_player.provider.domain
3808 if active_domain:
3809 for member_id in candidates:
3810 member = self.get_player(member_id)
3811 if member is None:
3812 continue
3813 if active_domain in member.playback_domains:
3814 return member_id
3815 return candidates[0]
3816
3817 async def _stop_player_or_its_queue(self, player: Player) -> None:
3818 """
3819 Stop the player, ending its queue when it is playing one of its own.
3820
3821 :param player: The player to stop.
3822 """
3823 # End the queue itself, exactly as a stop command does: stopping only the device
3824 # leaves the queue session open, so its preloading keeps pulling audio and a
3825 # provider streaming a live session (Spotify) stays tethered for another track or
3826 # two. Restricted to the player's own queue: get_active_queue resolves a protocol
3827 # player to its parent, and stopping that parent's queue would come straight back
3828 # here. The permission-free handler, because both callers act on behalf of the
3829 # server rather than a user that can address the player.
3830 if (
3831 active_queue := self.get_active_queue(player)
3832 ) and active_queue.queue_id == player.player_id:
3833 await self.mass.player_queues._handle_stop(player.player_id)
3834 return
3835 await self._handle_cmd_stop(player.player_id)
3836
3837 def _clear_sleep_timer(self, player: Player) -> None:
3838 """
3839 Clear the active sleep timer for the player.
3840
3841 :param player: Player to clear the timer for.
3842 """
3843 self.mass.cancel_timer(self._sleep_timer_task_id(player.player_id))
3844 if player.sleep_timer_expires_at is not None:
3845 player.set_sleep_timer_expires_at(None)
3846 player.update_state()
3847 self._signal_sleep_timer_updated(player, None)
3848
3849 async def _handle_sleep_timer_expired(self, player_id: str) -> None:
3850 """
3851 Stop playback when a player's sleep timer expires.
3852
3853 :param player_id: Player ID whose sleep timer expired.
3854 """
3855 player = self.get_player(player_id)
3856 if player is None or player.sleep_timer_expires_at is None:
3857 return
3858 player.set_sleep_timer_expires_at(None)
3859 player.update_state()
3860 self._signal_sleep_timer_updated(player, None)
3861 await self.cmd_stop(player_id)
3862
3863 def _signal_sleep_timer_updated(self, player: Player, expires_at: float | None) -> None:
3864 """
3865 Signal a sleep timer change for the player on the event bus.
3866
3867 :param player: Player whose sleep timer changed.
3868 :param expires_at: New expiry timestamp, or None when the timer was cleared.
3869 """
3870 if player.state.type == PlayerType.PROTOCOL:
3871 return
3872 self.mass.signal_event(
3873 EventType.PLAYER_SLEEP_TIMER_UPDATED,
3874 object_id=player.player_id,
3875 data=expires_at,
3876 )
3877
3878 @staticmethod
3879 def _sleep_timer_task_id(player_id: str) -> str:
3880 """
3881 Return the scheduled task ID for a player's sleep timer.
3882
3883 :param player_id: Player ID to build the task ID for.
3884 """
3885 return f"player_sleep_timer_{player_id}"
3886
3887 # Private command handlers (no permission checks)
3888
3889 async def _handle_cmd_resume(
3890 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
3891 ) -> None:
3892 """
3893 Handle resume playback command.
3894
3895 Skips permission checks and locking (internal use only).
3896 """
3897 player = self._get_player_with_redirect(player_id)
3898 source = source or player.state.active_source
3899 media = media or player.state.current_media
3900 # power on the player if needed
3901 if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
3902 await self._handle_cmd_power(player.player_id, True)
3903 # Redirect to queue controller if it is active
3904 if active_queue := self.mass.player_queues.get(source or player_id):
3905 await self.mass.player_queues.resume(active_queue.queue_id)
3906 return
3907 # try to handle command on player directly
3908 # TODO: check if player has an active source with native resume support
3909 active_source = next((x for x in player.state.source_list if x.id == source), None)
3910 if (
3911 player.state.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
3912 and active_source
3913 and active_source.can_play_pause
3914 and PlayerFeature.PAUSE in player.state.supported_features
3915 ):
3916 # player has some other source active and native resume support
3917 await player.play()
3918 return
3919 if active_source and not active_source.passive:
3920 await self.select_source(player_id, active_source.id)
3921 return
3922 if media:
3923 # try to re-play the current media item
3924 await player.play_media(media)
3925 return
3926 # fallback: just try to resume queue playback
3927 await self.mass.player_queues.resume(player.player_id)
3928
3929 async def _handle_cmd_power(
3930 self, player_id: str, powered: bool, skip_auto_play: bool = False
3931 ) -> None:
3932 """
3933 Handle player power on/off command.
3934
3935 Skips permission checks and locking (internal use only).
3936
3937 :param player_id: The player ID to power on/off.
3938 :param powered: True to power on, False to power off.
3939 :param skip_auto_play: If True, skip auto-play on power on.
3940 """
3941 player = self.get_player(player_id, True)
3942 assert player is not None # for type checking
3943 player_state = player.state
3944
3945 if player_state.powered == powered:
3946 self.logger.debug(
3947 "Ignoring power %s command for player %s: already in state %s",
3948 "ON" if powered else "OFF",
3949 player_state.name,
3950 "ON" if player_state.powered else "OFF",
3951 )
3952 return # nothing to do
3953
3954 # ungroup player at power off
3955 player_was_sync_child = bool(player.state.synced_to or player.state.active_group)
3956 if (
3957 (player_was_sync_child or player.group_members)
3958 and player.type in UNGROUP_ON_POWER_OFF_TYPES
3959 and not powered
3960 ):
3961 # ungroup player if it is synced (or is a sync leader itself)
3962 await self.cmd_ungroup(player_id)
3963
3964 # always stop player at power off
3965 if (
3966 not powered
3967 and not player_was_sync_child
3968 and player_state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
3969 ):
3970 # wait for the stop command to process and prevent race conditions
3971 async with self.wait_for_player_update(player_id, timeout=5):
3972 await self._stop_player_or_its_queue(player)
3973
3974 # power off all synced childs when player is a sync leader
3975 elif (
3976 not powered
3977 and player_state.type in UNGROUP_ON_POWER_OFF_TYPES
3978 and player_state.group_members
3979 ):
3980 async with TaskManager(self.mass) as tg:
3981 for member in self.iter_group_members(player, True):
3982 if member.power_control == PLAYER_CONTROL_NONE:
3983 continue
3984 tg.create_task(self._handle_cmd_power(member.player_id, False))
3985
3986 # handle actual power command
3987 if player_state.power_control == PLAYER_CONTROL_NONE:
3988 self.logger.debug(
3989 "Player %s does not support power control, ignoring power command",
3990 player_state.name,
3991 )
3992 return
3993 if player_state.power_control == PLAYER_CONTROL_NATIVE:
3994 # player supports power command natively: forward to player provider
3995 await player.power(powered)
3996 if powered:
3997 await wait_for_power_on(self.logger, player)
3998 elif player_state.power_control == PLAYER_CONTROL_FAKE:
3999 # user wants to use fake power control - so we (optimistically) update the state
4000 # and store the state in the cache
4001 player.extra_data[ATTR_FAKE_POWER] = powered
4002 # Group players need to actively form/dissolve their session when the
4003 # user toggles fake power — otherwise the toggle would only update the
4004 # cosmetic state without ever capturing or releasing the members.
4005 if player_state.type == PlayerType.GROUP:
4006 await player.power(powered)
4007 player.update_state() # trigger update of the player state
4008 if player_state.type != PlayerType.GROUP:
4009 # see register(): group fake-power is intentionally not persisted
4010 # because there is no session to restore at boot.
4011 await self.mass.cache.set(
4012 key=player_id,
4013 data=powered,
4014 provider=self.domain,
4015 category=CACHE_CATEGORY_PLAYER_POWER,
4016 )
4017 # handle external player control
4018 elif player_control := self._controls.get(player.state.power_control):
4019 control_name = player_control.name
4020 self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
4021 if not player_control.supports_power:
4022 raise UnsupportedFeaturedException(
4023 f"Player control {control_name} is not available"
4024 )
4025 if powered:
4026 assert player_control.power_on is not None # for type checking
4027 await player_control.power_on()
4028 await wait_for_power_on(self.logger, player, player_control)
4029 else:
4030 assert player_control.power_off is not None # for type checking
4031 await player_control.power_off()
4032 # always trigger a state update to update the UI
4033 player.refresh_state()
4034
4035 # handle 'auto play on power on' feature
4036 if (
4037 not skip_auto_play
4038 and not player_state.active_group
4039 and not player_state.synced_to
4040 and powered
4041 and player.config.get_value(CONF_AUTO_PLAY)
4042 and player_state.active_source in (None, player_id)
4043 and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
4044 ):
4045 await self.mass.player_queues.resume(player_id)
4046
4047 def _resolve_group_volume_player(self, player: Player) -> Player | None:
4048 """
4049 Return the player whose group a group volume command applies to.
4050
4051 Returns None if the given player is not grouped at all. Commands addressed to a
4052 synced member and to its sync leader resolve to the same player, so they read
4053 and guard one and the same group.
4054
4055 :param player: The player the command was addressed to.
4056 """
4057 # the group volume lock this resolves to may not share the VOLUME purpose:
4058 # set_group_volume sets the volume of the members concurrently and a sync leader
4059 # is a member of its own group, so a group command would wait on its own lock.
4060 if player.state.type == PlayerType.GROUP or player.state.group_members:
4061 # dedicated group player or sync leader
4062 return player
4063 if player.state.synced_to:
4064 # a synced player follows its sync leader
4065 return self.get_player(player.state.synced_to)
4066 return None
4067
4068 async def _set_member_volume(self, player_id: str, volume_level: int) -> None:
4069 """
4070 Set the volume of a single member as part of a group volume change.
4071
4072 :param player_id: player_id of the member to handle the command.
4073 :param volume_level: logical volume level (0..100) to set on the member.
4074 """
4075 # record before waiting for the lock, for the same reason as cmd_volume_set
4076 if member := self.get_player(player_id):
4077 self._record_volume_target(member, volume_level)
4078 # take the volume lock of the member itself, so a group volume change and an
4079 # individual volume command for that member can not overtake one another
4080 async with self.get_player_lock(player_id, PlayerLockPurpose.VOLUME):
4081 await self._handle_cmd_volume_set(player_id, volume_level, record_target=False)
4082
4083 async def _handle_cmd_volume_set(
4084 self, player_id: str, volume_level: int, *, record_target: bool = True
4085 ) -> None:
4086 """
4087 Handle Player volume set command.
4088
4089 Skips permission checks and locking (internal use only).
4090
4091 :param player_id: player_id of the player to handle the command.
4092 :param volume_level: logical volume level (0..100) to set on the player.
4093 :param record_target: Set to False when the caller already recorded the level as
4094 the base for the next volume nudge, before it waited for the volume lock.
4095 """
4096 player = self.get_player(player_id, True)
4097 assert player is not None # for type checker
4098
4099 # Clamp logical volume to 0-100
4100 volume_level = max(0, min(100, volume_level))
4101
4102 if player.type == PlayerType.GROUP:
4103 # redirect to special group volume control
4104 await self.cmd_group_volume(player_id, volume_level)
4105 return
4106
4107 # A muted player stays muted: only an explicit unmute lifts it, and the level
4108 # set here is the one it plays at once that happens. Fake mute is the exception,
4109 # because it is simulated with the volume itself.
4110 if self._stays_silent_on_volume_change(player):
4111 # a locked player stays silent, the volume it holds is the one
4112 # that gets restored once it is unmuted again
4113 volume_level = 0
4114 # the lock may have been earned after the caller recorded the level it asked
4115 # for, which is then not the level this player ends up at
4116 record_target = True
4117 else:
4118 player.extra_data.pop(ATTR_FAKE_MUTE, None)
4119
4120 if record_target:
4121 self._record_volume_target(player, volume_level)
4122
4123 # Scale logical volume (0-100) to device volume (min_volume-max_volume)
4124 device_volume = self.scale_volume_to_device(player_id, volume_level)
4125
4126 await self._notify_source_volume_change(player, volume_level)
4127
4128 # Handle native volume control support
4129 if player.volume_control == PLAYER_CONTROL_NATIVE:
4130 # player supports volume command natively: forward to player
4131 await player.volume_set(device_volume)
4132 return
4133 # Handle fake volume control support
4134 if player.volume_control == PLAYER_CONTROL_FAKE:
4135 # user wants to use fake volume control - so we (optimistically) update the state
4136 # and store the state in the cache. Fake volume uses the logical volume (no scaling).
4137 player.extra_data[ATTR_FAKE_VOLUME] = volume_level
4138 player.update_state()
4139 return
4140 # player has no volume support at all
4141 if player.volume_control == PLAYER_CONTROL_NONE:
4142 raise UnsupportedFeaturedException(
4143 f"Player {player.state.name} does not support volume control"
4144 )
4145 # handle external player control
4146 if player_control := self._controls.get(player.state.volume_control):
4147 control_name = player_control.name
4148 self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
4149 if not player_control.supports_volume:
4150 raise UnsupportedFeaturedException(
4151 f"Player control {control_name} is not available"
4152 )
4153 assert player_control.volume_set is not None
4154 # forward the already-scaled device volume; the external control sets the
4155 # raw device volume and does not apply min/max scaling of its own
4156 await player_control.volume_set(device_volume)
4157 return
4158 if protocol_player := self.get_player(player.state.volume_control):
4159 # forward the already-scaled device volume: the limits configured on this
4160 # (user-facing) player are the only ones that apply to the command
4161 self.logger.debug(
4162 "Redirecting volume command to protocol player %s",
4163 protocol_player.provider.manifest.name,
4164 )
4165 await protocol_player.volume_set(device_volume)
4166 return
4167
4168 @staticmethod
4169 def _is_in_group(state: PlayerState) -> bool:
4170 """Check if the player with the given state is currently grouped with other players."""
4171 # a sync leader has neither synced_to nor active_group set, but it does lead its
4172 # own group_members, which stays empty for a player that is not grouped at all
4173 return bool(state.synced_to or state.active_group or state.group_members)
4174
4175 def _has_active_mute_lock(self, player: Player) -> bool:
4176 """
4177 Check if the given player holds a mute lock that still applies to it.
4178
4179 A lock is only earned inside a group and only holds for as long as the player
4180 is still grouped, so it can not outlive the group it was earned in.
4181
4182 :param player: The player to check, which may be a protocol player.
4183 """
4184 if player.extra_data.get(ATTR_MUTE_LOCK) and self._is_in_group(player.state):
4185 return True
4186 # cmd_volume_mute stores the lock on the parent player, while the volume command
4187 # may arrive with the protocol player ID (e.g. during group volume changes)
4188 if player.protocol_parent_id and (parent := self.get_player(player.protocol_parent_id)):
4189 return bool(parent.extra_data.get(ATTR_MUTE_LOCK)) and self._is_in_group(parent.state)
4190 return False
4191
4192 def _stays_silent_on_volume_change(self, player: Player) -> bool:
4193 """Check if a volume command for the given player lands at 0 to keep it silent."""
4194 return (
4195 self._has_active_mute_lock(player)
4196 and player.mute_control == PLAYER_CONTROL_FAKE
4197 and bool(player.extra_data.get(ATTR_FAKE_MUTE))
4198 )
4199
4200 async def _mute_group_members(self, group_player: Player, muted: bool) -> None:
4201 """
4202 Mute or unmute all mute capable members of a player group or synced players.
4203
4204 :param group_player: The group player or sync leader.
4205 :param muted: bool if the group should be muted.
4206 """
4207 coros = []
4208 for child_player in self.iter_group_members(
4209 group_player, only_powered=True, exclude_self=False
4210 ):
4211 if child_player.mute_control == PLAYER_CONTROL_NONE:
4212 # members without a mute control are left alone, just like the
4213 # group mute state itself is calculated from the capable members only
4214 continue
4215 coros.append(self.cmd_volume_mute(child_player.player_id, muted))
4216 await asyncio.gather(*coros)
4217
4218 async def _handle_cmd_volume_mute(self, player: Player, mute_control: str, muted: bool) -> None:
4219 """
4220 Send the mute command to the given player's mute control.
4221
4222 Skips permission checks, locking and mute lock bookkeeping (internal use only).
4223
4224 :param player: the player to handle the command.
4225 :param mute_control: the already resolved mute control of the player.
4226 :param muted: bool if player should be muted.
4227 """
4228 if mute_control == PLAYER_CONTROL_NATIVE:
4229 # player supports mute command natively: forward to player
4230 await player.volume_mute(muted)
4231 return
4232 if mute_control == PLAYER_CONTROL_FAKE:
4233 # user wants to use fake mute control - so we use volume instead
4234 self.logger.debug(
4235 "Using volume for muting for player %s",
4236 player.state.name,
4237 )
4238 if muted:
4239 already_muted = bool(player.extra_data.get(ATTR_FAKE_MUTE))
4240 if not already_muted:
4241 # on a repeated mute command the volume is already 0
4242 player.extra_data[ATTR_PREVIOUS_VOLUME] = player.state.volume_level
4243 await self._handle_cmd_volume_set(player.player_id, 0)
4244 # set the flag after the volume command, as that clears it
4245 player.extra_data[ATTR_FAKE_MUTE] = True
4246 player.update_state()
4247 else:
4248 was_muted = bool(player.extra_data.get(ATTR_FAKE_MUTE))
4249 player.extra_data[ATTR_FAKE_MUTE] = False
4250 player.update_state()
4251 if not was_muted:
4252 # the volume is the one the user is listening at, restoring
4253 # anything here would turn a no-op unmute into a volume change
4254 return
4255 stored_volume: int | None = player.extra_data.pop(ATTR_PREVIOUS_VOLUME, None)
4256 # the volume was still unknown at mute time, so pick a low volume
4257 # rather than blasting the speaker at some assumed level
4258 await self._handle_cmd_volume_set(
4259 player.player_id, 1 if stored_volume is None else stored_volume
4260 )
4261 return
4262
4263 # handle external player control
4264 if player_control := self._controls.get(mute_control):
4265 control_name = player_control.name
4266 self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
4267 if not player_control.supports_mute:
4268 raise UnsupportedFeaturedException(
4269 f"Player control {control_name} is not available"
4270 )
4271 assert player_control.mute_set is not None
4272 await player_control.mute_set(muted)
4273 return
4274
4275 # handle to protocol player as volume_mute control
4276 if protocol_player := self.get_player(mute_control):
4277 self.logger.debug(
4278 "Redirecting mute command to protocol player %s",
4279 protocol_player.provider.manifest.name,
4280 )
4281 await protocol_player.volume_mute(muted)
4282 return
4283
4284 # the configured control disappeared after the mute control was resolved
4285 raise UnsupportedFeaturedException(f"Player {player.state.name} does not support muting")
4286
4287 async def _handle_play_media(self, player_id: str, media: PlayerMedia) -> None:
4288 """
4289 Handle play media command without group redirect.
4290
4291 Skips permission checks, locking, and all redirect logic (internal use only).
4292
4293 :param player_id: player_id of the player to handle the command.
4294 :param media: The Media that needs to be played on the player.
4295 """
4296 player = self.get_player(player_id, raise_unavailable=True)
4297 assert player is not None
4298 # media that is not the live source itself takes the player away from it. An
4299 # announcement is the exception: it interrupts the player and hands it straight
4300 # back, so releasing the source would tear down a session that is about to
4301 # resume — and one that cannot be re-selected once its plugin has let go.
4302 if media.media_type not in (MediaType.AUDIO_SOURCE, MediaType.ANNOUNCEMENT):
4303 await self._release_audio_source(player_id)
4304 # set active source if media has a source_id (e.g. plugin source or mass queue source)
4305 if media.source_id:
4306 player.set_active_mass_source(media.source_id)
4307
4308 # Determine output protocol to use:
4309 # While a session is active (playing/paused), keep using the already active
4310 # protocol so mid-session commands stay on the same output.
4311 # On a fresh start always (re)select: a leftover active protocol from a
4312 # previous session must not overrule user preference, a grouped protocol
4313 # or native playback (and it may point at a player that is gone by now).
4314 target_player: Player | None = None
4315 output_protocol: OutputProtocol | None = None
4316 if (
4317 player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
4318 and player.active_output_protocol
4319 and player.active_output_protocol != "native"
4320 and (protocol_player := self.get_player(player.active_output_protocol))
4321 ):
4322 # Use the already-set protocol directly
4323 output_protocol = player.get_linked_protocol(player.active_output_protocol)
4324 if output_protocol is not None:
4325 target_player = protocol_player
4326 if target_player is None:
4327 target_player, output_protocol = self._select_best_output_protocol(player)
4328
4329 if target_player.player_id != player.player_id:
4330 # Playing via linked protocol - update active output protocol
4331 # output_protocol is guaranteed to be non-None when target_player != player
4332 assert output_protocol is not None
4333 self.logger.debug(
4334 "Starting playback on %s via protocol %s (target=%s), group_members=%s",
4335 player.state.name,
4336 output_protocol.name,
4337 target_player.display_name,
4338 target_player.state.group_members,
4339 )
4340 player.set_active_output_protocol(output_protocol.output_protocol_id)
4341 elif player.type != PlayerType.GROUP:
4342 # Native playback - group players don't have output protocols of their own
4343 # (they delegate to a sync leader / member which manages its own protocol)
4344 self.logger.debug(
4345 "Starting playback on %s via native, group_members=%s",
4346 player.state.name,
4347 player.state.group_members,
4348 )
4349 player.set_active_output_protocol("native")
4350
4351 # power on the player if needed (skip auto-play since we're about to start playback)
4352 if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
4353 await self._handle_cmd_power(player.player_id, True, skip_auto_play=True)
4354 await target_player.play_media(media)
4355 if target_player.player_id != player.player_id:
4356 # notify the native player that protocol playback started
4357 assert output_protocol is not None
4358 await player.on_protocol_playback(output_protocol=output_protocol)
4359
4360 async def _handle_enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
4361 """
4362 Handle enqueue next media command without group redirect.
4363
4364 Skips permission checks, locking, and all redirect logic (internal use only).
4365
4366 :param player_id: player_id of the player to handle the command.
4367 :param media: The Media that needs to be enqueued on the player.
4368 """
4369 player = self.get_player(player_id, raise_unavailable=True)
4370 assert player is not None
4371 if target_player := self._get_control_target(
4372 player,
4373 required_feature=PlayerFeature.ENQUEUE,
4374 require_active=True,
4375 ):
4376 self.logger.debug(
4377 "Redirecting enqueue command to protocol player %s",
4378 target_player.provider.manifest.name,
4379 )
4380 await target_player.enqueue_next_media(media)
4381 return
4382
4383 if PlayerFeature.ENQUEUE not in player.state.supported_features:
4384 raise UnsupportedFeaturedException(
4385 f"Player {player.state.name} does not support enqueueing"
4386 )
4387 await player.enqueue_next_media(media)
4388
4389 async def _notify_source_volume_change(self, player: Player, volume_level: int) -> None:
4390 """
4391 Tell the source playing on a player that its volume changed.
4392
4393 Only the player the source is actually playing on notifies, never one that
4394 merely hears it as a group member — otherwise a group volume change would
4395 fire the callback once per child, each with a different value.
4396
4397 :param player: The player whose volume changed.
4398 :param volume_level: The new volume, 0-100.
4399 """
4400 if (session := self.get_audio_source_session(player.player_id)) is None:
4401 return
4402 provider = self.mass.get_provider(session.provider_instance_id)
4403 if not isinstance(provider, PluginProvider):
4404 return
4405 await provider.on_volume_change(session.source_id, volume_level)
4406
4407 def _resolve_command_target(self, player: Player, source_id: str | None) -> str:
4408 """
4409 Return the source (id) a command issued to a player applies to.
4410
4411 :param player: The player the command was issued to.
4412 :param source_id: The source the caller aimed the command at, if it named one.
4413 :return: The id of the player's active source, which is the id of Music
4414 Assistant's own queue when nothing else is playing on it.
4415 :raises PlayerCommandFailed: When the caller named a source that is no longer
4416 the one playing.
4417 """
4418 active_source_id = player.state.active_source or player.player_id
4419 if source_id is not None and source_id != active_source_id:
4420 msg = f"The source this was meant for is no longer playing on {player.state.name}."
4421 raise PlayerCommandFailed(msg)
4422 return active_source_id
4423
4424 async def _forward_to_external_source(
4425 self,
4426 player: Player,
4427 action: SourceControl,
4428 value: SourceControlValue = None,
4429 ) -> bool:
4430 """
4431 Hand a control action to the external source playing on a player.
4432
4433 Covers the external sources Music Assistant provides itself, which own a
4434 session it can talk to. A source belonging to the player (its line-in, TV
4435 input, or its own Spotify Connect) has no such session, so this reports that
4436 it did not take the action and the caller goes on to the player itself.
4437
4438 The per-action transport flags gate what the source advertises it can do, so
4439 a client is refused rather than left waiting. Ordering is not gated here: the
4440 session decides what reordering means for its own content. Most sources do not
4441 implement it at all, though, so a client should ask the source whether it can
4442 before offering the control - handing it one that quietly does nothing is
4443 worse than not offering it.
4444
4445 :param player: The player the action was issued to.
4446 :param action: The control action to hand over.
4447 :param value: The action's argument, where it takes one.
4448 :return: True when an external source took the action.
4449 """
4450 if (active := self._get_active_audio_source(player)) is None:
4451 return False
4452 audio_source, provider = active
4453 supported = {
4454 SourceControl.PLAY: audio_source.can_play_pause,
4455 SourceControl.PAUSE: audio_source.can_play_pause,
4456 SourceControl.SEEK: audio_source.can_seek,
4457 SourceControl.NEXT: audio_source.can_next_previous,
4458 SourceControl.PREVIOUS: audio_source.can_next_previous,
4459 }.get(action, True)
4460 if not supported:
4461 msg = (
4462 f"The active source ({audio_source.name}) on player "
4463 f"{player.display_name} does not support this action"
4464 )
4465 raise PlayerCommandFailed(msg)
4466 try:
4467 await provider.on_source_control(audio_source.item_id, action, value)
4468 except NotImplementedError as err:
4469 # a source with no control surface at all (vban_receiver) reaches the base
4470 # implementation; a caller deserves a refusal rather than a server error
4471 msg = (
4472 f"The active source ({audio_source.name}) on player "
4473 f"{player.display_name} can not be controlled"
4474 )
4475 raise PlayerCommandFailed(msg) from err
4476 return True
4477
4478 async def _release_audio_source(self, player_id: str) -> None:
4479 """
4480 Let go of the live source a player was playing, if it had one.
4481
4482 Tells the owning plugin so an upstream session still pointing at Music
4483 Assistant is released. A plugin that raises must not stop the player from
4484 moving on, so failures are logged rather than propagated.
4485
4486 :param player_id: The player that is done with its source.
4487 """
4488 if (session := self._end_audio_source_session(player_id)) is None:
4489 return
4490 self.trigger_player_update(player_id)
4491 provider = self.mass.get_provider(session.provider_instance_id)
4492 if not isinstance(provider, PluginProvider):
4493 return
4494 try:
4495 await provider.on_source_released(session.source_id, player_id)
4496 except Exception:
4497 self.logger.warning(
4498 "on_source_released raised for provider %s source %s player %s",
4499 provider.instance_id,
4500 session.source_id,
4501 player_id,
4502 exc_info=True,
4503 )
4504
4505 async def _release_unclaimed_audio_source(
4506 self, player_id: str, session: AudioSourceSession, playback_session_id: str
4507 ) -> None:
4508 """
4509 Release a source whose renderer never requested the stream.
4510
4511 The play command returned without error, so the late-start release in the
4512 streams controller never fires: no stream request means no failed stream
4513 request either. Without this the player would keep publishing a source
4514 that never started, with its own queue held inactive behind it.
4515
4516 :param player_id: The player the source was started on.
4517 :param session: The session that was started for it.
4518 :param playback_session_id: Playback session active when it was started.
4519 """
4520 current = self.get_audio_source_session(player_id)
4521 if (
4522 current is not session
4523 or current.playback_session_id != playback_session_id
4524 or current.stream_session_id is not None
4525 ):
4526 return
4527 self.logger.info(
4528 "AudioSource %s was never streamed by player %s, releasing it",
4529 session.source_id,
4530 player_id,
4531 )
4532 await self.deselect_source(
4533 player_id,
4534 provider_instance_id=session.provider_instance_id,
4535 source_id=session.source_id,
4536 playback_session_id=playback_session_id,
4537 )
4538
4539 async def _resolve_audio_source_uri(
4540 self, source: str
4541 ) -> tuple[AudioSource, PluginProvider] | None:
4542 """
4543 Resolve a source string to a live AudioSource, if that is what it names.
4544
4545 :param source: The source string a select names.
4546 :return: The source and its owning plugin, or None when the string names
4547 something else (a queue, a player-native source).
4548 """
4549 if "://" not in source:
4550 return None
4551 try:
4552 item = await self.mass.music.get_item_by_uri(source)
4553 except MusicAssistantError as err:
4554 # not resolvable as media, so it is something else (a queue id, a
4555 # player-native source) — logged because a provider being unavailable
4556 # or unauthenticated also lands here
4557 self.logger.debug("Could not resolve %s as an audio source: %s", source, err)
4558 return None
4559 if not isinstance(item, AudioSource):
4560 return None
4561 provider = self.mass.get_provider(item.provider)
4562 if not isinstance(provider, PluginProvider):
4563 return None
4564 if ProviderFeature.AUDIO_SOURCE not in provider.supported_features:
4565 return None
4566 return item, provider
4567
4568 async def _start_audio_source(
4569 self, player: Player, audio_source: AudioSource, provider: PluginProvider
4570 ) -> None:
4571 """
4572 Start a live external source on a player.
4573
4574 The player's queue is left exactly as it is: it simply stops being the
4575 active source, so it is still there to resume when the source ends.
4576
4577 :param player: The player to play the source on.
4578 :param audio_source: The source that was selected.
4579 :param provider: The plugin exposing that source.
4580 """
4581 # a player outputs one source at a time, so another one already on it has to be
4582 # handed back first: replacing the session silently would leave its plugin
4583 # holding an upstream session that still points at this player
4584 if (current := self.get_audio_source_session(player.player_id)) is not None and (
4585 current.source_id != audio_source.item_id
4586 or current.provider_instance_id != provider.instance_id
4587 ):
4588 await self._release_audio_source(player.player_id)
4589 session = self._start_audio_source_session(
4590 player.player_id, audio_source, provider.instance_id
4591 )
4592 try:
4593 await self._handle_play_media(
4594 player.player_id,
4595 PlayerMedia(
4596 uri=audio_source.uri or audio_source.item_id,
4597 media_type=MediaType.AUDIO_SOURCE,
4598 title=audio_source.name,
4599 # the session's owner, which its stream url is keyed on
4600 source_id=player.player_id,
4601 queue_session_id=session.playback_session_id,
4602 ),
4603 )
4604 except Exception:
4605 # the source never started, so the player must not go on publishing it:
4606 # a session left behind holds the queue inactive with nothing playing it
4607 if self.get_audio_source_session(player.player_id) is session:
4608 await self._release_audio_source(player.player_id)
4609 raise
4610 # the play command returning does not mean the renderer ever fetched the
4611 # stream url: until a stream request claims the session nothing will evict
4612 # the player the source may be moving from, and nothing else would ever
4613 # clear a session that is never streamed
4614 self.mass.call_later(
4615 AUDIO_SOURCE_CLAIM_TIMEOUT,
4616 self._release_unclaimed_audio_source,
4617 player.player_id,
4618 session,
4619 session.playback_session_id,
4620 task_id=f"release_unclaimed_audio_source_{player.player_id}",
4621 )
4622
4623 async def _handle_select_source(self, player_id: str, source: str | None) -> None:
4624 """
4625 Handle select source command without group redirect.
4626
4627 Skips permission checks, locking, and all redirect logic (internal use only).
4628
4629 :param player_id: player_id of the player to handle the command.
4630 :param source: The ID of the source that needs to be activated/selected.
4631 """
4632 if source is None:
4633 source = player_id # default to MA queue source
4634 player = self.get_player(player_id, True)
4635 assert player is not None
4636 # check if player is already playing and source is different
4637 # in that case we need to stop the player first
4638 prev_source = player.state.active_source
4639 if prev_source and source != prev_source:
4640 with suppress(PlayerCommandFailed, RuntimeError):
4641 # just try to stop (regardless of state) and let it settle, so the
4642 # new source does not race the teardown. A player that already
4643 # reports idle has nothing to tear down and does not wait at all.
4644 async with self.wait_for_player_update(
4645 player_id,
4646 attribute_name="playback_state",
4647 attribute_value=PlaybackState.IDLE,
4648 timeout=5,
4649 ):
4650 await self._handle_cmd_stop(player_id)
4651 # an audio source uri selects the live source itself, which plays on the
4652 # player while its queue keeps its own items and goes inactive
4653 if (resolved := await self._resolve_audio_source_uri(source)) is not None:
4654 await self._start_audio_source(player, *resolved)
4655 return
4656 # anything else takes the player away from a live source it was playing
4657 await self._release_audio_source(player_id)
4658 # check if source is a mass queue
4659 # this can be used to restore the queue after a source switch
4660 if self.mass.player_queues.get(source):
4661 player.set_active_mass_source(source)
4662 return
4663 # Legacy compatibility: the old plugin-source API used the
4664 # plugin's instance_id directly as the source string. The refactor
4665 # moved plugin sources to first-class AudioSource MediaItems played
4666 # via player_queues.play_media. Translate a legacy plugin-instance-id
4667 # source into the new flow so old frontends, third-party scripts,
4668 # and HA automations keep working — but only when the provider
4669 # exposes EXACTLY ONE AudioSource (it was always a 1:1 mapping under
4670 # the old API; multi-source providers have to use the explicit URI).
4671 if (legacy_prov := self.mass.get_provider(source)) and isinstance(
4672 legacy_prov, PluginProvider
4673 ):
4674 if ProviderFeature.AUDIO_SOURCE not in legacy_prov.supported_features:
4675 raise PlayerCommandFailed(f"Provider {source} does not expose AudioSources")
4676 sources = await legacy_prov.get_audio_sources()
4677 if len(sources) == 1:
4678 self.logger.debug(
4679 "Translating legacy select_source(%s) to play_media(%s)",
4680 source,
4681 sources[0].uri,
4682 )
4683 await self.mass.player_queues.play_media(player_id, str(sources[0].uri))
4684 return
4685 raise UnsupportedFeaturedException(
4686 f"Provider {source} exposes {len(sources)} AudioSources; the legacy "
4687 "select_source(plugin_instance_id) API only supported 1:1 mappings. "
4688 "Use player_queues.play_media with an explicit AudioSource URI."
4689 )
4690 # basic check if player supports source selection
4691 if PlayerFeature.SELECT_SOURCE not in player.state.supported_features:
4692 raise UnsupportedFeaturedException(
4693 f"Player {player.state.name} does not support source selection"
4694 )
4695 # basic check if source is valid for player
4696 if not any(x for x in player.state.source_list if x.id == source):
4697 raise PlayerCommandFailed(
4698 f"{source} is an invalid source for player {player.state.name}"
4699 )
4700 # forward to player
4701 await player.select_source(source)
4702
4703 async def _handle_cmd_stop(self, player_id: str) -> None:
4704 """
4705 Handle stop command without any redirects.
4706
4707 Skips permission checks, locking, and all redirect logic (internal use only).
4708
4709 :param player_id: player_id of the player to handle the command.
4710 """
4711 player = self.get_player(player_id, raise_unavailable=True)
4712 assert player is not None
4713 protocol_player: Player | None = None
4714 if player.active_output_protocol and player.active_output_protocol != "native":
4715 protocol_player = self.get_player(player.active_output_protocol)
4716 if player.state.playback_state == PlaybackState.IDLE:
4717 # The player already reports idle but an output protocol is still marked
4718 # active: the protocol player may never have received a stop at all
4719 # (e.g. the source stream ended on its own before this stop command
4720 # arrived). Forward an (idempotent) stop and schedule the protocol clear
4721 # so no stale session lingers on the device and the next playback
4722 # (re)selects the output protocol.
4723 if protocol_player is not None:
4724 await protocol_player.stop()
4725 if len(protocol_player.group_members) <= 1:
4726 self.schedule_active_output_protocol_clear(player)
4727 return
4728 player.mark_stop_called()
4729 # Delegate to active protocol player if one is active
4730 target_player = player
4731 if protocol_player is not None:
4732 target_player = protocol_player
4733 if PlayerFeature.POWER in target_player.supported_features:
4734 # if protocol player supports/requires power,
4735 # we power it off instead of just stopping (which also stops playback)
4736 # this is rare as most protocols do not support power control (except for cast)
4737 await self._handle_cmd_power(target_player.player_id, False)
4738 return
4739
4740 # handle command on player(protocol) directly
4741 await target_player.stop()
4742 # Only clear active protocol if the protocol player has no remaining group members.
4743 # If there are still protocol group members, keep the protocol active so that
4744 # when playback resumes it continues on the same protocol.
4745 if target_player.player_id == player.player_id or len(target_player.group_members) <= 1:
4746 self.schedule_active_output_protocol_clear(player)
4747
4748 async def _handle_cmd_play(self, player_id: str) -> None:
4749 """
4750 Handle play command without group redirect.
4751
4752 Skips permission checks, locking, and all redirect logic (internal use only).
4753
4754 :param player_id: player_id of the player to handle the command.
4755 """
4756 player = self.get_player(player_id, raise_unavailable=True)
4757 assert player is not None
4758 if player.state.playback_state == PlaybackState.PLAYING:
4759 self.logger.info(
4760 "Ignore PLAY request to player %s: player is already playing", player.state.name
4761 )
4762 return
4763 # If an AudioSource is the active queue item, proxy play to the plugin
4764 if active := self._get_active_audio_source(player):
4765 audio_source, plugin_prov = active
4766 if audio_source.can_play_pause:
4767 await plugin_prov.on_source_control(audio_source.item_id, SourceControl.PLAY)
4768 return
4769 # handle unpause (=play if player is paused)
4770 if player.state.playback_state == PlaybackState.PAUSED:
4771 active_source = next(
4772 (x for x in player.state.source_list if x.id == player.state.active_source), None
4773 )
4774 # raise if active source does not support play/pause
4775 if active_source and not active_source.can_play_pause:
4776 msg = (
4777 f"The active source ({active_source.name}) on player "
4778 f"{player.state.name} does not support play/pause"
4779 )
4780 raise PlayerCommandFailed(msg)
4781 # Delegate to active protocol player if one is active
4782 if target_player := self._get_control_target(
4783 player, PlayerFeature.PAUSE, require_active=True
4784 ):
4785 await target_player.play()
4786 return
4787 # No active protocol target: if the player rendering the audio supports pause and
4788 # the active (external) source can be paused, unpause it directly instead of
4789 # restarting the source.
4790 output_player = player.resolve_output_player()
4791 if (
4792 active_source
4793 and active_source.can_play_pause
4794 and PlayerFeature.PAUSE in output_player.supported_features
4795 ):
4796 await output_player.play()
4797 return
4798
4799 # player is not paused: try to resume the player
4800 # Note: We handle resume inline here without calling _handle_cmd_resume
4801 active_source = next(
4802 (x for x in player.state.source_list if x.id == player.state.active_source), None
4803 )
4804 media = player.state.current_media
4805 # power on the player if needed
4806 if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
4807 await self._handle_cmd_power(player.player_id, True)
4808 if active_source and not active_source.passive:
4809 await self._handle_select_source(player_id, active_source.id)
4810 return
4811 if media:
4812 # try to re-play the current media item
4813 await player.play_media(media)
4814 return
4815 # fallback: just send play command - which will fail if nothing can be played
4816 await player.play()
4817
4818 async def _handle_cmd_pause(self, player_id: str) -> None:
4819 """
4820 Handle pause command without any redirects.
4821
4822 Skips permission checks, locking, and all redirect logic (internal use only).
4823
4824 :param player_id: player_id of the player to handle the command.
4825 """
4826 player = self.get_player(player_id, raise_unavailable=True)
4827 assert player is not None
4828 if player.state.playback_state == PlaybackState.IDLE:
4829 return
4830 # If an AudioSource is the active queue item, proxy pause to the plugin
4831 if active := self._get_active_audio_source(player):
4832 audio_source, plugin_prov = active
4833 if audio_source.can_play_pause:
4834 await plugin_prov.on_source_control(audio_source.item_id, SourceControl.PAUSE)
4835 return
4836 # handle command on player/source directly
4837 active_source = next(
4838 (x for x in player.state.source_list if x.id == player.state.active_source), None
4839 )
4840 if active_source and not active_source.can_play_pause:
4841 # raise if active source does not support play/pause
4842 msg = (
4843 f"The active source ({active_source.name}) on player "
4844 f"{player.state.name} does not support play/pause"
4845 )
4846 raise PlayerCommandFailed(msg)
4847 # Delegate to active protocol player if one is active
4848 if target_player := self._get_control_target(
4849 player, PlayerFeature.PAUSE, require_active=True
4850 ):
4851 await target_player.pause()
4852 return
4853 # No active protocol target: if the player rendering the audio supports pause and the
4854 # active (external) source can be paused, forward the command to it instead of stopping
4855 # it (mirrors the external-source handling in cmd_seek/cmd_next_track).
4856 output_player = player.resolve_output_player()
4857 if (
4858 active_source
4859 and active_source.can_play_pause
4860 and PlayerFeature.PAUSE in output_player.supported_features
4861 ):
4862 await output_player.pause()
4863 return
4864 # player/protocol does not support pause: fall back to stop
4865 self.logger.debug(
4866 "Player/protocol %s does not support pause, using STOP instead",
4867 player.state.name,
4868 )
4869 await self._handle_cmd_stop(player.player_id)
4870