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