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