/
/
/
1"""
2Universal Player Provider implementation.
3
4This provider manages UniversalPlayer instances that are auto-created for devices
5that have no native (vendor-specific) provider in Music Assistant but support one
6or more generic streaming protocols such as AirPlay, Chromecast, or DLNA.
7
8The Universal Player acts as a virtual player wrapper that provides a unified
9interface while delegating actual playback to the underlying protocol player(s).
10"""
11
12from __future__ import annotations
13
14import asyncio
15import time
16from typing import TYPE_CHECKING, Any
17from uuid import uuid4
18
19from music_assistant_models.enums import IdentifierType, PlayerType
20
21from music_assistant.constants import (
22 CONF_LINKED_PROTOCOL_IDS,
23 CONF_PLAYERS,
24 CONF_PROTOCOL_PARENT_ID,
25)
26from music_assistant.models.player import DeviceInfo
27from music_assistant.models.player_provider import PlayerProvider
28
29from .constants import (
30 CONF_CREATED_AT,
31 CONF_DEVICE_IDENTIFIERS,
32 CONF_DEVICE_INFO,
33 UNIVERSAL_PLAYER_PREFIX,
34)
35from .player import UniversalPlayer
36
37if TYPE_CHECKING:
38 from music_assistant_models.config_entries import ConfigEntry
39
40 from music_assistant.models.player import Player
41
42
43class UniversalPlayerProvider(PlayerProvider):
44 """
45 Universal Player Provider.
46
47 Manages virtual players for devices that have no native (vendor-specific) provider
48 but support generic streaming protocols like AirPlay, Chromecast, or DLNA.
49 These players are automatically created when protocol players with PlayerType.PROTOCOL
50 are registered, providing a unified interface while delegating playback to the
51 underlying protocol player(s).
52 """
53
54 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
55 """Return Config entries to setup this provider."""
56 # Nothing to configure - universal players are auto-created
57 return ()
58
59 async def handle_async_init(self) -> None:
60 """Handle async initialization of the provider."""
61 # Serializes resolving, restoring and creating universal players, so a device
62 # can never end up with two of them (and thus two player ids).
63 self._lock = asyncio.Lock()
64
65 async def discover_players(self) -> None:
66 """
67 Discover players.
68
69 Universal players are created dynamically by the PlayerController,
70 not through discovery. However, we restore previously created
71 universal players from config. Native players that match a restored
72 universal player take it over, which removes the universal player.
73 """
74 async with self._lock:
75 for player_conf in await self.mass.config.get_player_configs(
76 self.instance_id, include_unavailable=True, include_disabled=True
77 ):
78 # Restore universal player from config
79 # The stored protocol IDs enable fast matching when protocols register
80 await self._restore_player(player_conf.player_id)
81
82 # This provider restores its players in a background task, so a native player
83 # may already have registered while none of the universal players it should
84 # replace existed yet. Re-check those now that the wrappers are back.
85 for player in self.mass.players.iter_players():
86 if player.state.type == PlayerType.GROUP:
87 continue
88 self.mass.players._check_replace_universal_player(player)
89 # Protocols that are disabled or not registered only ever reach a player's
90 # output list through cache recovery, which the registration path runs.
91 self.mass.players._recover_cached_protocol_links(player)
92
93 async def create_universal_player(
94 self,
95 player_id: str,
96 name: str,
97 device_info: DeviceInfo,
98 protocol_player_ids: list[str],
99 ) -> Player:
100 """
101 Create a new UniversalPlayer.
102
103 Called by the PlayerController when multiple protocol players are
104 detected for a device without a native player.
105
106 :param player_id: Player id for the new player, as minted by `mint_player_id`.
107 :param name: Display name for the player.
108 :param device_info: Aggregated device information.
109 :param protocol_player_ids: List of protocol player IDs to link.
110 :return: The created UniversalPlayer instance.
111 """
112 # Check if player already exists
113 if existing := self.mass.players.get_player(player_id):
114 # Update existing player with new protocol players
115 if isinstance(existing, UniversalPlayer):
116 for pid in protocol_player_ids:
117 existing.add_protocol_player(pid)
118 # Merge identifiers from new device_info
119 for id_type, value in device_info.identifiers.items():
120 existing.device_info.add_identifier(id_type, value)
121 # Persist updated data to config
122 await self._save_player_data(player_id, existing)
123 existing.update_state()
124 return existing
125
126 # Create config for the new player (complex values saved separately after)
127 self.mass.config.create_default_player_config(
128 player_id=player_id,
129 provider=self.instance_id,
130 player_type=PlayerType.GROUP,
131 name=name,
132 enabled=True,
133 values={
134 CONF_LINKED_PROTOCOL_IDS: protocol_player_ids,
135 CONF_CREATED_AT: time.time_ns(),
136 },
137 )
138
139 # Save device identifiers and info to config (these are nested dicts,
140 # not supported by ConfigValueType, so we save them directly)
141 base_key = f"{CONF_PLAYERS}/{player_id}/values"
142 self.mass.config.set(
143 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
144 {k.value: v for k, v in device_info.identifiers.items()},
145 )
146 self.mass.config.set(
147 f"{base_key}/{CONF_DEVICE_INFO}",
148 {"model": device_info.model, "manufacturer": device_info.manufacturer},
149 )
150
151 self.logger.info(
152 "Creating universal player %s with protocol players: %s",
153 player_id,
154 protocol_player_ids,
155 )
156
157 # Create the player instance
158 player = UniversalPlayer(
159 provider=self,
160 player_id=player_id,
161 name=name,
162 device_info=device_info,
163 protocol_player_ids=protocol_player_ids,
164 )
165
166 await self.mass.players.register_or_update(player)
167 return player
168
169 async def add_protocol_to_universal_player(
170 self, player_id: str, protocol_player_id: str
171 ) -> None:
172 """
173 Add a protocol player to an existing universal player.
174
175 Called when a new protocol player is discovered that matches an existing
176 universal player.
177
178 :param player_id: ID of the universal player.
179 :param protocol_player_id: ID of the protocol player to add.
180 """
181 if player := self.get_universal_player(player_id):
182 player.add_protocol_player(protocol_player_id)
183 # Save all player data (protocol IDs, identifiers, device info)
184 await self._save_player_data(player_id, player)
185 player.update_state()
186
187 async def remove_universal_player(self, player_id: str) -> None:
188 """
189 Remove a universal player.
190
191 Called when all protocol players for a device are removed.
192
193 :param player_id: ID of the universal player to remove.
194 """
195 await self.mass.players.unregister(player_id, permanent=True)
196
197 async def ensure_universal_players_for_protocols(
198 self, protocol_players: list[Player]
199 ) -> dict[str, Player]:
200 """
201 Ensure a universal player exists for a set of protocol players of one device.
202
203 A device keeps the universal player it already belongs to, so its player id -
204 the identity API consumers (such as the Home Assistant integration) bind to -
205 stays the same for the lifetime of the device. Only a device that was never
206 wrapped before gets a newly minted id.
207
208 A protocol domain the universal player already serves means a second device
209 behind the same identifiers (e.g. two AirPlay instances on one host); such a
210 player gets a universal player of its own.
211
212 :param protocol_players: List of protocol players for the same device.
213 :return: The universal player per protocol player id, keyed by protocol player id.
214 """
215 async with self._lock:
216 # Re-check - another task may have already handled these players
217 # Filter out players that are already linked to a parent
218 protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
219 if not protocol_players:
220 return {}
221
222 # The parent link persisted on the protocol player is the canonical side
223 # of the relation: it names the universal player this device belongs to.
224 assignments: dict[str, Player] = {}
225 unassigned: list[Player] = []
226 for player in protocol_players:
227 if universal_player := await self._resolve_stored_universal_player(player):
228 assignments[player.player_id] = universal_player
229 else:
230 unassigned.append(player)
231
232 target = next(iter(assignments.values()), None)
233 served_domains: set[str] = set()
234 if target is None:
235 # this device was never wrapped before: create one universal player
236 # for it, taking a single protocol player per domain
237 members = self._first_player_per_domain(unassigned)
238 target = await self.create_universal_player(
239 player_id=self.mint_player_id(),
240 name=self._get_clean_player_name(members),
241 device_info=self._aggregate_device_info(members),
242 protocol_player_ids=[p.player_id for p in members],
243 )
244 for player in members:
245 assignments[player.player_id] = target
246 served_domains.add(player.provider.domain)
247 unassigned = [p for p in unassigned if p.player_id not in assignments]
248 else:
249 served_domains = self._served_domains(target)
250 served_domains.update(
251 player.provider.domain
252 for player in protocol_players
253 if assignments.get(player.player_id) is target
254 )
255
256 for player in unassigned:
257 if player.provider.domain in served_domains:
258 assignments[player.player_id] = await self._create_separate_universal_player(
259 player
260 )
261 continue
262 served_domains.add(player.provider.domain)
263 await self.add_protocol_to_universal_player(target.player_id, player.player_id)
264 assignments[player.player_id] = target
265
266 return assignments
267
268 def mint_player_id(self) -> str:
269 """Return an unused player id for a new universal player."""
270 while True:
271 player_id = f"{UNIVERSAL_PLAYER_PREFIX}{uuid4().hex[:8]}"
272 if self.mass.players.get_player(player_id):
273 continue
274 if self.mass.config.get(f"{CONF_PLAYERS}/{player_id}"):
275 continue
276 return player_id
277
278 def get_universal_player(self, player_id: str) -> UniversalPlayer | None:
279 """Get a UniversalPlayer by ID if it exists and is managed by this provider."""
280 if player := self.mass.players.get_player(player_id):
281 if isinstance(player, UniversalPlayer):
282 return player
283 return None
284
285 async def remove_player(self, player_id: str) -> None:
286 """Remove a universal player and clean up any stale protocol player configs."""
287 if player := self.get_universal_player(player_id):
288 # Clean up configs for protocol players tracked by this universal player
289 # that are not currently registered (unavailable/stale).
290 # Available protocol players are handled by _cleanup_protocol_links
291 # in the player controller (clears parent + schedules re-evaluation).
292 for protocol_id in list(player._protocol_player_ids):
293 if not self.mass.players.get_player(protocol_id):
294 self.logger.info(
295 "Cleaning up stale protocol config %s from universal player %s",
296 protocol_id,
297 player_id,
298 )
299 self.mass.players.delete_player_config(protocol_id)
300 await self.remove_universal_player(player_id)
301
302 async def _restore_player(self, player_id: str) -> None:
303 """
304 Restore a universal player from config.
305
306 The stored protocol_player_ids enable fast matching when protocol players
307 register - they can be linked immediately without waiting for identifier matching.
308 Device identifiers are also restored to enable matching new protocol players.
309 """
310 if self.get_universal_player(player_id):
311 # a restore replaces the player instance, which would drop the output
312 # protocol links of the registered one while its members still point here
313 return
314
315 # Get stored config values
316 config = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
317 if not config:
318 return
319
320 # Get stored values
321 values = config.get("values") or {}
322 stored_identifiers = values.get(CONF_DEVICE_IDENTIFIERS, {})
323 stored_device_info = values.get(CONF_DEVICE_INFO, {})
324
325 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
326 valid_protocol_ids = self._resolve_stored_protocol_ids(player_id, all_player_configs)
327
328 # When nothing links to the stored universal player config (anymore),
329 # keep it - it holds user customizations - and simply skip restoring:
330 # the config is picked up again once a protocol player that stored this
331 # player as its parent registers.
332 if not valid_protocol_ids:
333 self.logger.debug(
334 "Not restoring universal player %s - no linked protocol players remain",
335 player_id,
336 )
337 return
338
339 # Protocols that (also) belong to a native player mean this universal
340 # player is a leftover wrapper: repair the protocol links to point at
341 # the rightful native parent and replace the wrapper by that native
342 # player instead of restoring it.
343 native_claims: dict[str, str] = {}
344 for protocol_id in valid_protocol_ids:
345 for other_player_id, other_config in all_player_configs.items():
346 if other_player_id == player_id:
347 continue
348 if other_config.get("provider") == "universal_player":
349 continue
350 other_values = other_config.get("values") or {}
351 if protocol_id in (other_values.get(CONF_LINKED_PROTOCOL_IDS) or []):
352 native_claims[protocol_id] = other_player_id
353 break
354 if native_claims:
355 # Members are same-device by construction, so members not claimed by
356 # any native follow the first claimer (keeps the cascade-disable
357 # repair intact for protocols that appeared after the parent left).
358 default_parent = next(iter(native_claims.values()))
359 by_parent: dict[str, list[str]] = {}
360 for protocol_id in valid_protocol_ids:
361 parent_id = native_claims.get(protocol_id, default_parent)
362 by_parent.setdefault(parent_id, []).append(protocol_id)
363 for native_id, protocol_ids in by_parent.items():
364 self.logger.info(
365 "Not restoring universal player %s - protocols %s are linked "
366 "to native player %s",
367 player_id,
368 protocol_ids,
369 native_id,
370 )
371 await self._reparent_protocols_to_native(native_id, protocol_ids)
372 # Mirror the runtime replacement by a native player: carry the
373 # wrapper's user settings and group memberships over to the native
374 # player and delete the wrapper's now-obsolete config, so it doesn't
375 # linger as a permanently unavailable entry in the settings UI.
376 # Skipped while the wrapper is still registered - the runtime
377 # replacement flow owns that transition.
378 if not self.mass.players.get_player(player_id):
379 self.logger.info(
380 "Removing stored config of universal player %s - replaced by %s",
381 player_id,
382 default_parent,
383 )
384 self.mass.players._migrate_universal_player_config(player_id, default_parent)
385 self.mass.players.delete_player_config(
386 player_id, replacement_player_id=default_parent
387 )
388 return
389
390 stored_protocol_ids = valid_protocol_ids
391
392 # Persist the updated protocol IDs to config if they changed
393 if valid_protocol_ids != list(values.get(CONF_LINKED_PROTOCOL_IDS) or []):
394 self.mass.config.set(
395 f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
396 valid_protocol_ids,
397 )
398
399 # Restore device info with stored values or defaults
400 device_info = DeviceInfo(
401 model=stored_device_info.get("model", "Universal Player"),
402 manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
403 )
404
405 # Restore identifiers (convert string keys back to IdentifierType enum)
406 for id_type_str, value in stored_identifiers.items():
407 try:
408 id_type = IdentifierType(id_type_str)
409 device_info.add_identifier(id_type, value)
410 except ValueError:
411 self.logger.warning(
412 "Unknown identifier type %s for player %s", id_type_str, player_id
413 )
414
415 # the default name, not the custom one: display_name already prefers the
416 # custom name, while update_state persists this one as the default name
417 name = config.get("default_name") or config.get("name") or f"Universal Player {player_id}"
418
419 self.logger.debug(
420 "Restoring universal player %s with %d protocol IDs and %d identifiers",
421 player_id,
422 len(stored_protocol_ids),
423 len(stored_identifiers),
424 )
425
426 player = UniversalPlayer(
427 provider=self,
428 player_id=player_id,
429 name=name,
430 device_info=device_info,
431 protocol_player_ids=list(stored_protocol_ids),
432 )
433 await self.mass.players.register_or_update(player)
434
435 def _resolve_stored_protocol_ids(
436 self, player_id: str, all_player_configs: dict[str, dict[str, Any]]
437 ) -> list[str]:
438 """
439 Resolve the current protocol player membership of a stored universal player.
440
441 Reconciles the universal player's stored member list with the parent links
442 persisted on the protocol players themselves, dropping members that moved
443 away or unlinked. Configs are never deleted here.
444 """
445 config = all_player_configs.get(player_id) or {}
446 values = config.get("values") or {}
447 stored_protocol_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS) or [])
448
449 # The child's persisted parent link is the canonical side of the relation:
450 # also pick up children that point at us but are missing from our stored
451 # list (e.g. only one side of the link survived an interrupted shutdown).
452 for child_id, child_config in all_player_configs.items():
453 child_values = child_config.get("values") or {}
454 if child_values.get(CONF_PROTOCOL_PARENT_ID) != player_id:
455 continue
456 if child_id not in stored_protocol_ids:
457 stored_protocol_ids.append(child_id)
458
459 valid_protocol_ids = []
460 for protocol_id in stored_protocol_ids:
461 protocol_config = all_player_configs.get(protocol_id)
462 if not protocol_config:
463 # Config doesn't exist, keep it for now (player may register later)
464 valid_protocol_ids.append(protocol_id)
465 continue
466 protocol_values = protocol_config.get("values") or {}
467 parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
468 if parent_id == player_id:
469 # the persisted parent link proves this child still belongs to us,
470 # even if its player_type was left stale by an aborted registration
471 valid_protocol_ids.append(protocol_id)
472 continue
473 if parent_id:
474 self.logger.info(
475 "Removing %s from universal player %s - moved to parent %s",
476 protocol_id,
477 player_id,
478 parent_id,
479 )
480 continue
481 if protocol_config.get("player_type") != "protocol":
482 self.logger.info(
483 "Removing %s from universal player %s - player type changed to %s",
484 protocol_id,
485 player_id,
486 protocol_config.get("player_type"),
487 )
488 continue
489 # unlinked protocol player: no longer ours, but keep its config -
490 # it may relink (or be adopted by another player) once it registers
491 self.logger.info(
492 "Removing %s from universal player %s - no longer linked",
493 protocol_id,
494 player_id,
495 )
496 return valid_protocol_ids
497
498 async def _reparent_protocols_to_native(
499 self, native_parent_id: str, protocol_ids: list[str]
500 ) -> None:
501 """
502 Restore protocol players' parent link to their rightful native parent.
503
504 Used to repair configs when a stale universal player wrapped protocols that
505 belong to a native player: the protocols' cached parent_id was overwritten to
506 point at the universal player when it was created. Protocols of a disabled
507 native parent are cascade-disabled as well, so they don't immediately wrap
508 into a fresh universal player on the next registration cycle.
509 """
510 parent_config = self.mass.config.get(f"{CONF_PLAYERS}/{native_parent_id}") or {}
511 parent_enabled = parent_config.get("enabled", True)
512 for protocol_id in protocol_ids:
513 protocol_raw = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
514 if not protocol_raw:
515 continue
516 self.mass.config.set(
517 f"{CONF_PLAYERS}/{protocol_id}/values/{CONF_PROTOCOL_PARENT_ID}",
518 native_parent_id,
519 )
520 if parent_enabled or not protocol_raw.get("enabled", True):
521 continue
522 self.logger.info(
523 "Disabling orphaned protocol player %s to match its disabled parent %s",
524 protocol_id,
525 native_parent_id,
526 )
527 await self.mass.config.save_player_config(protocol_id, {"enabled": False})
528
529 async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
530 """Save protocol player IDs to config for persistence across restarts."""
531 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
532 self.mass.config.set(conf_key, protocol_player_ids)
533 self.logger.debug(
534 "Saved protocol IDs for %s: %s",
535 player_id,
536 protocol_player_ids,
537 )
538
539 async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
540 """Save all player data to config for persistence across restarts."""
541 base_key = f"{CONF_PLAYERS}/{player_id}/values"
542
543 # Save protocol IDs
544 self.mass.config.set(
545 f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
546 player._protocol_player_ids,
547 )
548
549 # Save identifiers (convert IdentifierType enum keys to strings)
550 self.mass.config.set(
551 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
552 {k.value: v for k, v in player.device_info.identifiers.items()},
553 )
554
555 # Save device info (model, manufacturer)
556 self.mass.config.set(
557 f"{base_key}/{CONF_DEVICE_INFO}",
558 {
559 "model": player.device_info.model,
560 "manufacturer": player.device_info.manufacturer,
561 },
562 )
563
564 self.logger.debug(
565 "Saved player data for %s: %d protocols, %d identifiers",
566 player_id,
567 len(player._protocol_player_ids),
568 len(player.device_info.identifiers),
569 )
570
571 async def _create_separate_universal_player(self, protocol_player: Player) -> Player:
572 """
573 Create a separate universal player for a protocol player that was rejected.
574
575 Used when a second instance of the same protocol domain (e.g., two AirPlay
576 instances on the same host) cannot join the universal player of the device.
577
578 :param protocol_player: The protocol player that needs its own universal player.
579 """
580 return await self.create_universal_player(
581 player_id=self.mint_player_id(),
582 name=self._get_clean_player_name([protocol_player]),
583 device_info=self._aggregate_device_info([protocol_player]),
584 protocol_player_ids=[protocol_player.player_id],
585 )
586
587 async def _resolve_stored_universal_player(
588 self, protocol_player: Player
589 ) -> UniversalPlayer | None:
590 """
591 Return the universal player a protocol player is persistently linked to, if any.
592
593 :param protocol_player: The protocol player to resolve the universal player of.
594 """
595 parent_id = self.mass.config.get(
596 f"{CONF_PLAYERS}/{protocol_player.player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
597 )
598 if not isinstance(parent_id, str) or not parent_id:
599 return None
600 if existing := self.get_universal_player(parent_id):
601 return existing
602 raw_conf = self.mass.config.get(f"{CONF_PLAYERS}/{parent_id}")
603 # the "up" prefix alone is not enough, a native player id could
604 # coincidentally carry it, so require our own provider as well
605 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
606 return None
607 if not raw_conf.get("enabled", True):
608 # the user turned this device off, bringing it back would defeat that intent
609 return None
610 await self._restore_player(parent_id)
611 return self.get_universal_player(parent_id)
612
613 def _served_domains(self, universal_player: Player) -> set[str]:
614 """Return the protocol domains that are occupied on a universal player."""
615 return {
616 link.protocol_domain
617 for link in universal_player.linked_output_protocols
618 # a registered player occupies this domain slot even if unavailable
619 if link.protocol_domain and self.mass.players.get_player(link.output_protocol_id)
620 }
621
622 def _first_player_per_domain(self, protocol_players: list[Player]) -> list[Player]:
623 """Return the first protocol player of every distinct protocol domain."""
624 seen: set[str] = set()
625 members: list[Player] = []
626 for player in protocol_players:
627 if player.provider.domain in seen:
628 continue
629 seen.add(player.provider.domain)
630 members.append(player)
631 return members
632
633 def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
634 """Aggregate device info from protocol players."""
635 first_player = protocol_players[0]
636 device_info = DeviceInfo(
637 model=first_player.device_info.model,
638 manufacturer=first_player.device_info.manufacturer,
639 )
640 # Merge identifiers from all protocol players
641 for player in protocol_players:
642 for conn_type, value in player.device_info.identifiers.items():
643 device_info.add_identifier(conn_type, value)
644 return device_info
645
646 def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
647 """
648 Get the best display name from protocol players.
649
650 Prefers names from protocols that typically provide user-friendly names
651 (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
652 (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
653 UUIDs, or player IDs.
654 """
655 # Protocol priority for name selection (higher priority = better names typically)
656 # Chromecast and DLNA usually have good user-configured names
657 # AirPlay also provides sensible names
658 # Squeezelite and SendSpin may use MAC addresses or technical IDs
659 name_priority = {
660 "chromecast": 1,
661 "airplay": 2,
662 "dlna": 3,
663 "squeezelite": 4,
664 "sendspin": 5,
665 }
666
667 def is_valid_name(name: str) -> bool:
668 """Check if a name looks like a real user-friendly name, not a technical ID."""
669 if not name or len(name) < 2:
670 return False
671 name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
672 # Filter out names that look like MAC addresses (12 hex chars)
673 if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
674 return False
675 # Filter out names that look like UUIDs
676 if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
677 return False
678 # Filter out names that start with common player ID prefixes
679 return not name_lower.startswith(
680 ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
681 )
682
683 # Sort players by protocol priority, then find the first valid name
684 sorted_players = sorted(
685 protocol_players,
686 key=lambda p: name_priority.get(p.provider.domain, 10),
687 )
688
689 for player in sorted_players:
690 player_name = player.state.name
691 if is_valid_name(player_name):
692 return player_name
693
694 # Fallback to first player's name if no valid name found
695 return protocol_players[0].display_name
696