/
/
/
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 = list(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 # Filter out protocol IDs that are no longer PROTOCOL type players
87 valid_protocol_ids = []
88 for protocol_id in stored_protocol_ids:
89 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
90 if not protocol_config:
91 # Config doesn't exist, keep it for now (player may register later)
92 valid_protocol_ids.append(protocol_id)
93 continue
94 protocol_player_type = protocol_config.get("player_type")
95 if protocol_player_type == "protocol":
96 valid_protocol_ids.append(protocol_id)
97 else:
98 self.logger.info(
99 "Removing %s from universal player %s - player type changed to %s",
100 protocol_id,
101 player_id,
102 protocol_player_type,
103 )
104
105 # If no valid protocol IDs remain, delete this stale universal player
106 if not valid_protocol_ids:
107 self.logger.info(
108 "Deleting stale universal player %s - no valid protocol players remain",
109 player_id,
110 )
111 await self.mass.config.remove_player_config(player_id)
112 return
113
114 stored_protocol_ids = valid_protocol_ids
115
116 # Persist the filtered protocol IDs to config if they changed
117 if len(valid_protocol_ids) != len(values.get(CONF_LINKED_PROTOCOL_IDS, [])):
118 self.mass.config.set(
119 f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
120 valid_protocol_ids,
121 )
122
123 # Check if protocols have been linked to a native player (stale universal player)
124 for protocol_id in stored_protocol_ids:
125 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
126 if protocol_config:
127 protocol_values = protocol_config.get("values", {})
128 protocol_parent_id = protocol_values.get("protocol_parent_id")
129 if protocol_parent_id and protocol_parent_id != player_id:
130 self.logger.info(
131 "Deleting stale universal player %s - protocol %s has moved to parent %s",
132 player_id,
133 protocol_id,
134 protocol_parent_id,
135 )
136 await self.mass.config.remove_player_config(player_id)
137 return
138
139 # Check if native player has this protocol in linked_protocol_player_ids
140 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
141 for other_player_id, other_config in all_player_configs.items():
142 if other_player_id == player_id:
143 continue
144 if other_config.get("provider") == "universal_player":
145 continue
146 other_values = other_config.get("values", {})
147 linked_protocols = other_values.get("linked_protocol_player_ids", [])
148 if protocol_id in linked_protocols:
149 self.logger.info(
150 "Deleting stale universal player %s - "
151 "protocol %s is linked to native player %s",
152 player_id,
153 protocol_id,
154 other_player_id,
155 )
156 await self.mass.config.remove_player_config(player_id)
157 return
158
159 # Restore device info with stored values or defaults
160 device_info = DeviceInfo(
161 model=stored_device_info.get("model", "Universal Player"),
162 manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
163 )
164
165 # Restore identifiers (convert string keys back to IdentifierType enum)
166 for id_type_str, value in stored_identifiers.items():
167 try:
168 id_type = IdentifierType(id_type_str)
169 device_info.add_identifier(id_type, value)
170 except ValueError:
171 self.logger.warning(
172 "Unknown identifier type %s for player %s", id_type_str, player_id
173 )
174
175 name = config.get("name", f"Universal Player {player_id}")
176
177 self.logger.debug(
178 "Restoring universal player %s with %d protocol IDs and %d identifiers",
179 player_id,
180 len(stored_protocol_ids),
181 len(stored_identifiers),
182 )
183
184 player = UniversalPlayer(
185 provider=self,
186 player_id=player_id,
187 name=name,
188 device_info=device_info,
189 protocol_player_ids=list(stored_protocol_ids),
190 )
191 await self.mass.players.register_or_update(player)
192
193 async def create_universal_player(
194 self,
195 device_key: str,
196 name: str,
197 device_info: DeviceInfo,
198 protocol_player_ids: list[str],
199 ) -> Player:
200 """
201 Create a new UniversalPlayer.
202
203 Called by the PlayerController when multiple protocol players are
204 detected for a device without a native player.
205
206 :param device_key: Unique device key (typically MAC address).
207 :param name: Display name for the player.
208 :param device_info: Aggregated device information.
209 :param protocol_player_ids: List of protocol player IDs to link.
210 :return: The created UniversalPlayer instance.
211 """
212 # Generate player_id from device_key
213 player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
214
215 # Check if player already exists
216 if existing := self.mass.players.get_player(player_id):
217 # Update existing player with new protocol players
218 if isinstance(existing, UniversalPlayer):
219 for pid in protocol_player_ids:
220 existing.add_protocol_player(pid)
221 # Merge identifiers from new device_info
222 for id_type, value in device_info.identifiers.items():
223 existing.device_info.add_identifier(id_type, value)
224 # Persist updated data to config
225 await self._save_player_data(player_id, existing)
226 existing.update_state()
227 return existing
228
229 # Create config for the new player (complex values saved separately after)
230 self.mass.config.create_default_player_config(
231 player_id=player_id,
232 provider=self.instance_id,
233 player_type=PlayerType.GROUP,
234 name=name,
235 enabled=True,
236 values={
237 CONF_LINKED_PROTOCOL_IDS: protocol_player_ids,
238 },
239 )
240
241 # Save device identifiers and info to config (these are nested dicts,
242 # not supported by ConfigValueType, so we save them directly)
243 base_key = f"{CONF_PLAYERS}/{player_id}/values"
244 self.mass.config.set(
245 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
246 {k.value: v for k, v in device_info.identifiers.items()},
247 )
248 self.mass.config.set(
249 f"{base_key}/{CONF_DEVICE_INFO}",
250 {"model": device_info.model, "manufacturer": device_info.manufacturer},
251 )
252
253 self.logger.info(
254 "Creating universal player %s with protocol players: %s",
255 player_id,
256 protocol_player_ids,
257 )
258
259 # Create the player instance
260 player = UniversalPlayer(
261 provider=self,
262 player_id=player_id,
263 name=name,
264 device_info=device_info,
265 protocol_player_ids=protocol_player_ids,
266 )
267
268 await self.mass.players.register_or_update(player)
269 return player
270
271 async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
272 """Save protocol player IDs to config for persistence across restarts."""
273 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
274 self.mass.config.set(conf_key, protocol_player_ids)
275 self.logger.debug(
276 "Saved protocol IDs for %s: %s",
277 player_id,
278 protocol_player_ids,
279 )
280
281 async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
282 """Save all player data to config for persistence across restarts."""
283 base_key = f"{CONF_PLAYERS}/{player_id}/values"
284
285 # Save protocol IDs
286 self.mass.config.set(
287 f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
288 player._protocol_player_ids,
289 )
290
291 # Save identifiers (convert IdentifierType enum keys to strings)
292 self.mass.config.set(
293 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
294 {k.value: v for k, v in player.device_info.identifiers.items()},
295 )
296
297 # Save device info (model, manufacturer)
298 self.mass.config.set(
299 f"{base_key}/{CONF_DEVICE_INFO}",
300 {
301 "model": player.device_info.model,
302 "manufacturer": player.device_info.manufacturer,
303 },
304 )
305
306 self.logger.debug(
307 "Saved player data for %s: %d protocols, %d identifiers",
308 player_id,
309 len(player._protocol_player_ids),
310 len(player.device_info.identifiers),
311 )
312
313 async def add_protocol_to_universal_player(
314 self, player_id: str, protocol_player_id: str
315 ) -> None:
316 """
317 Add a protocol player to an existing universal player.
318
319 Called when a new protocol player is discovered that matches an existing
320 universal player.
321
322 :param player_id: ID of the universal player.
323 :param protocol_player_id: ID of the protocol player to add.
324 """
325 if player := self.get_universal_player(player_id):
326 player.add_protocol_player(protocol_player_id)
327 # Save all player data (protocol IDs, identifiers, device info)
328 await self._save_player_data(player_id, player)
329 player.update_state()
330
331 async def remove_universal_player(self, player_id: str) -> None:
332 """
333 Remove a universal player.
334
335 Called when all protocol players for a device are removed.
336
337 :param player_id: ID of the universal player to remove.
338 """
339 await self.mass.players.unregister(player_id, permanent=True)
340
341 async def ensure_universal_player_for_protocols(
342 self, protocol_players: list[Player]
343 ) -> Player | None:
344 """
345 Ensure a universal player exists for a set of protocol players.
346
347 This method handles the orchestration of creating or updating a universal player
348 for the given protocol players. It uses per-device locking to prevent race
349 conditions when multiple protocols for the same device register simultaneously.
350
351 :param protocol_players: List of protocol players for the same device.
352 :return: The created or updated universal player, or None if operation failed.
353 """
354 device_key = self._get_device_key_from_players(protocol_players)
355 if not device_key:
356 return None
357
358 universal_player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
359
360 # Use a per-device lock to prevent race conditions
361 if device_key not in self._universal_player_locks:
362 self._universal_player_locks[device_key] = asyncio.Lock()
363
364 async with self._universal_player_locks[device_key]:
365 # Re-check - another task may have already handled these players
366 # Filter out players that are already linked to a parent
367 protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
368 if not protocol_players:
369 return None
370
371 # Check if universal player already exists
372 if existing := self.mass.players.get_player(universal_player_id):
373 # Update existing universal player with new protocol players
374 protocol_player_ids = [p.player_id for p in protocol_players]
375 for player_id in protocol_player_ids:
376 if isinstance(existing, UniversalPlayer):
377 await self.add_protocol_to_universal_player(universal_player_id, player_id)
378 return existing
379
380 # Create new universal player
381 device_info = self._aggregate_device_info(protocol_players)
382 name = self._get_clean_player_name(protocol_players)
383 protocol_player_ids = [p.player_id for p in protocol_players]
384
385 return await self.create_universal_player(
386 device_key=device_key,
387 name=name,
388 device_info=device_info,
389 protocol_player_ids=protocol_player_ids,
390 )
391
392 def get_universal_player(self, player_id: str) -> UniversalPlayer | None:
393 """Get a UniversalPlayer by ID if it exists and is managed by this provider."""
394 if player := self.mass.players.get_player(player_id):
395 if isinstance(player, UniversalPlayer):
396 return player
397 return None
398
399 def _get_device_key_from_players(self, protocol_players: list[Player]) -> str | None:
400 """
401 Generate a device key from protocol players' identifiers.
402
403 Prefers MAC address (most stable), falls back to UUID, then player_id.
404 IP address is not used as it can change with DHCP and cause incorrect matches.
405 """
406 uuid_key: str | None = None
407 for player in protocol_players:
408 identifiers = player.device_info.identifiers
409 # Prefer MAC address (most reliable)
410 # Use normalize_mac_for_matching to handle locally-administered MAC variants
411 # Some protocols (like AirPlay) report a variant where bit 1 of the first octet
412 # is set (e.g., 54:78:... vs 56:78:...), but they represent the same device
413 if mac := identifiers.get(IdentifierType.MAC_ADDRESS):
414 return normalize_mac_for_matching(mac)
415 # Fall back to UUID (reliable for DLNA, Chromecast)
416 if not uuid_key and (uuid := identifiers.get(IdentifierType.UUID)):
417 # Normalize UUID: remove special characters, lowercase
418 uuid_key = uuid.replace("-", "").replace(":", "").replace("_", "").lower()
419 if uuid_key:
420 return uuid_key
421 # Last resort: use player_id as device key for protocol players without identifiers
422 # (e.g., Sendspin players that don't expose IP/MAC)
423 if protocol_players:
424 return protocol_players[0].player_id.replace(":", "").replace("-", "").lower()
425 return None
426
427 def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
428 """Aggregate device info from protocol players."""
429 first_player = protocol_players[0]
430 device_info = DeviceInfo(
431 model=first_player.device_info.model,
432 manufacturer=first_player.device_info.manufacturer,
433 )
434 # Merge identifiers from all protocol players
435 for player in protocol_players:
436 for conn_type, value in player.device_info.identifiers.items():
437 device_info.add_identifier(conn_type, value)
438 return device_info
439
440 def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
441 """
442 Get the best display name from protocol players.
443
444 Prefers names from protocols that typically provide user-friendly names
445 (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
446 (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
447 UUIDs, or player IDs.
448 """
449 # Protocol priority for name selection (higher priority = better names typically)
450 # Chromecast and DLNA usually have good user-configured names
451 # AirPlay also provides sensible names
452 # Squeezelite and SendSpin may use MAC addresses or technical IDs
453 name_priority = {
454 "chromecast": 1,
455 "airplay": 2,
456 "dlna": 3,
457 "squeezelite": 4,
458 "sendspin": 5,
459 }
460
461 def is_valid_name(name: str) -> bool:
462 """Check if a name looks like a real user-friendly name, not a technical ID."""
463 if not name or len(name) < 2:
464 return False
465 name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
466 # Filter out names that look like MAC addresses (12 hex chars)
467 if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
468 return False
469 # Filter out names that look like UUIDs
470 if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
471 return False
472 # Filter out names that start with common player ID prefixes
473 return not name_lower.startswith(
474 ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
475 )
476
477 # Sort players by protocol priority, then find the first valid name
478 sorted_players = sorted(
479 protocol_players,
480 key=lambda p: name_priority.get(p.provider.domain, 10),
481 )
482
483 for player in sorted_players:
484 player_name = player.state.name
485 if is_valid_name(player_name):
486 return player_name
487
488 # Fallback to first player's name if no valid name found
489 return protocol_players[0].display_name
490