music-assistant-server

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