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