/
/
/
1"""
2Universal Player implementation.
3
4A virtual player for devices that have no native (vendor-specific) provider in
5Music Assistant but support one or more generic streaming protocols such as
6AirPlay, Sendspin, Chromecast, or DLNA.
7
8The Universal Player is automatically created when a protocol player with
9PlayerType.PROTOCOL is registered, providing a unified interface while delegating
10actual playback to the underlying protocol player(s).
11"""
12
13from __future__ import annotations
14
15from typing import TYPE_CHECKING
16
17from music_assistant.constants import CONF_PREFERRED_OUTPUT_PROTOCOL
18from music_assistant.models.player import DeviceInfo, Player
19
20if TYPE_CHECKING:
21 from music_assistant_models.enums import PlayerFeature
22
23 from .provider import UniversalPlayerProvider
24
25
26class UniversalPlayer(Player):
27 """
28 Universal Player implementation.
29
30 A virtual player for devices without native Music Assistant support that use
31 generic streaming protocols. It does NOT have PLAY_MEDIA capability on its own.
32 Playback is always delegated to one of the linked protocol players via the protocol
33 linking system.
34 """
35
36 def __init__(
37 self,
38 provider: UniversalPlayerProvider,
39 player_id: str,
40 name: str,
41 device_info: DeviceInfo,
42 protocol_player_ids: list[str],
43 ) -> None:
44 """
45 Initialize UniversalPlayer instance.
46
47 :param provider: The UniversalPlayerProvider instance.
48 :param player_id: Unique player ID (typically based on MAC address).
49 :param name: Display name for the player.
50 :param device_info: Device information aggregated from protocol players.
51 :param protocol_player_ids: List of protocol player IDs to link.
52 """
53 super().__init__(provider, player_id)
54 self._protocol_player_ids = protocol_player_ids
55 # Set player attributes
56 self._attr_name = name
57 self._attr_device_info = device_info
58 # a universal player does not have any features on its own,
59 # it delegates to protocol players
60 self._attr_supported_features = set()
61
62 @property
63 def hidden_by_default(self) -> bool:
64 """Return if the player should be hidden in the UI by default."""
65 if self.device_info.model.lower() == "web browser": # noqa: SIM103
66 # hide web players by default
67 return True
68 return False
69
70 @property
71 def available(self) -> bool:
72 """Return if the player is currently available."""
73 # A universal player is available if any of its linked protocol players are available
74 return any(
75 (p := self.mass.players.get_player(pid)) and p.available
76 for pid in self._protocol_player_ids
77 )
78
79 @property
80 def expose_to_ha_by_default(self) -> bool:
81 """Return if the player should be exposed to Home Assistant by default."""
82 if self.device_info.model.lower() == "web browser": # noqa: SIM103
83 # hide web players by default
84 return False
85 return True
86
87 def _get_control_target(
88 self, required_feature: PlayerFeature, require_active: bool = False
89 ) -> Player | None:
90 """Get the best player to send control commands to.
91
92 Prefers the active output protocol, otherwise uses the first available
93 protocol player that supports the needed feature.
94 """
95 # If we have an active protocol, use that
96 if (
97 self.active_output_protocol
98 and self.active_output_protocol != "native"
99 and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
100 and required_feature in protocol_player.supported_features
101 ):
102 return protocol_player
103
104 # If require_active is set, and no active protocol found, return None
105 if require_active:
106 return None
107
108 # Otherwise, use the first available linked protocol
109 for protocol_player_id in self._protocol_player_ids:
110 if (
111 (protocol_player := self.mass.players.get_player(protocol_player_id))
112 and protocol_player.available
113 and required_feature in protocol_player.supported_features
114 ):
115 return protocol_player
116
117 return None
118
119 def add_protocol_player(self, protocol_player_id: str) -> None:
120 """Add a protocol player to this universal player."""
121 if protocol_player_id not in self._protocol_player_ids:
122 self._protocol_player_ids.append(protocol_player_id)
123
124 def remove_protocol_player(self, protocol_player_id: str) -> None:
125 """Remove a protocol player from this universal player."""
126 if protocol_player_id in self._protocol_player_ids:
127 self._protocol_player_ids.remove(protocol_player_id)
128
129 def _get_preferred_protocol_player(self) -> Player | None:
130 """
131 Get the preferred protocol player for this universal player.
132
133 Selection priority:
134 1. Active output protocol (if set and available)
135 2. User's preferred output protocol (from settings), fallback to highest
136 priority if preferred is not available
137 """
138 # 1. Active output protocol takes precedence
139 if (
140 self.active_output_protocol
141 and self.active_output_protocol != "native"
142 and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
143 and protocol_player.available
144 ):
145 return protocol_player
146
147 # 2. User's preferred output protocol (with fallback to highest priority)
148 preferred = self.mass.config.get_raw_player_config_value(
149 self.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
150 )
151 if preferred and (protocol_player := self.mass.players.get_player(str(preferred))):
152 if protocol_player.available:
153 return protocol_player
154
155 # Fallback: if user's preferred protocol is not available,
156 # use the highest priority available protocol
157 for protocol in sorted(self.linked_output_protocols, key=lambda x: x.priority):
158 if protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
159 if protocol_player.available:
160 return protocol_player
161
162 return None
163