music-assistant-server
55.9 KB•PY
player.py
55.9 KB • 1,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