/
/
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._repoint_group_memberships(player_id, default_parent)
386 self.mass.players.delete_player_config(player_id)
387 return
388
389 stored_protocol_ids = valid_protocol_ids
390
391 # Persist the updated protocol IDs to config if they changed
392 if valid_protocol_ids != list(values.get(CONF_LINKED_PROTOCOL_IDS) or []):
393 self.mass.config.set(
394 f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
395 valid_protocol_ids,
396 )
397
398 # Restore device info with stored values or defaults
399 device_info = DeviceInfo(
400 model=stored_device_info.get("model", "Universal Player"),
401 manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
402 )
403
404 # Restore identifiers (convert string keys back to IdentifierType enum)
405 for id_type_str, value in stored_identifiers.items():
406 try:
407 id_type = IdentifierType(id_type_str)
408 device_info.add_identifier(id_type, value)
409 except ValueError:
410 self.logger.warning(
411 "Unknown identifier type %s for player %s", id_type_str, player_id
412 )
413
414 # the default name, not the custom one: display_name already prefers the
415 # custom name, while update_state persists this one as the default name
416 name = config.get("default_name") or config.get("name") or f"Universal Player {player_id}"
417
418 self.logger.debug(
419 "Restoring universal player %s with %d protocol IDs and %d identifiers",
420 player_id,
421 len(stored_protocol_ids),
422 len(stored_identifiers),
423 )
424
425 player = UniversalPlayer(
426 provider=self,
427 player_id=player_id,
428 name=name,
429 device_info=device_info,
430 protocol_player_ids=list(stored_protocol_ids),
431 )
432 await self.mass.players.register_or_update(player)
433
434 def _resolve_stored_protocol_ids(
435 self, player_id: str, all_player_configs: dict[str, dict[str, Any]]
436 ) -> list[str]:
437 """
438 Resolve the current protocol player membership of a stored universal player.
439
440 Reconciles the universal player's stored member list with the parent links
441 persisted on the protocol players themselves, dropping members that moved
442 away or unlinked. Configs are never deleted here.
443 """
444 config = all_player_configs.get(player_id) or {}
445 values = config.get("values") or {}
446 stored_protocol_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS) or [])
447
448 # The child's persisted parent link is the canonical side of the relation:
449 # also pick up children that point at us but are missing from our stored
450 # list (e.g. only one side of the link survived an interrupted shutdown).
451 for child_id, child_config in all_player_configs.items():
452 child_values = child_config.get("values") or {}
453 if child_values.get(CONF_PROTOCOL_PARENT_ID) != player_id:
454 continue
455 if child_id not in stored_protocol_ids:
456 stored_protocol_ids.append(child_id)
457
458 valid_protocol_ids = []
459 for protocol_id in stored_protocol_ids:
460 protocol_config = all_player_configs.get(protocol_id)
461 if not protocol_config:
462 # Config doesn't exist, keep it for now (player may register later)
463 valid_protocol_ids.append(protocol_id)
464 continue
465 protocol_values = protocol_config.get("values") or {}
466 parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
467 if parent_id == player_id:
468 # the persisted parent link proves this child still belongs to us,
469 # even if its player_type was left stale by an aborted registration
470 valid_protocol_ids.append(protocol_id)
471 continue
472 if parent_id:
473 self.logger.info(
474 "Removing %s from universal player %s - moved to parent %s",
475 protocol_id,
476 player_id,
477 parent_id,
478 )
479 continue
480 if protocol_config.get("player_type") != "protocol":
481 self.logger.info(
482 "Removing %s from universal player %s - player type changed to %s",
483 protocol_id,
484 player_id,
485 protocol_config.get("player_type"),
486 )
487 continue
488 # unlinked protocol player: no longer ours, but keep its config -
489 # it may relink (or be adopted by another player) once it registers
490 self.logger.info(
491 "Removing %s from universal player %s - no longer linked",
492 protocol_id,
493 player_id,
494 )
495 return valid_protocol_ids
496
497 async def _reparent_protocols_to_native(
498 self, native_parent_id: str, protocol_ids: list[str]
499 ) -> None:
500 """
501 Restore protocol players' parent link to their rightful native parent.
502
503 Used to repair configs when a stale universal player wrapped protocols that
504 belong to a native player: the protocols' cached parent_id was overwritten to
505 point at the universal player when it was created. Protocols of a disabled
506 native parent are cascade-disabled as well, so they don't immediately wrap
507 into a fresh universal player on the next registration cycle.
508 """
509 parent_config = self.mass.config.get(f"{CONF_PLAYERS}/{native_parent_id}") or {}
510 parent_enabled = parent_config.get("enabled", True)
511 for protocol_id in protocol_ids:
512 protocol_raw = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
513 if not protocol_raw:
514 continue
515 self.mass.config.set(
516 f"{CONF_PLAYERS}/{protocol_id}/values/{CONF_PROTOCOL_PARENT_ID}",
517 native_parent_id,
518 )
519 if parent_enabled or not protocol_raw.get("enabled", True):
520 continue
521 self.logger.info(
522 "Disabling orphaned protocol player %s to match its disabled parent %s",
523 protocol_id,
524 native_parent_id,
525 )
526 await self.mass.config.save_player_config(protocol_id, {"enabled": False})
527
528 async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
529 """Save protocol player IDs to config for persistence across restarts."""
530 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
531 self.mass.config.set(conf_key, protocol_player_ids)
532 self.logger.debug(
533 "Saved protocol IDs for %s: %s",
534 player_id,
535 protocol_player_ids,
536 )
537
538 async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
539 """Save all player data to config for persistence across restarts."""
540 base_key = f"{CONF_PLAYERS}/{player_id}/values"
541
542 # Save protocol IDs
543 self.mass.config.set(
544 f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
545 player._protocol_player_ids,
546 )
547
548 # Save identifiers (convert IdentifierType enum keys to strings)
549 self.mass.config.set(
550 f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
551 {k.value: v for k, v in player.device_info.identifiers.items()},
552 )
553
554 # Save device info (model, manufacturer)
555 self.mass.config.set(
556 f"{base_key}/{CONF_DEVICE_INFO}",
557 {
558 "model": player.device_info.model,
559 "manufacturer": player.device_info.manufacturer,
560 },
561 )
562
563 self.logger.debug(
564 "Saved player data for %s: %d protocols, %d identifiers",
565 player_id,
566 len(player._protocol_player_ids),
567 len(player.device_info.identifiers),
568 )
569
570 async def _create_separate_universal_player(self, protocol_player: Player) -> Player:
571 """
572 Create a separate universal player for a protocol player that was rejected.
573
574 Used when a second instance of the same protocol domain (e.g., two AirPlay
575 instances on the same host) cannot join the universal player of the device.
576
577 :param protocol_player: The protocol player that needs its own universal player.
578 """
579 return await self.create_universal_player(
580 player_id=self.mint_player_id(),
581 name=self._get_clean_player_name([protocol_player]),
582 device_info=self._aggregate_device_info([protocol_player]),
583 protocol_player_ids=[protocol_player.player_id],
584 )
585
586 async def _resolve_stored_universal_player(
587 self, protocol_player: Player
588 ) -> UniversalPlayer | None:
589 """
590 Return the universal player a protocol player is persistently linked to, if any.
591
592 :param protocol_player: The protocol player to resolve the universal player of.
593 """
594 parent_id = self.mass.config.get(
595 f"{CONF_PLAYERS}/{protocol_player.player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
596 )
597 if not isinstance(parent_id, str) or not parent_id:
598 return None
599 if existing := self.get_universal_player(parent_id):
600 return existing
601 raw_conf = self.mass.config.get(f"{CONF_PLAYERS}/{parent_id}")
602 # the "up" prefix alone is not enough, a native player id could
603 # coincidentally carry it, so require our own provider as well
604 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
605 return None
606 if not raw_conf.get("enabled", True):
607 # the user turned this device off, bringing it back would defeat that intent
608 return None
609 await self._restore_player(parent_id)
610 return self.get_universal_player(parent_id)
611
612 def _served_domains(self, universal_player: Player) -> set[str]:
613 """Return the protocol domains that are occupied on a universal player."""
614 return {
615 link.protocol_domain
616 for link in universal_player.linked_output_protocols
617 # a registered player occupies this domain slot even if unavailable
618 if link.protocol_domain and self.mass.players.get_player(link.output_protocol_id)
619 }
620
621 def _first_player_per_domain(self, protocol_players: list[Player]) -> list[Player]:
622 """Return the first protocol player of every distinct protocol domain."""
623 seen: set[str] = set()
624 members: list[Player] = []
625 for player in protocol_players:
626 if player.provider.domain in seen:
627 continue
628 seen.add(player.provider.domain)
629 members.append(player)
630 return members
631
632 def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
633 """Aggregate device info from protocol players."""
634 first_player = protocol_players[0]
635 device_info = DeviceInfo(
636 model=first_player.device_info.model,
637 manufacturer=first_player.device_info.manufacturer,
638 )
639 # Merge identifiers from all protocol players
640 for player in protocol_players:
641 for conn_type, value in player.device_info.identifiers.items():
642 device_info.add_identifier(conn_type, value)
643 return device_info
644
645 def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
646 """
647 Get the best display name from protocol players.
648
649 Prefers names from protocols that typically provide user-friendly names
650 (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
651 (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
652 UUIDs, or player IDs.
653 """
654 # Protocol priority for name selection (higher priority = better names typically)
655 # Chromecast and DLNA usually have good user-configured names
656 # AirPlay also provides sensible names
657 # Squeezelite and SendSpin may use MAC addresses or technical IDs
658 name_priority = {
659 "chromecast": 1,
660 "airplay": 2,
661 "dlna": 3,
662 "squeezelite": 4,
663 "sendspin": 5,
664 }
665
666 def is_valid_name(name: str) -> bool:
667 """Check if a name looks like a real user-friendly name, not a technical ID."""
668 if not name or len(name) < 2:
669 return False
670 name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
671 # Filter out names that look like MAC addresses (12 hex chars)
672 if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
673 return False
674 # Filter out names that look like UUIDs
675 if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
676 return False
677 # Filter out names that start with common player ID prefixes
678 return not name_lower.startswith(
679 ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
680 )
681
682 # Sort players by protocol priority, then find the first valid name
683 sorted_players = sorted(
684 protocol_players,
685 key=lambda p: name_priority.get(p.provider.domain, 10),
686 )
687
688 for player in sorted_players:
689 player_name = player.state.name
690 if is_valid_name(player_name):
691 return player_name
692
693 # Fallback to first player's name if no valid name found
694 return protocol_players[0].display_name
695