/
/
/
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 len(self.linked_output_protocols) == 0:
66 # If we have no linked protocols, hide by default
67 return True
68 if self.device_info.model.lower() == "web browser": # noqa: SIM103
69 # hide web players by default
70 return True
71 return False
72
73 @property
74 def available(self) -> bool:
75 """Return if the player is currently available."""
76 # A universal player is available if any of its linked protocol players are available
77 return any(
78 (p := self.mass.players.get_player(pid)) and p.available
79 for pid in self._protocol_player_ids
80 )
81
82 @property
83 def expose_to_ha_by_default(self) -> bool:
84 """Return if the player should be exposed to Home Assistant by default."""
85 if len(self.linked_output_protocols) == 0:
86 # If we have no linked protocols, hide by default
87 return False
88 if self.device_info.model.lower() == "web browser": # noqa: SIM103
89 # hide web players by default
90 return False
91 return True
92
93 def _get_control_target(
94 self, required_feature: PlayerFeature, require_active: bool = False
95 ) -> Player | None:
96 """Get the best player to send control commands to.
97
98 Prefers the active output protocol, otherwise uses the first available
99 protocol player that supports the needed feature.
100 """
101 # If we have an active protocol, use that
102 if (
103 self.active_output_protocol
104 and self.active_output_protocol != "native"
105 and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
106 and required_feature in protocol_player.supported_features
107 ):
108 return protocol_player
109
110 # If require_active is set, and no active protocol found, return None
111 if require_active:
112 return None
113
114 # Otherwise, use the first available linked protocol
115 for protocol_player_id in self._protocol_player_ids:
116 if (
117 (protocol_player := self.mass.players.get_player(protocol_player_id))
118 and protocol_player.available
119 and required_feature in protocol_player.supported_features
120 ):
121 return protocol_player
122
123 return None
124
125 def add_protocol_player(self, protocol_player_id: str) -> None:
126 """Add a protocol player to this universal player."""
127 if protocol_player_id not in self._protocol_player_ids:
128 self._protocol_player_ids.append(protocol_player_id)
129
130 def remove_protocol_player(self, protocol_player_id: str) -> None:
131 """Remove a protocol player from this universal player."""
132 if protocol_player_id in self._protocol_player_ids:
133 self._protocol_player_ids.remove(protocol_player_id)
134
135 def _get_preferred_protocol_player(self) -> Player | None:
136 """
137 Get the preferred protocol player for this universal player.
138
139 Selection priority:
140 1. Active output protocol (if set and available)
141 2. User's preferred output protocol (from settings), fallback to highest
142 priority if preferred is not available
143 """
144 # 1. Active output protocol takes precedence
145 if (
146 self.active_output_protocol
147 and self.active_output_protocol != "native"
148 and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
149 and protocol_player.available
150 ):
151 return protocol_player
152
153 # 2. User's preferred output protocol (with fallback to highest priority)
154 preferred = self.mass.config.get_raw_player_config_value(
155 self.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
156 )
157 if preferred and (protocol_player := self.mass.players.get_player(str(preferred))):
158 if protocol_player.available:
159 return protocol_player
160
161 # Fallback: if user's preferred protocol is not available,
162 # use the highest priority available protocol
163 for protocol in sorted(self.linked_output_protocols, key=lambda x: x.priority):
164 if protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
165 if protocol_player.available:
166 return protocol_player
167
168 return None
169