music-assistant-server

55.9 KBPY
player.py
55.9 KB1,472 lines • python
1"""
2Base class/model for a Player within Music Assistant.
3
4All providerspecific players should inherit from this class and implement the required methods.
5
6Note that the serverside Player object is not the same as the clientside Player object,
7which is a dataclass in the models package containing the player state.
8"""
9
10from __future__ import annotations
11
12import time
13from abc import ABC, abstractmethod
14from collections.abc import Callable
15from copy import deepcopy
16from typing import TYPE_CHECKING, Any, cast, final
17
18from music_assistant_models.constants import (
19    EXTRA_ATTRIBUTES_TYPES,
20    PLAYER_CONTROL_FAKE,
21    PLAYER_CONTROL_NATIVE,
22    PLAYER_CONTROL_NONE,
23)
24from music_assistant_models.enums import (
25    MediaType,
26    PlaybackState,
27    PlayerFeature,
28    PlayerType,
29)
30from music_assistant_models.errors import UnsupportedFeaturedException
31from music_assistant_models.player import (
32    DeviceInfo,
33    PlayerMedia,
34    PlayerOption,
35    PlayerOptionValueType,
36    PlayerSoundMode,
37    PlayerSource,
38)
39from music_assistant_models.player import Player as PlayerState
40from music_assistant_models.unique_list import UniqueList
41from propcache import under_cached_property as cached_property
42
43from music_assistant.constants import (
44    ATTR_ANNOUNCEMENT_IN_PROGRESS,
45    ATTR_FAKE_MUTE,
46    ATTR_FAKE_POWER,
47    ATTR_FAKE_VOLUME,
48    CONF_ENTRY_PLAYER_ICON,
49    CONF_EXPOSE_PLAYER_TO_HA,
50    CONF_FLOW_MODE,
51    CONF_HIDE_IN_UI,
52    CONF_MUTE_CONTROL,
53    CONF_POWER_CONTROL,
54    CONF_SMART_FADES_MODE,
55    CONF_VOLUME_CONTROL,
56)
57from music_assistant.helpers.util import get_changed_dataclass_values
58
59if TYPE_CHECKING:
60    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, PlayerConfig
61
62    from .player_provider import PlayerProvider
63
64
65class Player(ABC):
66    """
67    Base representation of a Player within the Music Assistant Server.
68
69    Player Provider implementations should inherit from this base model.
70    """
71
72    _attr_type: PlayerType = PlayerType.PLAYER
73    _attr_supported_features: set[PlayerFeature]
74    _attr_group_members: list[str]
75    _attr_static_group_members: list[str]
76    _attr_device_info: DeviceInfo
77    _attr_can_group_with: set[str]
78    _attr_source_list: list[PlayerSource]
79    _attr_sound_mode_list: list[PlayerSoundMode]
80    _attr_options: list[PlayerOption]
81    _attr_available: bool = True
82    _attr_name: str | None = None
83    _attr_powered: bool | None = None
84    _attr_playback_state: PlaybackState = PlaybackState.IDLE
85    _attr_volume_level: int | None = None
86    _attr_volume_muted: bool | None = None
87    _attr_elapsed_time: float | None = None
88    _attr_elapsed_time_last_updated: float | None = None
89    _attr_active_source: str | None = None
90    _attr_active_sound_mode: str | None = None
91    _attr_current_media: PlayerMedia | None = None
92    _attr_needs_poll: bool = False
93    _attr_poll_interval: int = 30
94    _attr_hidden_by_default: bool = False
95    _attr_expose_to_ha_by_default: bool = True
96    _attr_enabled_by_default: bool = True
97
98    def __init__(self, provider: PlayerProvider, player_id: str) -> None:
99        """Initialize the Player."""
100        # set mass as public variable
101        self.mass = provider.mass
102        self.logger = provider.logger
103        # initialize mutable attributes
104        self._attr_supported_features = set()
105        self._attr_group_members = []
106        self._attr_static_group_members = []
107        self._attr_device_info = DeviceInfo()
108        self._attr_can_group_with = set()
109        self._attr_source_list = []
110        self._attr_sound_mode_list = []
111        self._attr_options = []
112        # do not override/overwrite these private attributes below!
113        self._cache: dict[str, Any] = {}  # storage dict for cached properties
114        self._player_id = player_id
115        self._provider = provider
116        self.mass.config.create_default_player_config(
117            player_id, self.provider_id, self.type, self.name, self.enabled_by_default
118        )
119        self._config = self.mass.config.get_base_player_config(player_id, self.provider_id)
120        self._extra_data: dict[str, Any] = {}
121        self._extra_attributes: dict[str, Any] = {}
122        self._on_unload_callbacks: list[Callable[[], None]] = []
123        self.__active_mass_source = player_id
124        # The PlayerState is the (snapshotted) final state of the player
125        # after applying any config overrides and other transformations,
126        # such as the display name and player controls.
127        # the state is updated when calling 'update_state' and is what is sent over the API.
128        self._state = PlayerState(
129            player_id=self.player_id,
130            provider=self.provider_id,
131            type=self.type,
132            name=self.display_name,
133            available=self.available,
134            device_info=self.device_info,
135            supported_features=self.supported_features,
136            playback_state=self.playback_state,
137        )
138
139    @property
140    def type(self) -> PlayerType:
141        """Return the type of the player."""
142        return self._attr_type
143
144    @property
145    def available(self) -> bool:
146        """Return if the player is available."""
147        return self._attr_available
148
149    @property
150    def name(self) -> str | None:
151        """Return the name of the player."""
152        return self._attr_name
153
154    @property
155    def supported_features(self) -> set[PlayerFeature]:
156        """Return the supported features of the player."""
157        return self._attr_supported_features
158
159    @property
160    def playback_state(self) -> PlaybackState:
161        """Return the current playback state of the player."""
162        return self._attr_playback_state
163
164    @property
165    def requires_flow_mode(self) -> bool:
166        """
167        Return if the player needs flow mode.
168
169        Will by default be set to True if the player does not support PlayerFeature.ENQUEUE
170        or has crossfade enabled without gapless support.
171        """
172        if PlayerFeature.ENQUEUE not in self.supported_features:
173            # without enqueue support, flow mode is required
174            return True
175        return (
176            # player has crossfade enabled without gapless support - flow mode is required
177            PlayerFeature.GAPLESS_PLAYBACK not in self.supported_features
178            and str(self._config.get_value(CONF_SMART_FADES_MODE)) != "disabled"
179        )
180
181    @property
182    def device_info(self) -> DeviceInfo:
183        """Return the device info of the player."""
184        return self._attr_device_info
185
186    @property
187    def elapsed_time(self) -> float | None:
188        """Return the elapsed time in (fractional) seconds of the current track (if any)."""
189        return self._attr_elapsed_time
190
191    @property
192    def elapsed_time_last_updated(self) -> float | None:
193        """
194        Return when the elapsed time was last updated.
195
196        return: The (UTC) timestamp when the elapsed time was last updated,
197        or None if it was never updated (or unknown).
198        """
199        return self._attr_elapsed_time_last_updated
200
201    @property
202    def group_members(self) -> list[str]:
203        """
204        Return the group members of the player.
205
206        If there are other players synced/grouped with this player,
207        this should return the id's of players synced to this player,
208        and this should include the player's own id (as first item in the list).
209
210        If there are currently no group members, this should return an empty list.
211        """
212        if self.type == PlayerType.PLAYER and (
213            len(self._attr_group_members) >= 1 and self.player_id not in self._attr_group_members
214        ):
215            # always ensure the player_id is in the group_members list for players
216            return [self.player_id, *self._attr_group_members]
217        if self._attr_group_members == [self.player_id]:
218            return []
219        return self._attr_group_members
220
221    @property
222    def static_group_members(self) -> list[str]:
223        """
224        Return the static group members for a player group.
225
226        For PlayerType.GROUP return the player_ids of members that must not be removed by
227        the user.
228        For all other player types return an empty list.
229        """
230        return self._attr_static_group_members
231
232    @property
233    def can_group_with(self) -> set[str]:
234        """
235        Return the id's of players this player can group with.
236
237        This should return set of player_id's this player can group/sync with
238        or just the provider's instance_id if all players can group with each other.
239        """
240        return self._attr_can_group_with
241
242    @property
243    def needs_poll(self) -> bool:
244        """Return if the player needs to be polled for state updates."""
245        return self._attr_needs_poll
246
247    @property
248    def poll_interval(self) -> int:
249        """
250        Return the (dynamic) poll interval for the player.
251
252        Only used if 'needs_poll' is set to True.
253        This should return the interval in seconds.
254        """
255        return self._attr_poll_interval
256
257    @property
258    def hidden_by_default(self) -> bool:
259        """Return if the player should be hidden in the UI by default."""
260        return self._attr_hidden_by_default
261
262    @property
263    def expose_to_ha_by_default(self) -> bool:
264        """Return if the player should be exposed to Home Assistant by default."""
265        return self._attr_expose_to_ha_by_default
266
267    @property
268    def enabled_by_default(self) -> bool:
269        """Return if the player should be enabled by default."""
270        return self._attr_enabled_by_default
271
272    @property
273    def _powered(self) -> bool | None:
274        """
275        Return if the player is powered on.
276
277        If the player does not support PlayerFeature.POWER,
278        or the state is (currently) unknown, this property may return None.
279
280        Note that this is NOT the final power state of the player,
281        as it may be overridden by a playercontrol.
282        Hence it's marked as a private property.
283        The final power state can be retrieved by using the 'powered' property.
284        """
285        return self._attr_powered
286
287    @property
288    def _volume_level(self) -> int | None:
289        """
290        Return the current volume level (0..100) of the player.
291
292        If the player does not support PlayerFeature.VOLUME_SET,
293        or the state is (currently) unknown, this property may return None.
294
295        Note that this is NOT the final volume level state of the player,
296        as it may be overridden by a playercontrol.
297        Hence it's marked as a private property.
298        The final volume level state can be retrieved by using the 'volume_level' property.
299        """
300        return self._attr_volume_level
301
302    @property
303    def _volume_muted(self) -> bool | None:
304        """
305        Return the current mute state of the player.
306
307        If the player does not support PlayerFeature.VOLUME_MUTE,
308        or the state is (currently) unknown, this property may return None.
309
310        Note that this is NOT the final muted state of the player,
311        as it may be overridden by a playercontrol.
312        Hence it's marked as a private property.
313        The final muted state can be retrieved by using the 'volume_muted' property.
314        """
315        return self._attr_volume_muted
316
317    @property
318    def _active_source(self) -> str | None:
319        """
320        Return the (id of) the active source of the player.
321
322        Only required if the player supports PlayerFeature.SELECT_SOURCE.
323
324        Set to None if the player is not currently playing a source or
325        the player_id if the player is currently playing a MA queue.
326
327        Note that this is NOT the final active source of the player,
328        as it may be overridden by a active group/sync membership.
329        Hence it's marked as a private property.
330        The final active source can be retrieved by using the 'active_source' property.
331        """
332        return self._attr_active_source
333
334    @property
335    def _current_media(self) -> PlayerMedia | None:
336        """
337        Return the current media being played by the player.
338
339        Note that this is NOT the final current media of the player,
340        as it may be overridden by a active group/sync membership.
341        Hence it's marked as a private property.
342        The final current media can be retrieved by using the 'current_media' property.
343        """
344        return self._attr_current_media
345
346    @property
347    def _source_list(self) -> list[PlayerSource]:
348        """
349        Return list of available (native) sources for this player.
350
351        Note that this is NOT the final source list of the player,
352        as we inject the MA queue source if the player is currently playing a MA queue.
353        Hence it's marked as a private property.
354        The final source list can be retrieved by using the 'source_list' property.
355        """
356        return self._attr_source_list
357
358    async def power(self, powered: bool) -> None:
359        """
360        Handle POWER command on the player.
361
362        Will only be called if the PlayerFeature.POWER is supported.
363
364        :param powered: bool if player should be powered on or off.
365        """
366        raise NotImplementedError("power needs to be implemented when PlayerFeature.POWER is set")
367
368    async def volume_set(self, volume_level: int) -> None:
369        """
370        Handle VOLUME_SET command on the player.
371
372        Will only be called if the PlayerFeature.VOLUME_SET is supported.
373
374        :param volume_level: volume level (0..100) to set on the player.
375        """
376        raise NotImplementedError(
377            "volume_set needs to be implemented when PlayerFeature.VOLUME_SET is set"
378        )
379
380    async def volume_mute(self, muted: bool) -> None:
381        """
382        Handle VOLUME MUTE command on the player.
383
384        Will only be called if the PlayerFeature.VOLUME_MUTE is supported.
385
386        :param muted: bool if player should be muted.
387        """
388        raise NotImplementedError(
389            "volume_mute needs to be implemented when PlayerFeature.VOLUME_MUTE is set"
390        )
391
392    async def play(self) -> None:
393        """Handle PLAY command on the player."""
394        raise NotImplementedError("play needs to be implemented")
395
396    @abstractmethod
397    async def stop(self) -> None:
398        """
399        Handle STOP command on the player.
400
401        Will only be called if the player reports PlayerFeature.PAUSE is supported or
402        player supports resuming of stopped playback.
403        """
404        raise NotImplementedError("stop needs to be implemented")
405
406    async def pause(self) -> None:
407        """
408        Handle PAUSE command on the player.
409
410        Will only be called if the player reports PlayerFeature.PAUSE is supported.
411        """
412        raise NotImplementedError("pause needs to be implemented when PlayerFeature.PAUSE is set")
413
414    async def next_track(self) -> None:
415        """
416        Handle NEXT_TRACK command on the player.
417
418        Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
419        is supported and the player is not currently playing a MA queue.
420        """
421        raise NotImplementedError(
422            "next_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
423        )
424
425    async def previous_track(self) -> None:
426        """
427        Handle PREVIOUS_TRACK command on the player.
428
429        Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
430        is supported and the player is not currently playing a MA queue.
431        """
432        raise NotImplementedError(
433            "previous_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
434        )
435
436    async def seek(self, position: int) -> None:
437        """
438        Handle SEEK command on the player.
439
440        Seek to a specific position in the current track.
441        Will only be called if the player reports PlayerFeature.SEEK is
442        supported and the player is NOT currently playing a MA queue.
443
444        :param position: The position to seek to, in seconds.
445        """
446        raise NotImplementedError("seek needs to be implemented when PlayerFeature.SEEK is set")
447
448    @abstractmethod
449    async def play_media(
450        self,
451        media: PlayerMedia,
452    ) -> None:
453        """
454        Handle PLAY MEDIA command on given player.
455
456        This is called by the Player controller to start playing Media on the player,
457        which can be a MA queue item/stream or a native source.
458        The provider's own implementation should work out how to handle this request.
459
460        :param media: Details of the item that needs to be played on the player.
461        """
462        raise NotImplementedError("play_media needs to be implemented")
463
464    async def enqueue_next_media(self, media: PlayerMedia) -> None:
465        """
466        Handle enqueuing of the next (queue) item on the player.
467
468        Called when player reports it started buffering a queue item
469        and when the queue items updated.
470
471        A PlayerProvider implementation is in itself responsible for handling this
472        so that the queue items keep playing until its empty or the player stopped.
473
474        Will only be called if the player reports PlayerFeature.ENQUEUE is
475        supported and the player is currently playing a MA queue.
476
477        This will NOT be called if the end of the queue is reached (and repeat disabled).
478        This will NOT be called if the player is using flow mode to playback the queue.
479
480         :param media: Details of the item that needs to be enqueued on the player.
481        """
482        raise NotImplementedError(
483            "enqueue_next_media needs to be implemented when PlayerFeature.ENQUEUE is set"
484        )
485
486    async def play_announcement(
487        self, announcement: PlayerMedia, volume_level: int | None = None
488    ) -> None:
489        """
490        Handle (native) playback of an announcement on the player.
491
492        Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported.
493
494        :param announcement: Details of the announcement that needs to be played on the player.
495        :param volume_level: The volume level to play the announcement at (0..100).
496            If not set, the player should use the current volume level.
497        """
498        raise NotImplementedError(
499            "play_announcement needs to be implemented when PlayerFeature.PLAY_ANNOUNCEMENT is set"
500        )
501
502    async def select_source(self, source: str) -> None:
503        """
504        Handle SELECT SOURCE command on the player.
505
506        Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
507
508        :param source: The source(id) to select, as defined in the source_list.
509        """
510        raise NotImplementedError(
511            "select_source needs to be implemented when PlayerFeature.SELECT_SOURCE is set"
512        )
513
514    async def select_sound_mode(self, sound_mode: str) -> None:
515        """
516        Handle SELECT SOUND MODE command on the player.
517
518        Will only be called if the PlayerFeature.SELECT_SOUND_MODE is supported.
519
520        :param source: The sound_mode(id) to select, as defined in the sound_mode_list.
521        """
522        raise NotImplementedError(
523            "select_sound_mode needs to be implemented when PlayerFeature.SELECT_SOUND_MODE is set"
524        )
525
526    async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
527        """
528        Handle SET_OPTION command on the player.
529
530        Will only be called if the PlayerFeature.OPTIONS is supported.
531
532        :param option_key: The option_key of the PlayerOption
533        :param option_value: The new value of the PlayerOption
534        """
535        raise NotImplementedError(
536            "set_option needs to be implemented when PlayerFeature.Option is set"
537        )
538
539    async def set_members(
540        self,
541        player_ids_to_add: list[str] | None = None,
542        player_ids_to_remove: list[str] | None = None,
543    ) -> None:
544        """
545        Handle SET_MEMBERS command on the player.
546
547        Group or ungroup the given child player(s) to/from this player.
548        Will only be called if the PlayerFeature.SET_MEMBERS is supported.
549
550        :param player_ids_to_add: List of player_id's to add to the group.
551        :param player_ids_to_remove: List of player_id's to remove from the group.
552        """
553        raise NotImplementedError(
554            "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
555        )
556
557    async def poll(self) -> None:
558        """
559        Poll player for state updates.
560
561        This is called by the Player Manager;
562        if the 'needs_poll' property is True.
563        """
564        raise NotImplementedError("poll needs to be implemented when needs_poll is True")
565
566    async def get_config_entries(
567        self,
568        action: str | None = None,
569        values: dict[str, ConfigValueType] | None = None,
570    ) -> list[ConfigEntry]:
571        """
572        Return all (provider/player specific) Config Entries for the player.
573
574        action: [optional] action key called from config entries UI.
575        values: the (intermediate) raw values for config entries sent with the action.
576        """
577        # Return any (player/provider specific) config entries for a player.
578        # To override the default config entries, simply define an entry with the same key
579        # and it will be used instead of the default one.
580        return []
581
582    async def on_config_updated(self) -> None:
583        """
584        Handle logic when the player is loaded or updated.
585
586        Override this method in your player implementation if you need
587        to perform any additional setup logic after the player is registered and
588        the self.config was loaded, and whenever the config changes.
589        """
590        return
591
592    async def on_unload(self) -> None:
593        """Handle logic when the player is unloaded from the Player controller."""
594        for callback in self._on_unload_callbacks:
595            try:
596                callback()
597            except Exception as err:
598                self.logger.error(
599                    "Error calling on_unload callback for player %s: %s",
600                    self.player_id,
601                    err,
602                )
603
604    async def group_with(self, target_player_id: str) -> None:
605        """
606        Handle GROUP_WITH command on the player.
607
608        Group this player to the given syncleader/target.
609        Will only be called if the PlayerFeature.SET_MEMBERS is supported.
610
611        :param target_player: player_id of the target player / sync leader.
612        """
613        # convenience helper method
614        # no need to implement unless your player/provider has an optimized way to execute this
615        # default implementation will simply call set_members
616        # to add the target player to the group.
617        target_player = self.mass.players.get(target_player_id, raise_unavailable=True)
618        assert target_player  # for type checking
619        await target_player.set_members(player_ids_to_add=[self.player_id])
620
621    async def ungroup(self) -> None:
622        """
623        Handle UNGROUP command on the player.
624
625        Remove the player from any (sync)groups it currently is grouped to.
626        If this player is the sync leader (or group player),
627        all child's will be ungrouped and the group dissolved.
628
629        Will only be called if the PlayerFeature.SET_MEMBERS is supported.
630        """
631        # convenience helper method
632        # no need to implement unless your player/provider has an optimized way to execute this
633        # default implementation will simply call set_members
634        if self.synced_to:
635            if parent_player := self.mass.players.get(self.synced_to):
636                # if this player is synced to another player, remove self from that group
637                await parent_player.set_members(player_ids_to_remove=[self.player_id])
638        elif self.group_members:
639            await self.set_members(player_ids_to_remove=self.group_members)
640
641    @property
642    def synced_to(self) -> str | None:
643        """
644        Return the id of the player this player is synced to (sync leader).
645
646        If this player is not synced to another player (or is the sync leader itself),
647        this should return None.
648        If it is part of a (permanent) group, this should also return None.
649        """
650        # default implementation: feel free to override
651        for player in self.mass.players.all():
652            if player.player_id == self.player_id:
653                # skip self
654                continue
655            if player.type == PlayerType.PLAYER and self.player_id in player.group_members:
656                # this player is synced to another player, but not part of a (permanent) group
657                return player.player_id
658        return None
659
660    @property
661    def active_sound_mode(self) -> str | None:
662        """Return active sound mode of this player."""
663        return self._attr_active_sound_mode
664
665    @cached_property
666    def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
667        """Return available PlayerSoundModes for Player."""
668        return UniqueList(self._attr_sound_mode_list)
669
670    @cached_property
671    def options(self) -> UniqueList[PlayerOption]:
672        """Return all PlayerOptions for Player."""
673        return UniqueList(self._attr_options)
674
675    def _on_player_media_updated(self) -> None:  # noqa: B027
676        """Handle callback when the current media of the player is updated."""
677        # optional callback for players that want to be informed when the final
678        # current media is updated (after applying group/sync membership logic).
679        # for instance to update any display information on the physical player.
680
681    # DO NOT OVERWRITE BELOW !
682    # These properties and methods are either managed by core logic or they
683    # are used to perform a very specific function. Overwriting these may
684    # produce undesirable effects.
685
686    @property
687    @final
688    def player_id(self) -> str:
689        """Return the id of the player."""
690        return self._player_id
691
692    @property
693    @final
694    def provider(self) -> PlayerProvider:
695        """Return the provider of the player."""
696        return self._provider
697
698    @property
699    @final
700    def provider_id(self) -> str:
701        """Return the provider (instance) id of the player."""
702        return self._provider.instance_id
703
704    @property
705    @final
706    def config(self) -> PlayerConfig:
707        """Return the config of the player."""
708        return self._config
709
710    @property
711    @final
712    def extra_attributes(self) -> dict[str, EXTRA_ATTRIBUTES_TYPES]:
713        """
714        Return the extra attributes of the player.
715
716        This is a dict that can be used to pass any extra (serializable)
717        attributes over the API, to be consumed by the UI (or another APi client, such as HA).
718        This is not persisted and not used or validated by the core logic.
719        """
720        return self._extra_attributes
721
722    @property
723    @final
724    def extra_data(self) -> dict[str, Any]:
725        """
726        Return the extra data of the player.
727
728        This is a dict that can be used to store any extra data
729        that is not part of the player state or config.
730        This is not persisted and not exposed on the API.
731        """
732        return self._extra_data
733
734    @cached_property
735    @final
736    def display_name(self) -> str:
737        """Return the display name of the player."""
738        if custom_name := self._config.name:
739            # always prefer the custom name over the default name
740            return custom_name
741        return self.name or self._config.default_name or self.player_id
742
743    @property
744    @final
745    def powered(self) -> bool | None:
746        """
747        Return the FINAL power state of the player.
748
749        This is a convenience property which calculates the final power state
750        based on the playercontrol which may have been set-up.
751        """
752        power_control = self.power_control
753        if power_control == PLAYER_CONTROL_FAKE:
754            return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
755        if power_control == PLAYER_CONTROL_NATIVE:
756            return self._powered
757        if power_control == PLAYER_CONTROL_NONE:
758            return None
759        if control := self.mass.players.get_player_control(power_control):
760            return control.power_state
761        return None
762
763    @property
764    @final
765    def volume_level(self) -> int | None:
766        """
767        Return the FINAL volume level of the player.
768
769        This is a convenience property which calculates the final volume level
770        based on the playercontrol which may have been set-up.
771        """
772        volume_control = self.volume_control
773        if volume_control == PLAYER_CONTROL_FAKE:
774            return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
775        if volume_control == PLAYER_CONTROL_NATIVE:
776            return self._volume_level
777        if volume_control == PLAYER_CONTROL_NONE:
778            return None
779        if control := self.mass.players.get_player_control(volume_control):
780            return control.volume_level
781        return None
782
783    @property
784    @final
785    def volume_muted(self) -> bool | None:
786        """
787        Return the FINAL mute state of the player.
788
789        This is a convenience property which calculates the final mute state
790        based on the playercontrol which may have been set-up.
791        """
792        mute_control = self.mute_control
793        if mute_control == PLAYER_CONTROL_FAKE:
794            return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
795        if mute_control == PLAYER_CONTROL_NATIVE:
796            return self._volume_muted
797        if mute_control == PLAYER_CONTROL_NONE:
798            return None
799        if control := self.mass.players.get_player_control(mute_control):
800            return control.volume_muted
801        return None
802
803    @property
804    @final
805    def active_source(self) -> str | None:
806        """
807        Return the FINAL active source of the player.
808
809        This is a convenience property which calculates the final active source
810        based on any group memberships or source plugins that can be active.
811        """
812        # if the player is grouped/synced, use the active source of the group/parent player
813        if parent_player_id := (self.active_group or self.synced_to):
814            if parent_player_id != self.player_id and (
815                parent_player := self.mass.players.get(parent_player_id)
816            ):
817                return parent_player.active_source
818        for plugin_source in self.mass.players.get_plugin_sources():
819            if plugin_source.in_use_by == self.player_id:
820                return plugin_source.id
821        if (
822            self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
823            and self._active_source
824        ):
825            # active source as reported by the player itself
826            # but only if playing/paused, otherwise we always prefer the MA source
827            return self._active_source
828        # return the (last) known MA source
829        return self.__active_mass_source
830
831    @cached_property
832    @final
833    def source_list(self) -> UniqueList[PlayerSource]:
834        """
835        Return the FINAL source list of the player.
836
837        This is a convenience property with the calculated final source list
838        based on any group memberships or source plugins that can be active.
839        """
840        return self.__attr_source_list or UniqueList()
841
842    @cached_property
843    @final
844    def enabled(self) -> bool:
845        """Return if the player is enabled."""
846        return self._config.enabled
847
848    @property
849    def corrected_elapsed_time(self) -> float | None:
850        """Return the corrected/realtime elapsed time."""
851        if self.elapsed_time is None or self.elapsed_time_last_updated is None:
852            return None
853        if self.playback_state == PlaybackState.PLAYING:
854            return self.elapsed_time + (time.time() - self.elapsed_time_last_updated)
855        return self.elapsed_time
856
857    @property
858    @final
859    def active_groups(self) -> list[str]:
860        """
861        Return the player ids of all playergroups that are currently active for this player.
862
863        This will return the ids of the groupplayers if any groups are active.
864        If no groups are currently active, this will return an empty list.
865        """
866        return self.__attr_active_groups or []
867
868    @property
869    @final
870    def active_group(self) -> str | None:
871        """
872        Return the player id of the (first) playergroup that is currently active for this player.
873
874        This will return the id of the groupplayer if a group is active.
875        If no group is currently active, this will return None.
876        """
877        active_groups = self.active_groups
878        return active_groups[0] if active_groups else None
879
880    @property
881    @final
882    def current_media(self) -> PlayerMedia | None:
883        """
884        Return the current media being played by the player.
885
886        This is a convenience property with the calculates current media
887        based on any group memberships or source plugins that can be active.
888        """
889        return self.__attr_current_media
890
891    @cached_property
892    @final
893    def icon(self) -> str:
894        """Return the player icon."""
895        return cast("str", self._config.get_value(CONF_ENTRY_PLAYER_ICON.key))
896
897    @cached_property
898    @final
899    def power_control(self) -> str:
900        """Return the power control type."""
901        if conf := self._config.get_value(CONF_POWER_CONTROL):
902            return str(conf)
903        return PLAYER_CONTROL_NONE
904
905    @cached_property
906    @final
907    def volume_control(self) -> str:
908        """Return the volume control type."""
909        if conf := self._config.get_value(CONF_VOLUME_CONTROL):
910            return str(conf)
911        return PLAYER_CONTROL_NONE
912
913    @cached_property
914    @final
915    def mute_control(self) -> str:
916        """Return the mute control type."""
917        if conf := self._config.get_value(CONF_MUTE_CONTROL):
918            return str(conf)
919        return PLAYER_CONTROL_NONE
920
921    @property
922    @final
923    def group_volume(self) -> int:
924        """
925        Return the group volume level.
926
927        If this player is a group player or syncgroup, this will return the average volume
928        level of all (powered on) child players in the group.
929        If the player is not a group player or syncgroup, this will return the volume level
930        of the player itself (if set), or 0 if not set.
931        """
932        if len(self.group_members) == 0:
933            # player is not a group or syncgroup
934            return self.volume_level or 0
935        # calculate group volume from all (turned on) players
936        group_volume = 0
937        active_players = 0
938        for child_player in self.mass.players.iter_group_members(
939            self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER
940        ):
941            if (child_volume := child_player.volume_level) is None:
942                continue
943            group_volume += child_volume
944            active_players += 1
945        if active_players:
946            group_volume = int(group_volume / active_players)
947        return group_volume
948
949    @cached_property
950    @final
951    def hide_in_ui(self) -> bool:
952        """
953        Return the hide player in UI options.
954
955        This is a convenience property based on the config entry.
956        """
957        return bool(self._config.get_value(CONF_HIDE_IN_UI, self.hidden_by_default))
958
959    @cached_property
960    @final
961    def expose_to_ha(self) -> bool:
962        """
963        Return if the player should be exposed to Home Assistant.
964
965        This is a convenience property that returns True if the player is set to be exposed
966        to Home Assistant, based on the config entry.
967        """
968        return bool(self._config.get_value(CONF_EXPOSE_PLAYER_TO_HA))
969
970    @property
971    @final
972    def mass_queue_active(self) -> bool:
973        """
974        Return if the/a Music Assistant Queue is currently active for this player.
975
976        This is a convenience property that returns True if the
977        player currently has a Music Assistant Queue as active source.
978        """
979        return bool(self.mass.players.get_active_queue(self))
980
981    @property
982    @final
983    def flow_mode(self) -> bool:
984        """
985        Return if the player needs flow mode.
986
987        Will use 'requires_flow_mode' unless overridden by flow_mode config.
988        """
989        if bool(self._config.get_value(CONF_FLOW_MODE)) is True:
990            # flow mode explicitly enabled in config
991            return True
992        return self.requires_flow_mode
993
994    @property
995    @final
996    def state(self) -> PlayerState:
997        """Return the current PlayerState of the player."""
998        return self._state
999
1000    @final
1001    def update_state(self, force_update: bool = False, signal_event: bool = True) -> None:
1002        """
1003        Update the PlayerState with the current state of the player.
1004
1005        This method should be called to update the player's state
1006        and signal any changes to the PlayerController.
1007
1008        :param force_update: If True, a state update event will be
1009        pushed even if the state has not actually changed.
1010        :param signal_event: If True, signal the state update event to the PlayerController.
1011        """
1012        self.mass.verify_event_loop_thread("player.update_state")
1013        # clear the dict for the cached properties
1014        self._cache.clear()
1015        # calculate the new state
1016        prev_media_checksum = self._get_player_media_checksum()
1017        changed_values = self.__calculate_state()
1018        if prev_media_checksum != self._get_player_media_checksum():
1019            # current media changed, call the media updated callback
1020            self._on_player_media_updated()
1021        # ignore some values that are not relevant for the state
1022        changed_values.pop("elapsed_time_last_updated", None)
1023        changed_values.pop("extra_attributes.seq_no", None)
1024        changed_values.pop("extra_attributes.last_poll", None)
1025        changed_values.pop("current_media.elapsed_time_last_updated", None)
1026        # persist the default name if it changed
1027        if self.name and self.config.default_name != self.name:
1028            self.mass.config.set_player_default_name(self.player_id, self.name)
1029        # persist the player type if it changed
1030        if self.type != self._config.player_type:
1031            self.mass.config.set_player_type(self.player_id, self.type)
1032        # return early if nothing changed (unless force_update is True)
1033        if len(changed_values) == 0 and not force_update:
1034            return
1035        # signal the state update to the PlayerController
1036        if signal_event:
1037            self.mass.players.signal_player_state_update(self, changed_values)
1038
1039    @final
1040    def set_current_media(  # noqa: PLR0913
1041        self,
1042        uri: str,
1043        media_type: MediaType = MediaType.UNKNOWN,
1044        title: str | None = None,
1045        artist: str | None = None,
1046        album: str | None = None,
1047        image_url: str | None = None,
1048        duration: int | None = None,
1049        source_id: str | None = None,
1050        queue_item_id: str | None = None,
1051        custom_data: dict[str, Any] | None = None,
1052        clear_all: bool = False,
1053    ) -> None:
1054        """
1055        Set current_media helper.
1056
1057        Assumes use of '_attr_current_media'.
1058        """
1059        if self._attr_current_media is None or clear_all:
1060            self._attr_current_media = PlayerMedia(
1061                uri=uri,
1062                media_type=media_type,
1063            )
1064        self._attr_current_media.uri = uri
1065        if media_type != MediaType.UNKNOWN:
1066            self._attr_current_media.media_type = media_type
1067        if title:
1068            self._attr_current_media.title = title
1069        if artist:
1070            self._attr_current_media.artist = artist
1071        if album:
1072            self._attr_current_media.album = album
1073        if image_url:
1074            self._attr_current_media.image_url = image_url
1075        if duration:
1076            self._attr_current_media.duration = duration
1077        if source_id:
1078            self._attr_current_media.source_id = source_id
1079        if queue_item_id:
1080            self._attr_current_media.queue_item_id = queue_item_id
1081        if custom_data:
1082            self._attr_current_media.custom_data = custom_data
1083
1084    @final
1085    def set_config(self, config: PlayerConfig) -> None:
1086        """
1087        Set/update the player config.
1088
1089        May only be called by the PlayerController.
1090        """
1091        # TODO: validate that caller is the PlayerController ?
1092        self._config = config
1093
1094    @final
1095    def to_dict(self) -> dict[str, Any]:
1096        """Return the (serializable) dict representation of the Player."""
1097        return self.state.to_dict()
1098
1099    @final
1100    def supports_feature(self, feature: PlayerFeature) -> bool:
1101        """Return True if this player supports the given feature."""
1102        return feature in self.supported_features
1103
1104    @final
1105    def check_feature(self, feature: PlayerFeature) -> None:
1106        """Check if this player supports the given feature."""
1107        if not self.supports_feature(feature):
1108            raise UnsupportedFeaturedException(
1109                f"Player {self.display_name} does not support feature {feature.name}"
1110            )
1111
1112    def _get_player_media_checksum(self) -> str:
1113        """Return a checksum for the current media."""
1114        if not (media := self.current_media):
1115            return ""
1116        return (
1117            f"{media.uri}|{media.title}|{media.source_id}|{media.queue_item_id}|"
1118            f"{media.image_url}|{media.duration}|{media.elapsed_time}"
1119        )
1120
1121    def __calculate_state(
1122        self,
1123    ) -> dict[str, tuple[Any, Any]]:
1124        """
1125        Calculate the (current) PlayerState.
1126
1127        This method is called when we're updating the player,
1128        and we compare the current state with the previous state to determine
1129        if we need to signal a state change to API consumers.
1130
1131        Returns a dict with the state attributes that have changed.
1132        """
1133        self.__attr_active_groups = self.__calculate_active_groups()
1134        self.__attr_current_media = self.__calculate_current_media()
1135        self.__attr_source_list = self.__calculate_source_list()
1136        prev_state = deepcopy(self._state)
1137        self._state = PlayerState(
1138            player_id=self.player_id,
1139            provider=self.provider_id,
1140            type=self.type,
1141            available=self.enabled and self.available,
1142            device_info=self.device_info,
1143            supported_features=self.supported_features,
1144            playback_state=self.playback_state,
1145            elapsed_time=self.elapsed_time,
1146            elapsed_time_last_updated=self.elapsed_time_last_updated,
1147            powered=self.powered,
1148            volume_level=self.volume_level,
1149            volume_muted=self.volume_muted,
1150            group_members=UniqueList(self.group_members),
1151            static_group_members=UniqueList(self.static_group_members),
1152            can_group_with=self.can_group_with,
1153            synced_to=self.synced_to,
1154            active_source=self.active_source,
1155            source_list=self.source_list,
1156            active_sound_mode=self.active_sound_mode,
1157            sound_mode_list=self.sound_mode_list,
1158            options=self.options,
1159            active_group=self.active_group,
1160            current_media=self.current_media,
1161            name=self.display_name,
1162            enabled=self.enabled,
1163            hide_in_ui=self.hide_in_ui,
1164            expose_to_ha=self.expose_to_ha,
1165            icon=self.icon,
1166            group_volume=self.group_volume,
1167            extra_attributes=self.extra_attributes,
1168            power_control=self.power_control,
1169            volume_control=self.volume_control,
1170            mute_control=self.mute_control,
1171        )
1172
1173        # correct group_members if needed
1174        if self._state.group_members == [self.player_id]:
1175            self._state.group_members.clear()
1176        elif (
1177            self._state.group_members
1178            and self.player_id not in self._state.group_members
1179            and self.type == PlayerType.PLAYER
1180        ):
1181            self._state.group_members.set([self.player_id, *self._state.group_members])
1182
1183        # track stop called state
1184        if (
1185            prev_state.playback_state == PlaybackState.IDLE
1186            and self._state.playback_state != PlaybackState.IDLE
1187        ):
1188            self.__stop_called = False
1189        elif (
1190            prev_state.playback_state != PlaybackState.IDLE
1191            and self._state.playback_state == PlaybackState.IDLE
1192        ):
1193            self.__stop_called = True
1194
1195        # Auto correct player state if player is synced (or group child)
1196        # This is because some players/providers do not accurately update this info
1197        # for the sync child's.
1198        if self._state.synced_to and (sync_leader := self.mass.players.get(self._state.synced_to)):
1199            self._state.playback_state = sync_leader.playback_state
1200            self._state.elapsed_time = sync_leader.elapsed_time
1201            self._state.elapsed_time_last_updated = sync_leader.elapsed_time_last_updated
1202
1203        return get_changed_dataclass_values(
1204            prev_state,
1205            self._state,
1206            recursive=True,
1207        )
1208
1209    __attr_active_groups: list[str] | None = None
1210
1211    def __calculate_active_groups(self) -> list[str]:
1212        """Calculate the active groups for the player."""
1213        active_groups = []
1214        for player in self.mass.players.all(return_unavailable=False, return_disabled=False):
1215            if player.type != PlayerType.GROUP:
1216                continue
1217            if player.player_id == self.player_id:
1218                continue
1219            if not (player.powered or player.playback_state == PlaybackState.PLAYING):
1220                continue
1221            if self.player_id in player.group_members:
1222                active_groups.append(player.player_id)
1223        return active_groups
1224
1225    __attr_current_media: PlayerMedia | None = None
1226
1227    def __calculate_current_media(self) -> PlayerMedia | None:
1228        """Calculate the current media for the player."""
1229        if self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
1230            # if an announcement is in progress, return announcement details
1231            return PlayerMedia(
1232                uri="announcement",
1233                media_type=MediaType.ANNOUNCEMENT,
1234                title="ANNOUNCEMENT",
1235            )
1236        # if the player is grouped/synced, use the current_media of the group/parent player
1237        if parent_player_id := (self.active_group or self.synced_to):
1238            if parent_player_id != self.player_id and (
1239                parent_player := self.mass.players.get(parent_player_id)
1240            ):
1241                return parent_player.current_media
1242        # if a pluginsource is currently active, return those details
1243        if (
1244            self.active_source
1245            and (source := self.mass.players.get_plugin_source(self.active_source))
1246            and source.metadata
1247        ):
1248            return PlayerMedia(
1249                uri=source.metadata.uri or source.id,
1250                media_type=MediaType.PLUGIN_SOURCE,
1251                title=source.metadata.title,
1252                artist=source.metadata.artist,
1253                album=source.metadata.album,
1254                image_url=source.metadata.image_url,
1255                duration=source.metadata.duration,
1256                source_id=source.id,
1257                elapsed_time=source.metadata.elapsed_time,
1258                elapsed_time_last_updated=source.metadata.elapsed_time_last_updated,
1259            )
1260        # if MA queue is active, return those details
1261        active_queue = None
1262        if self._current_media and self._current_media.source_id:
1263            active_queue = self.mass.player_queues.get(self._current_media.source_id)
1264        if not active_queue and self.active_source:
1265            active_queue = self.mass.player_queues.get(self.active_source)
1266        if not active_queue and self._active_source is None:
1267            active_queue = self.mass.player_queues.get(self.player_id)
1268
1269        if active_queue and (current_item := active_queue.current_item):
1270            item_image_url = (
1271                # the image format needs to be 500x500 jpeg for maximum compatibility with players
1272                self.mass.metadata.get_image_url(current_item.image, size=500, image_format="jpeg")
1273                if current_item.image
1274                else None
1275            )
1276            if current_item.streamdetails and (
1277                stream_metadata := current_item.streamdetails.stream_metadata
1278            ):
1279                # handle stream metadata in streamdetails (e.g. for radio stream)
1280                return PlayerMedia(
1281                    uri=current_item.uri,
1282                    media_type=current_item.media_type,
1283                    title=stream_metadata.title or current_item.name,
1284                    artist=stream_metadata.artist,
1285                    album=stream_metadata.album or stream_metadata.description or current_item.name,
1286                    image_url=(stream_metadata.image_url or item_image_url),
1287                    duration=stream_metadata.duration or current_item.duration,
1288                    source_id=active_queue.queue_id,
1289                    queue_item_id=current_item.queue_item_id,
1290                    elapsed_time=stream_metadata.elapsed_time or int(active_queue.elapsed_time),
1291                    elapsed_time_last_updated=stream_metadata.elapsed_time_last_updated
1292                    or active_queue.elapsed_time_last_updated,
1293                )
1294            if media_item := current_item.media_item:
1295                # normal media item
1296                # we use getattr here to avoid issues with different media item types
1297                version = getattr(media_item, "version", None)
1298                album = getattr(media_item, "album", None)
1299                podcast = getattr(media_item, "podcast", None)
1300                metadata = getattr(media_item, "metadata", None)
1301                description = getattr(metadata, "description", None) if metadata else None
1302                return PlayerMedia(
1303                    uri=str(media_item.uri),
1304                    media_type=media_item.media_type,
1305                    title=f"{media_item.name} ({version})" if version else media_item.name,
1306                    artist=getattr(media_item, "artist_str", None),
1307                    album=album.name if album else podcast.name if podcast else description,
1308                    # the image format needs to be 500x500 jpeg for maximum player compatibility
1309                    image_url=self.mass.metadata.get_image_url(
1310                        current_item.media_item.image, size=500, image_format="jpeg"
1311                    )
1312                    or item_image_url
1313                    if current_item.media_item.image
1314                    else item_image_url,
1315                    duration=media_item.duration,
1316                    source_id=active_queue.queue_id,
1317                    queue_item_id=current_item.queue_item_id,
1318                    elapsed_time=int(active_queue.elapsed_time),
1319                    elapsed_time_last_updated=active_queue.elapsed_time_last_updated,
1320                )
1321
1322            # fallback to basic current item details
1323            return PlayerMedia(
1324                uri=current_item.uri,
1325                media_type=current_item.media_type,
1326                title=current_item.name,
1327                image_url=item_image_url,
1328                duration=current_item.duration,
1329                source_id=active_queue.queue_id,
1330                queue_item_id=current_item.queue_item_id,
1331                elapsed_time=int(active_queue.elapsed_time),
1332                elapsed_time_last_updated=active_queue.elapsed_time_last_updated,
1333            )
1334        if active_queue:
1335            # queue is active but no current item
1336            return None
1337        # return native current media if no group/queue is active
1338        if self._current_media:
1339            return PlayerMedia(
1340                uri=self._current_media.uri,
1341                media_type=self._current_media.media_type,
1342                title=self._current_media.title,
1343                artist=self._current_media.artist,
1344                album=self._current_media.album,
1345                image_url=self._current_media.image_url,
1346                duration=self._current_media.duration,
1347                source_id=self._current_media.source_id or self._active_source,
1348                queue_item_id=self._current_media.queue_item_id,
1349                elapsed_time=self._current_media.elapsed_time or int(self.elapsed_time)
1350                if self.elapsed_time
1351                else None,
1352                elapsed_time_last_updated=self._current_media.elapsed_time_last_updated
1353                or self.elapsed_time_last_updated,
1354            )
1355        return None
1356
1357    __attr_source_list: UniqueList[PlayerSource] | None = None
1358
1359    def __calculate_source_list(self) -> UniqueList[PlayerSource]:
1360        """Calculate the source list for the player."""
1361        sources = UniqueList(self._source_list)
1362        # always ensure the Music Assistant Queue is in the source list
1363        mass_source = next((x for x in sources if x.id == self.player_id), None)
1364        if mass_source is None:
1365            # if the MA queue is not in the source list, add it
1366            mass_source = PlayerSource(
1367                id=self.player_id,
1368                name="Music Assistant Queue",
1369                passive=False,
1370                # TODO: Do we want to dynamically set these based on the queue state ?
1371                can_play_pause=True,
1372                can_seek=True,
1373                can_next_previous=True,
1374            )
1375            sources.append(mass_source)
1376        # append all/any plugin sources (convert to PlayerSource to avoid deepcopy issues)
1377        for plugin_source in self.mass.players.get_plugin_sources():
1378            if hasattr(plugin_source, "as_player_source"):
1379                sources.append(plugin_source.as_player_source())
1380            else:
1381                sources.append(plugin_source)
1382        return sources
1383
1384    # The id of the (last) active mass source.
1385    # This is to keep track of the last active MA source for the player,
1386    # so we can restore it when needed (e.g. after switching to a plugin source).
1387    __active_mass_source: str = ""
1388
1389    def set_active_mass_source(self, value: str) -> None:
1390        """
1391        Set the id of the (last) active mass source.
1392
1393        This is to keep track of the last active MA source for the player,
1394        so we can restore it when needed (e.g. after switching to a plugin source).
1395        """
1396        self.__active_mass_source = value
1397        self.update_state()
1398
1399    __stop_called: bool = False
1400
1401    def mark_stop_called(self) -> None:
1402        """Mark that the STOP command was called on the player."""
1403        self.__stop_called = True
1404
1405    @property
1406    def stop_called(self) -> bool:
1407        """
1408        Return True if the STOP command was called on the player.
1409
1410        This is used to differentiate between a user-initiated stop
1411        and a natural end of playback (e.g. end of track/queue).
1412        mainly for debugging/logging purposes by the streams controller.
1413        """
1414        return self.__stop_called
1415
1416    def __hash__(self) -> int:
1417        """Return a hash of the Player."""
1418        return hash(self.player_id)
1419
1420    def __str__(self) -> str:
1421        """Return a string representation of the Player."""
1422        return f"Player {self.name} ({self.player_id})"
1423
1424    def __repr__(self) -> str:
1425        """Return a string representation of the Player."""
1426        return f"<Player name={self.name} id={self.player_id} available={self.available}>"
1427
1428    def __eq__(self, other: object) -> bool:
1429        """Check equality of two Player objects."""
1430        if not isinstance(other, Player):
1431            return False
1432        return self.player_id == other.player_id
1433
1434    def __ne__(self, other: object) -> bool:
1435        """Check inequality of two Player objects."""
1436        return not self.__eq__(other)
1437
1438
1439__all__ = [
1440    # explicitly re-export the models we imported from the models package,
1441    # for convenience reasons
1442    "EXTRA_ATTRIBUTES_TYPES",
1443    "DeviceInfo",
1444    "Player",
1445    "PlayerMedia",
1446    "PlayerSource",
1447    "PlayerState",
1448]
1449
1450
1451class GroupPlayer(Player):
1452    """Helper class for a (generic) group player."""
1453
1454    _attr_type: PlayerType = PlayerType.GROUP
1455
1456    @cached_property
1457    def synced_to(self) -> str | None:
1458        """Return the id of the player this player is synced to (sync leader)."""
1459        # default implementation: groups can't be synced
1460        return None
1461
1462    async def volume_set(self, volume_level: int) -> None:
1463        """
1464        Handle VOLUME_SET command on the player.
1465
1466        :param volume_level: volume level (0..100) to set on the player.
1467        """
1468        # Default implementation:
1469        # This will set the (relative) volume level on all child players.
1470        # free to override if you want to handle this differently.
1471        await self.mass.players.set_group_volume(self, volume_level)
1472