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