music-assistant-server

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