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