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