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