/
/
/
1"""Universal Player Provider implementation.
2
3This provider manages UniversalPlayer instances that are auto-created for devices
4that have no native (vendor-specific) provider in Music Assistant but support one
5or more generic streaming protocols such as AirPlay, Chromecast, or DLNA.
6
7The Universal Player acts as a virtual player wrapper that provides a unified
8interface while delegating actual playback to the underlying protocol player(s).
9"""
10
11from __future__ import annotations
12
13import asyncio
14from typing import TYPE_CHECKING
15
16from music_assistant_models.enums import IdentifierType, PlayerType
17
18from music_assistant.constants import CONF_PLAYERS
19from music_assistant.helpers.util import normalize_mac_for_matching
20from music_assistant.models.player import DeviceInfo
21from music_assistant.models.player_provider import PlayerProvider
22
23from .constants import (
24 CONF_DEVICE_IDENTIFIERS,
25 CONF_DEVICE_INFO,
26 CONF_LINKED_PROTOCOL_IDS,
27 UNIVERSAL_PLAYER_PREFIX,
28)
29from .player import UniversalPlayer
30
31if TYPE_CHECKING:
32 from music_assistant.models.player import Player
33
34
35class UniversalPlayerProvider(PlayerProvider):
36 """
37 Universal Player Provider.
38
39 Manages virtual players for devices that have no native (vendor-specific) provider
40 but support generic streaming protocols like AirPlay, Chromecast, or DLNA.
41 These players are automatically created when protocol players with PlayerType.PROTOCOL
42 are registered, providing a unified interface while delegating playback to the
43 underlying protocol player(s).
44 """
45
46 async def handle_async_init(self) -> None:
47 """Handle async initialization of the provider."""
48 # Lock to prevent race conditions during universal player creation
49 self._universal_player_locks: dict[str, asyncio.Lock] = {}
50
51 async def discover_players(self) -> None:
52 """
53 Discover players.
54
55 Universal players are created dynamically by the PlayerController,
56 not through discovery. However, we restore previously created
57 universal players from config.
58 """
59 for player_conf in await self.mass.config.get_player_configs(
60 self.instance_id, include_unavailable=True, include_disabled=True
61 ):
62 if player_conf.player_id.startswith(UNIVERSAL_PLAYER_PREFIX):
63 # Restore universal player from config
64 # The stored protocol IDs enable fast matching when protocols register
65 await self._restore_player(player_conf.player_id)
66
67 async def _restore_player(self, player_id: str) -> None:
68 """
69 Restore a universal player from config.
70
71 The stored protocol_player_ids enable fast matching when protocol players
72 register - they can be linked immediately without waiting for identifier matching.
73 Device identifiers are also restored to enable matching new protocol players.
74 """
75 # Get stored config values
76 config = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
77 if not config:
78 return
79
80 # Get stored values
81 values = config.get("values", {})
82 stored_protocol_ids = values.get(CONF_LINKED_PROTOCOL_IDS, [])
83 stored_identifiers = values.get(CONF_DEVICE_IDENTIFIERS, {})
84 stored_device_info = values.get(CONF_DEVICE_INFO, {})
85
86 # Check if protocols have been linked to a native player (stale universal player)
87 for protocol_id in stored_protocol_ids:
88 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
89 if protocol_config:
90 protocol_values = protocol_config.get("values", {})
91 protocol_parent_id = protocol_values.get("protocol_parent_id")
92 if protocol_parent_id and protocol_parent_id != player_id:
93 self.logger.info(
94 "Deleting stale universal player %s - protocol %s has moved to parent %s",
95 player_id,
96 protocol_id,
97 protocol_parent_id,
98 )
99 await self.mass.config.remove_player_config(player_id)
100 return
101
102 # Check if native player has this protocol in linked_protocol_player_ids
103 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
104 for other_player_id, other_config in all_player_configs.items():
105 if other_player_id == player_id:
106 continue
107 if other_config.get("provider") == "universal_player":
108 continue
109 other_values = other_config.get("values", {})
110 linked_protocols = other_values.get("linked_protocol_player_ids", [])
111 if protocol_id in linked_protocols:
112 self.logger.info(
113 "Deleting stale universal player %s - "
114 "protocol %s is linked to native player %s",
115 player_id,
116 protocol_id,
117 other_player_id,
118 )
119 await self.mass.config.remove_player_config(player_id)
120 return
121
122 # Restore device info with stored values or defaults
123 device_info = DeviceInfo(
124 model=stored_device_info.get("model", "Universal Player"),
125 manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
126 )
127
128 # Restore identifiers (convert string keys back to IdentifierType enum)
129 for id_type_str, value in stored_identifiers.items():
130 try:
131 id_type = IdentifierType(id_type_str)
132 device_info.add_identifier(id_type, value)
133 except ValueError:
134 self.logger.warning(
135 "Unknown identifier type %s for player %s", id_type_str, player_id
136 )
137
138 name = config.get("name", f"Universal Player {player_id}")
139
140 self.logger.debug(
141 "Restoring universal player %s with %d protocol IDs and %d identifiers",
142 player_id,
143 len(stored_protocol_ids),
144 len(stored_identifiers),
145 )
146
147 player = UniversalPlayer(
148 provider=self,
149 player_id=player_id,
150 name=name,
151 device_info=device_info,
152 protocol_player_ids=list(stored_protocol_ids),
153 )
154 await self.mass.players.register_or_update(player)
155
156 async def create_universal_player(
157 self,
158 device_key: str,
159 name: str,
160 device_info: DeviceInfo,
161 protocol_player_ids: list[str],
162 ) -> Player:
163 """
164 Create a new UniversalPlayer.
165
166 Called by the PlayerController when multiple protocol players are
167 detected for a device without a native player.
168
169 :param device_key: Unique device key (typically MAC address).
170 :param name: Display name for the player.
171 :param device_info: Aggregated device information.
172 :param protocol_player_ids: List of protocol player IDs to link.
173 :return: The created UniversalPlayer instance.
174 """
175 # Generate player_id from device_key
176 player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
177
178 # Check if player already exists
179 if existing := self.mass.players.get_player(player_id):
180 # Update existing player with new protocol players
181 if isinstance(existing, UniversalPlayer):
182 for pid in protocol_player_ids:
183 existing.add_protocol_player(pid)
184 # Merge identifiers from new device_info
185 for id_type, value in device_info.identifiers.items():
186 existing.device_info.add_identifier(id_type, value)
187 # Persist updated data to config
188 await self._save_player_data(player_id, existing)
189 existing.update_state()
190 return existing
191
192 # Create config for the new player (complex values saved separately after)
193 self.mass.config.create_default_player_config(
194 player_id=player_id,
195 provider=self.instance_id,
196 player_type=PlayerType.GROUP,
197 name=name,
198 enabled=True,
199 values={
200 CONF_LINKED_PROTOCOL_IDS: protocol_player_ids,
201 },
202 )
203
204 # Save device identifiers and info to config (these are nested dicts,
205 # not supported by ConfigValueType, so we save them directly)
206 base_key = f"{CONF_PLAYERS}/{player_id}/values"
207 self.mass.config.set(
208 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
209 {k.value: v for k, v in device_info.identifiers.items()},
210 )
211 self.mass.config.set(
212 f"{base_key}/{CONF_DEVICE_INFO}",
213 {"model": device_info.model, "manufacturer": device_info.manufacturer},
214 )
215
216 self.logger.info(
217 "Creating universal player %s with protocol players: %s",
218 player_id,
219 protocol_player_ids,
220 )
221
222 # Create the player instance
223 player = UniversalPlayer(
224 provider=self,
225 player_id=player_id,
226 name=name,
227 device_info=device_info,
228 protocol_player_ids=protocol_player_ids,
229 )
230
231 await self.mass.players.register_or_update(player)
232 return player
233
234 async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
235 """Save protocol player IDs to config for persistence across restarts."""
236 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
237 self.mass.config.set(conf_key, protocol_player_ids)
238 self.logger.debug(
239 "Saved protocol IDs for %s: %s",
240 player_id,
241 protocol_player_ids,
242 )
243
244 async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
245 """Save all player data to config for persistence across restarts."""
246 base_key = f"{CONF_PLAYERS}/{player_id}/values"
247
248 # Save protocol IDs
249 self.mass.config.set(
250 f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
251 player._protocol_player_ids,
252 )
253
254 # Save identifiers (convert IdentifierType enum keys to strings)
255 self.mass.config.set(
256 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
257 {k.value: v for k, v in player.device_info.identifiers.items()},
258 )
259
260 # Save device info (model, manufacturer)
261 self.mass.config.set(
262 f"{base_key}/{CONF_DEVICE_INFO}",
263 {
264 "model": player.device_info.model,
265 "manufacturer": player.device_info.manufacturer,
266 },
267 )
268
269 self.logger.debug(
270 "Saved player data for %s: %d protocols, %d identifiers",
271 player_id,
272 len(player._protocol_player_ids),
273 len(player.device_info.identifiers),
274 )
275
276 async def add_protocol_to_universal_player(
277 self, player_id: str, protocol_player_id: str
278 ) -> None:
279 """
280 Add a protocol player to an existing universal player.
281
282 Called when a new protocol player is discovered that matches an existing
283 universal player.
284
285 :param player_id: ID of the universal player.
286 :param protocol_player_id: ID of the protocol player to add.
287 """
288 if player := self.get_universal_player(player_id):
289 player.add_protocol_player(protocol_player_id)
290 # Save all player data (protocol IDs, identifiers, device info)
291 await self._save_player_data(player_id, player)
292 player.update_state()
293
294 async def remove_universal_player(self, player_id: str) -> None:
295 """
296 Remove a universal player.
297
298 Called when all protocol players for a device are removed.
299
300 :param player_id: ID of the universal player to remove.
301 """
302 await self.mass.players.unregister(player_id, permanent=True)
303
304 async def ensure_universal_player_for_protocols(
305 self, protocol_players: list[Player]
306 ) -> Player | None:
307 """
308 Ensure a universal player exists for a set of protocol players.
309
310 This method handles the orchestration of creating or updating a universal player
311 for the given protocol players. It uses per-device locking to prevent race
312 conditions when multiple protocols for the same device register simultaneously.
313
314 :param protocol_players: List of protocol players for the same device.
315 :return: The created or updated universal player, or None if operation failed.
316 """
317 device_key = self._get_device_key_from_players(protocol_players)
318 if not device_key:
319 return None
320
321 universal_player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
322
323 # Use a per-device lock to prevent race conditions
324 if device_key not in self._universal_player_locks:
325 self._universal_player_locks[device_key] = asyncio.Lock()
326
327 async with self._universal_player_locks[device_key]:
328 # Re-check - another task may have already handled these players
329 # Filter out players that are already linked to a parent
330 protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
331 if not protocol_players:
332 return None
333
334 # Check if universal player already exists
335 if existing := self.mass.players.get_player(universal_player_id):
336 # Update existing universal player with new protocol players
337 protocol_player_ids = [p.player_id for p in protocol_players]
338 for player_id in protocol_player_ids:
339 if isinstance(existing, UniversalPlayer):
340 await self.add_protocol_to_universal_player(universal_player_id, player_id)
341 return existing
342
343 # Create new universal player
344 device_info = self._aggregate_device_info(protocol_players)
345 name = self._get_clean_player_name(protocol_players)
346 protocol_player_ids = [p.player_id for p in protocol_players]
347
348 return await self.create_universal_player(
349 device_key=device_key,
350 name=name,
351 device_info=device_info,
352 protocol_player_ids=protocol_player_ids,
353 )
354
355 def get_universal_player(self, player_id: str) -> UniversalPlayer | None:
356 """Get a UniversalPlayer by ID if it exists and is managed by this provider."""
357 if player := self.mass.players.get_player(player_id):
358 if isinstance(player, UniversalPlayer):
359 return player
360 return None
361
362 def _get_device_key_from_players(self, protocol_players: list[Player]) -> str | None:
363 """
364 Generate a device key from protocol players' identifiers.
365
366 Prefers MAC address (most stable), falls back to UUID, then player_id.
367 IP address is not used as it can change with DHCP and cause incorrect matches.
368 """
369 uuid_key: str | None = None
370 for player in protocol_players:
371 identifiers = player.device_info.identifiers
372 # Prefer MAC address (most reliable)
373 # Use normalize_mac_for_matching to handle locally-administered MAC variants
374 # Some protocols (like AirPlay) report a variant where bit 1 of the first octet
375 # is set (e.g., 54:78:... vs 56:78:...), but they represent the same device
376 if mac := identifiers.get(IdentifierType.MAC_ADDRESS):
377 return normalize_mac_for_matching(mac)
378 # Fall back to UUID (reliable for DLNA, Chromecast)
379 if not uuid_key and (uuid := identifiers.get(IdentifierType.UUID)):
380 # Normalize UUID: remove special characters, lowercase
381 uuid_key = uuid.replace("-", "").replace(":", "").replace("_", "").lower()
382 if uuid_key:
383 return uuid_key
384 # Last resort: use player_id as device key for protocol players without identifiers
385 # (e.g., Sendspin players that don't expose IP/MAC)
386 if protocol_players:
387 return protocol_players[0].player_id.replace(":", "").replace("-", "").lower()
388 return None
389
390 def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
391 """Aggregate device info from protocol players."""
392 first_player = protocol_players[0]
393 device_info = DeviceInfo(
394 model=first_player.device_info.model,
395 manufacturer=first_player.device_info.manufacturer,
396 )
397 # Merge identifiers from all protocol players
398 for player in protocol_players:
399 for conn_type, value in player.device_info.identifiers.items():
400 device_info.add_identifier(conn_type, value)
401 return device_info
402
403 def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
404 """
405 Get the best display name from protocol players.
406
407 Prefers names from protocols that typically provide user-friendly names
408 (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
409 (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
410 UUIDs, or player IDs.
411 """
412 # Protocol priority for name selection (higher priority = better names typically)
413 # Chromecast and DLNA usually have good user-configured names
414 # AirPlay also provides sensible names
415 # Squeezelite and SendSpin may use MAC addresses or technical IDs
416 name_priority = {
417 "chromecast": 1,
418 "airplay": 2,
419 "dlna": 3,
420 "squeezelite": 4,
421 "sendspin": 5,
422 }
423
424 def is_valid_name(name: str) -> bool:
425 """Check if a name looks like a real user-friendly name, not a technical ID."""
426 if not name or len(name) < 2:
427 return False
428 name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
429 # Filter out names that look like MAC addresses (12 hex chars)
430 if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
431 return False
432 # Filter out names that look like UUIDs
433 if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
434 return False
435 # Filter out names that start with common player ID prefixes
436 return not name_lower.startswith(
437 ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
438 )
439
440 # Sort players by protocol priority, then find the first valid name
441 sorted_players = sorted(
442 protocol_players,
443 key=lambda p: name_priority.get(p.provider.domain, 10),
444 )
445
446 for player in sorted_players:
447 player_name = player.state.name
448 if is_valid_name(player_name):
449 return player_name
450
451 # Fallback to first player's name if no valid name found
452 return protocol_players[0].display_name
453