/
/
1"""
2Protocol Linking Mixin for the Player Controller.
3
4Handles all logic for linking protocol players (AirPlay, Chromecast, DLNA) to
5native players or wrapping them in Universal Players.
6
7This module provides the ProtocolLinkingMixin class which is inherited by
8PlayerController to add protocol linking capabilities.
9"""
10
11from __future__ import annotations
12
13import asyncio
14import logging
15from contextlib import suppress
16from copy import deepcopy
17from typing import TYPE_CHECKING, cast
18
19from music_assistant_models.enums import (
20 EventType,
21 IdentifierType,
22 PlaybackState,
23 PlayerFeature,
24 PlayerType,
25 ProviderType,
26)
27from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError
28
29from music_assistant.constants import (
30 CONF_CACHED_ARP_MAC,
31 CONF_GROUP_MEMBERS,
32 CONF_LINKED_PROTOCOL_IDS,
33 CONF_PLAYER_DSP,
34 CONF_PLAYER_QUEUES,
35 CONF_PLAYERS,
36 CONF_PREFERRED_OUTPUT_PROTOCOL,
37 CONF_PROTOCOL_KEY_SPLITTER,
38 CONF_PROTOCOL_PARENT_ID,
39 CONF_REPORTED_MAC,
40 CONF_UNDERLYING_PLAYER_ID,
41 PROTOCOL_PRIORITY,
42 VERBOSE_LOG_LEVEL,
43)
44from music_assistant.helpers.util import (
45 is_locally_administered_mac,
46 is_valid_mac_address,
47 normalize_mac_for_matching,
48)
49from music_assistant.models.player import LinkedOutputProtocol, Player
50from music_assistant.providers.sync_group.constants import CONF_ALLOWED_MEMBERS
51from music_assistant.providers.universal_player import UniversalPlayer, UniversalPlayerProvider
52from music_assistant.providers.universal_player.constants import (
53 CONF_CREATED_AT,
54 CONF_DEVICE_IDENTIFIERS,
55 CONF_DEVICE_INFO,
56)
57
58if TYPE_CHECKING:
59 from collections.abc import Coroutine
60 from typing import Any
61
62 from music_assistant_models.player import OutputProtocol
63
64 from music_assistant import MusicAssistant
65
66# Config value keys that are bookkeeping of the universal player wrapper itself
67# (protocol links, device-identity caches and its creation moment) and must never
68# be carried over when a native player replaces a universal player, or when one
69# universal player absorbs another.
70UNIVERSAL_PLAYER_INTERNAL_CONF_KEYS = (
71 CONF_LINKED_PROTOCOL_IDS,
72 CONF_PROTOCOL_PARENT_ID,
73 CONF_UNDERLYING_PLAYER_ID,
74 CONF_DEVICE_IDENTIFIERS,
75 CONF_DEVICE_INFO,
76 CONF_CACHED_ARP_MAC,
77 CONF_REPORTED_MAC,
78 CONF_CREATED_AT,
79)
80
81
82class ProtocolLinkingMixin:
83 """
84 Mixin class providing protocol linking functionality for PlayerController.
85
86 Handles the complex logic of:
87 - Matching protocol players to native players via device identifiers
88 - Creating Universal Players for devices without native support
89 - Managing protocol links and their lifecycle
90 - Selecting the best output protocol for playback
91
92 This mixin expects to be mixed with a class that provides:
93 - mass: MusicAssistant instance
94 - _players: dict of registered players
95 - _pending_protocol_evaluations: dict of pending protocol evaluations
96 - logger: logging.Logger instance
97 - all(): method to get all players
98 - get(): method to get a player by ID
99 - unregister(): method to unregister a player
100 """
101
102 # Type hints for attributes provided by the class this mixin is used with
103 if TYPE_CHECKING:
104 mass: MusicAssistant
105 _players: dict[str, Player]
106 _pending_protocol_evaluations: dict[str, asyncio.TimerHandle]
107 _delayed_evaluation_lock: asyncio.Lock
108 logger: logging.Logger
109
110 def all_players( # noqa: D102
111 self,
112 return_unavailable: bool = True,
113 return_disabled: bool = False,
114 provider_filter: str | None = None,
115 return_protocol_players: bool = False,
116 ) -> list[Player]: ...
117
118 def get_player(self, player_id: str) -> Player | None: ... # noqa: D102
119
120 def unregister( # noqa: D102
121 self,
122 player_id: str,
123 permanent: bool = False,
124 replacement_player_id: str | None = None,
125 ) -> Coroutine[Any, Any, None]: ...
126
127 def _is_protocol_player(self, player: Player) -> bool:
128 """
129 Check if a player is a generic protocol player without native support.
130
131 Protocol players have PlayerType.PROTOCOL set by their provider, indicating
132 they are generic streaming endpoints (e.g., AirPlay receiver, Chromecast device)
133 without vendor-specific native support in Music Assistant.
134 """
135 return player.state.type == PlayerType.PROTOCOL
136
137 def _evaluate_protocol_links(self, player: Player) -> None:
138 """
139 Evaluate and establish protocol links for a player.
140
141 Called when a player is registered to:
142 1. If it's from a protocol provider - try to link to a native player.
143 2. If it's a native player - try to link any existing protocol players.
144 """
145 if player.state.type == PlayerType.PROTOCOL:
146 # Protocol player: try to find a native parent
147 self._try_link_protocol_to_native(player)
148 elif player.state.type == PlayerType.GROUP:
149 return
150 else:
151 # A player that registers with a non-protocol type can no longer be a
152 # protocol child: drop a leftover persisted parent link (e.g. from a
153 # bridge client that turned web player) so the startup repair pass
154 # doesn't heal its player type back to protocol.
155 if self._get_cached_protocol_parent_id(player.player_id):
156 self._clear_protocol_parent_id(player.player_id)
157 # Native player (including STEREO_PAIR): try to find protocol players to link
158 self._try_link_protocols_to_native(player)
159
160 def _try_link_protocol_to_native(self, protocol_player: Player) -> None:
161 """Try to link a protocol player to a native player."""
162 protocol_domain = protocol_player.provider.domain
163
164 # Derived protocol players (e.g. Sendspin bridges riding on another
165 # protocol) resolve strictly via their underlying player - no identifier
166 # matching or delayed evaluation. If the underlying player has no parent
167 # yet, the link is established by _link_derived_protocols_of as soon as
168 # the underlying player gets linked.
169 if protocol_player.underlying_player_id:
170 self._try_link_derived_protocol(protocol_player)
171 return
172
173 # Check for cached parent_id from previous session and restore link immediately
174 cached_parent_id = self._get_cached_protocol_parent_id(protocol_player.player_id)
175 if cached_parent_id:
176 result = self._try_restore_cached_parent(
177 protocol_player, cached_parent_id, protocol_domain
178 )
179 if result:
180 return
181 if not self.get_player(cached_parent_id):
182 # The persisted owner has not registered yet: wait for it instead of
183 # letting another parent's cached ids or identifiers claim this
184 # protocol. Delayed evaluation links it elsewhere if the owner
185 # never shows up.
186 self._schedule_protocol_evaluation(protocol_player)
187 return
188 # The parent is registered but did not take the link (it is a group, the
189 # cached id points at the protocol player itself, or the parent already
190 # has an active link from this domain) - fall through to generic matching.
191
192 # Look for a matching native player
193 if self._try_link_to_existing_player(protocol_player, protocol_domain):
194 return
195
196 # No native player found - schedule delayed evaluation to allow other protocols to register
197 if not protocol_player.protocol_parent_id:
198 self._schedule_protocol_evaluation(protocol_player)
199
200 def _try_restore_cached_parent(
201 self, protocol_player: Player, cached_parent_id: str, protocol_domain: str
202 ) -> bool:
203 """
204 Try to restore a cached parent link from a previous session.
205
206 :param protocol_player: The protocol player to link.
207 :param cached_parent_id: The cached parent player ID.
208 :param protocol_domain: The protocol domain (e.g., "airplay").
209 :return: True if the link was restored, False if the caller must resolve the parent.
210 """
211 if parent_player := self.get_player(cached_parent_id):
212 if parent_player.state.type == PlayerType.GROUP:
213 self._clear_protocol_parent_id(protocol_player.player_id)
214 return False
215 already_linked = any(
216 link.output_protocol_id == protocol_player.player_id
217 for link in parent_player.linked_output_protocols
218 )
219 if already_linked:
220 # Already linked from a previous call - just restore parent and identifiers
221 protocol_player.set_protocol_parent_id(cached_parent_id)
222 else:
223 # Try to add the link (may be refused if domain already has active link)
224 self._add_protocol_link(parent_player, protocol_player, protocol_domain)
225 if protocol_player.protocol_parent_id:
226 protocol_player.refresh_state()
227 parent_player.refresh_state()
228 # Merge the protocol player's identifiers into the universal player
229 # so identifiers discovered since the last persist (e.g. an
230 # ARP-resolved MAC) are included and new protocol players
231 # (like Sendspin bridges) can match via identifiers.
232 if parent_player.provider.domain == "universal_player" and isinstance(
233 parent_player, UniversalPlayer
234 ):
235 for conn_type, value in protocol_player.device_info.identifiers.items():
236 parent_player.device_info.add_identifier(conn_type, value)
237 self._update_universal_device_info(parent_player, protocol_player)
238 # Check if this universal player should now be merged with another
239 # (e.g., DLNA brought a MAC via ARP that matches an AirPlay universal)
240 self._check_merge_universal_players(parent_player)
241 return True
242 # Link was refused (domain already active on parent) - fall through
243 return False
244
245 # Parent is not registered yet. Leave the protocol player unparented
246 # so the caller schedules delayed evaluation, which can wait for the
247 # cached parent without stranding the protocol player on a dangling id.
248 return False
249
250 def _try_link_to_existing_player(self, protocol_player: Player, protocol_domain: str) -> bool:
251 """
252 Try to link a protocol player to an existing native or universal player.
253
254 :param protocol_player: The protocol player to link.
255 :param protocol_domain: The protocol domain (e.g., "airplay").
256 :return: True if linked successfully, False if no match found.
257 """
258 # Protocol players should only link to:
259 # 1. True native players (Sonos, etc.)
260 # 2. Universal players
261 # NOT to other protocol players (they get merged via universal_player)
262 for native_player in self.all_players(return_protocol_players=False):
263 if native_player.player_id == protocol_player.player_id:
264 continue
265 if native_player.state.type in (PlayerType.PROTOCOL, PlayerType.GROUP):
266 continue
267
268 # For universal players, check if this protocol player is in its stored list
269 # or if identifiers match (for new protocol players like Sendspin bridges
270 # that weren't previously known to the Universal Player)
271 if native_player.provider.domain == "universal_player":
272 if isinstance(native_player, UniversalPlayer):
273 is_known = protocol_player.player_id in native_player._protocol_player_ids
274 is_match = not is_known and self._identifiers_match(
275 native_player, protocol_player, protocol_domain
276 )
277 if is_known or is_match:
278 self._add_protocol_link(native_player, protocol_player, protocol_domain)
279 # Check if linking actually succeeded (may be refused for
280 # duplicate domain)
281 if not protocol_player.protocol_parent_id:
282 continue
283 # Merge the protocol player's identifiers into the universal
284 # player so newly discovered identifiers are included as well
285 for conn_type, value in protocol_player.device_info.identifiers.items():
286 native_player.device_info.add_identifier(conn_type, value)
287 # Update model/manufacturer if universal player has generic values
288 self._update_universal_device_info(native_player, protocol_player)
289 # Register newly matched protocol player with the universal player
290 if is_match:
291 native_player.add_protocol_player(protocol_player.player_id)
292 # Persist updated data to config (async via task)
293 self._save_universal_player_data(native_player)
294 # Check if this universal player should now be merged with another
295 self._check_merge_universal_players(native_player)
296 protocol_player.refresh_state()
297 native_player.refresh_state()
298 return True
299 continue
300
301 # Check cached protocol IDs first for fast matching on restart
302 cached_ids = self._get_cached_protocol_ids(native_player.player_id)
303 if protocol_player.player_id in cached_ids:
304 self._add_protocol_link(native_player, protocol_player, protocol_domain)
305 if protocol_player.protocol_parent_id:
306 protocol_player.refresh_state()
307 native_player.refresh_state()
308 return True
309 # Link refused (domain duplicate) - try next native player
310 continue
311
312 # Fallback to identifier matching
313 if self._identifiers_match(native_player, protocol_player, protocol_domain):
314 self._add_protocol_link(native_player, protocol_player, protocol_domain)
315 if protocol_player.protocol_parent_id:
316 protocol_player.refresh_state()
317 native_player.refresh_state()
318 return True
319 # Link refused (domain duplicate) - try next native player
320 continue
321
322 # Final fallback: check if any already-linked protocol player on this native
323 # player shares identifiers with the new protocol player ("sibling matching").
324 # This handles native players (e.g., HEOS) that don't have their own MAC/serial
325 # identifiers but have protocol players (e.g., AirPlay) from the same device
326 # that do share identifiers with the new protocol player (e.g., Sendspin bridge).
327 if self._match_via_linked_protocols(native_player, protocol_player, protocol_domain):
328 return True
329
330 return False
331
332 def _match_via_linked_protocols(
333 self,
334 native_player: Player,
335 protocol_player: Player,
336 protocol_domain: str,
337 ) -> bool:
338 """
339 Try to match a protocol player to a native player via sibling protocol identifiers.
340
341 Check if any of the native player's already-linked protocol players share
342 identifiers with the new protocol player. This handles native players that lack
343 their own device identifiers but have sibling protocols from the same physical device.
344
345 :param native_player: The native player to potentially link to.
346 :param protocol_player: The new protocol player to link.
347 :param protocol_domain: The protocol domain of the new player.
348 :return: True if linked successfully, False if no match found.
349 """
350 for linked in native_player.linked_output_protocols:
351 linked_player = self.get_player(linked.output_protocol_id)
352 if not linked_player:
353 continue
354 if self._identifiers_match(linked_player, protocol_player, protocol_domain):
355 self._add_protocol_link(native_player, protocol_player, protocol_domain)
356 if protocol_player.protocol_parent_id:
357 protocol_player.refresh_state()
358 native_player.refresh_state()
359 return True
360 # Link refused (domain duplicate) - stop checking siblings
361 break
362 return False
363
364 def _try_link_derived_protocol(self, protocol_player: Player) -> bool:
365 """
366 Link a derived protocol player to the parent of its underlying player.
367
368 Derived protocol players carry an underlying_player_id declared by their
369 bridge, which makes the parent resolution deterministic: they always join
370 the parent of the player they ride on (or that player itself when it is
371 not a protocol player). Callers must ensure the player is not linked yet.
372
373 :param protocol_player: The derived protocol player to link.
374 :return: True if the player got linked, False if the underlying player
375 (or its parent) is not available yet or the link was refused.
376 """
377 underlying = self.get_player(protocol_player.underlying_player_id or "")
378 if underlying is None:
379 return False
380 if underlying.state.type == PlayerType.PROTOCOL:
381 parent = (
382 self.get_player(underlying.protocol_parent_id)
383 if underlying.protocol_parent_id
384 else None
385 )
386 else:
387 parent = underlying
388 if parent is None or parent.state.type == PlayerType.GROUP:
389 return False
390
391 self._add_protocol_link(parent, protocol_player, protocol_player.provider.domain)
392 if not protocol_player.protocol_parent_id:
393 # Link refused (e.g. parent already has an active link from this domain)
394 return False
395
396 if parent.provider.domain == "universal_player" and isinstance(parent, UniversalPlayer):
397 # Track membership and identifiers on the universal player so the
398 # derived protocol restores quickly on the next start.
399 parent.add_protocol_player(protocol_player.player_id)
400 for conn_type, value in protocol_player.device_info.identifiers.items():
401 parent.device_info.add_identifier(conn_type, value)
402 self._save_universal_player_data(parent)
403
404 protocol_player.refresh_state()
405 parent.refresh_state()
406 self.logger.debug(
407 "Linked derived protocol %s to %s (via underlying %s)",
408 protocol_player.player_id,
409 parent.player_id,
410 underlying.player_id,
411 )
412 return True
413
414 def _link_derived_protocols_of(self, underlying_player: Player) -> None:
415 """
416 Link any waiting derived protocol players that ride on the given player.
417
418 Called after a player is linked to a parent (or registered as a native
419 player), so derived protocol players that registered earlier can join
420 the same parent.
421 """
422 for candidate in self.all_players(return_protocol_players=True):
423 if candidate.underlying_player_id != underlying_player.player_id:
424 continue
425 if candidate.protocol_parent_id:
426 continue
427 self._try_link_derived_protocol(candidate)
428
429 def _schedule_protocol_evaluation(self, protocol_player: Player) -> None:
430 """
431 Schedule a delayed protocol evaluation.
432
433 Delays evaluation to allow other protocol players and native players to register.
434 Uses a longer delay (30s) if this protocol player was previously linked to a native
435 player that hasn't registered yet, giving native providers time to start up.
436 """
437 player_id = protocol_player.player_id
438
439 # Cancel any existing pending evaluation for this player
440 if player_id in self._pending_protocol_evaluations:
441 self._pending_protocol_evaluations[player_id].cancel()
442
443 # Check if this protocol player has a cached parent (was previously linked)
444 cached_parent_id = self._get_cached_protocol_parent_id(player_id)
445 if cached_parent_id and not self.get_player(cached_parent_id):
446 # Previously linked to a native player that hasn't registered yet
447 # Use longer delay to give native providers time to start up
448 delay = 45.0
449 self.logger.debug(
450 "Protocol player %s waiting for cached parent %s (45s delay)",
451 player_id,
452 cached_parent_id,
453 )
454 else:
455 # Standard delay for protocol player discovery
456 # Allows time for other protocols and native players to register
457 delay = 15.0
458
459 # Schedule evaluation after the delay
460 handle = self.mass.loop.call_later(
461 delay,
462 lambda: self.mass.create_task(self._delayed_protocol_evaluation(player_id)),
463 )
464 self._pending_protocol_evaluations[player_id] = handle
465
466 async def _delayed_protocol_evaluation(self, player_id: str) -> None:
467 """
468 Perform delayed protocol evaluation.
469
470 Called after a delay to allow all protocol players for a device to register.
471 Decides whether to create a universal player, join an existing one, or
472 promote a single protocol player directly.
473
474 Uses a shared lock to serialize evaluations - multiple protocol players from the
475 same device may trigger concurrent evaluations that would otherwise race each other.
476 """
477 self._pending_protocol_evaluations.pop(player_id, None)
478
479 async with self._delayed_evaluation_lock:
480 protocol_player = self.get_player(player_id)
481 if not protocol_player or protocol_player.protocol_parent_id:
482 return
483 if protocol_player.state.type != PlayerType.PROTOCOL:
484 # The player changed type while the evaluation was pending
485 # (e.g. re-registered as a regular player); it must never be
486 # linked as a protocol or wrapped in a universal player.
487 return
488
489 # Derived protocol players resolve strictly via their underlying
490 # player and never match by identifiers or wrap into universal
491 # players; they wait for _link_derived_protocols_of otherwise.
492 if protocol_player.underlying_player_id:
493 self._try_link_derived_protocol(protocol_player)
494 return
495
496 protocol_domain = protocol_player.provider.domain
497
498 # Re-try linking to an existing native/universal player
499 if self._try_link_to_existing_player(protocol_player, protocol_domain):
500 return
501
502 # Check if there's an existing universal player we should join
503 if existing_universal := self._find_matching_universal_player(protocol_player):
504 await self._add_protocol_to_existing_universal(
505 existing_universal, protocol_player, protocol_domain
506 )
507 if protocol_player.protocol_parent_id is not None:
508 return
509 # Link refused (domain duplicate) - fall through to create separate UP
510
511 # Refuse to create a universal player wrapper when the cached parent
512 # config exists but is disabled. The user explicitly turned the
513 # parent device off; surfacing its protocols as a separate player
514 # would defeat that intent.
515 cached_parent_id = self._get_cached_protocol_parent_id(player_id)
516 if cached_parent_id:
517 parent_raw = self.mass.config.get(f"{CONF_PLAYERS}/{cached_parent_id}")
518 if parent_raw and not parent_raw.get("enabled", True):
519 self.logger.debug(
520 "Skipping universal player creation for %s: cached parent %s is disabled",
521 player_id,
522 cached_parent_id,
523 )
524 return
525
526 # Find all protocol players that match this device's identifiers
527 matching_protocols = self._find_matching_protocol_players(protocol_player)
528
529 # Create or update UniversalPlayer for all protocol players
530 await self._create_or_update_universal_player(matching_protocols)
531
532 def _find_matching_protocol_players(self, protocol_player: Player) -> list[Player]:
533 """
534 Find all protocol players that match the same device as the given player.
535
536 Searches through all registered protocol players to find ones that share
537 identifiers (MAC, IP, UUID) with the given player, indicating they represent
538 the same physical device.
539 """
540 matching = [protocol_player]
541 protocol_domain = protocol_player.provider.domain
542
543 for other_player in self.all_players(return_protocol_players=True):
544 if other_player.player_id == protocol_player.player_id:
545 continue
546 if other_player.state.type != PlayerType.PROTOCOL:
547 continue
548 if other_player.protocol_parent_id:
549 continue
550 if other_player.underlying_player_id:
551 # Derived protocol players follow their underlying player
552 # once it is linked; they never seed a universal player.
553 continue
554 # Skip players from the same protocol domain
555 # Multiple instances of the same protocol on one host are separate players
556 if other_player.provider.domain == protocol_domain:
557 continue
558 if self._identifiers_match(protocol_player, other_player):
559 matching.append(other_player)
560
561 return matching
562
563 def _find_matching_universal_player(self, protocol_player: Player) -> Player | None:
564 """Find an existing universal player that matches this protocol player."""
565 for player in self._players.values():
566 if not isinstance(player, UniversalPlayer):
567 continue
568 if self._identifiers_match(protocol_player, player, ""):
569 return player
570 return None
571
572 async def _add_protocol_to_existing_universal(
573 self, universal_player: Player, protocol_player: Player, protocol_domain: str
574 ) -> None:
575 """Add a protocol player to an existing universal player."""
576 # Refuse if the universal player already has a registered player from this domain.
577 # This prevents a second instance (e.g., two snapcast players on the same host)
578 # from replacing the first. The caller falls through to create a separate UP.
579 for link in universal_player.linked_output_protocols:
580 if link.protocol_domain == protocol_domain and self.get_player(link.output_protocol_id):
581 return
582
583 self._add_protocol_link(universal_player, protocol_player, protocol_domain)
584
585 # Check if linking actually succeeded (may be refused for duplicate domain)
586 if not protocol_player.protocol_parent_id:
587 return
588
589 if isinstance(universal_player, UniversalPlayer):
590 universal_player.add_protocol_player(protocol_player.player_id)
591 for conn_type, value in protocol_player.device_info.identifiers.items():
592 universal_player.device_info.add_identifier(conn_type, value)
593 # Update model/manufacturer if universal player has generic values
594 self._update_universal_device_info(universal_player, protocol_player)
595
596 # Persist all player data (protocol IDs, identifiers, device info) to config
597 for provider in self.mass.get_providers(ProviderType.PLAYER):
598 if provider.domain == "universal_player":
599 await cast("UniversalPlayerProvider", provider)._save_player_data(
600 universal_player.player_id, universal_player
601 )
602 break
603
604 # Check if this universal player should now be merged with another
605 self._check_merge_universal_players(universal_player)
606
607 protocol_player.refresh_state()
608 universal_player.refresh_state()
609
610 def _update_universal_device_info(
611 self, universal_player: UniversalPlayer, protocol_player: Player
612 ) -> None:
613 """
614 Update universal player's device info from protocol player if needed.
615
616 A universal player carries generic placeholder device info
617 (model="Universal Player", manufacturer="Music Assistant") when no real
618 values are known for it (yet). This method updates those values from a
619 protocol player that has real device info.
620 """
621 # Check if universal player has generic placeholder device info
622 device_info = universal_player.device_info
623 protocol_info = protocol_player.device_info
624
625 # Update model if universal player has generic value
626 if device_info.model in (None, "Universal Player") and protocol_info.model:
627 device_info.model = protocol_info.model
628
629 # Update manufacturer if universal player has generic value
630 if device_info.manufacturer in (None, "Music Assistant") and protocol_info.manufacturer:
631 device_info.manufacturer = protocol_info.manufacturer
632
633 def _save_universal_player_data(self, universal_player: UniversalPlayer) -> None:
634 """
635 Save universal player data to config via background task.
636
637 This is a helper to persist player data from synchronous code.
638 """
639
640 async def _do_save() -> None:
641 for provider in self.mass.get_providers(ProviderType.PLAYER):
642 if provider.domain == "universal_player":
643 await cast("UniversalPlayerProvider", provider)._save_player_data(
644 universal_player.player_id, universal_player
645 )
646 break
647
648 self.mass.create_task(_do_save())
649
650 def _get_known_protocol_ids(self, parent: Player) -> list[str]:
651 """
652 Get all protocol IDs tracked for a parent player.
653
654 Includes both active links and cached/inactive protocol IDs so callers can
655 safely migrate or clean up the full parent/protocol relationship.
656 """
657 result: list[str] = []
658 seen: set[str] = set()
659
660 for linked in parent.linked_output_protocols:
661 if linked.output_protocol_id not in seen:
662 result.append(linked.output_protocol_id)
663 seen.add(linked.output_protocol_id)
664
665 if parent.provider.domain == "universal_player" and isinstance(parent, UniversalPlayer):
666 cached_ids = parent._protocol_player_ids
667 else:
668 cached_ids = self._get_cached_protocol_ids(parent.player_id)
669
670 for protocol_id in cached_ids:
671 if protocol_id not in seen:
672 result.append(protocol_id)
673 seen.add(protocol_id)
674
675 return result
676
677 def _migrate_protocol_ids_to_parent(self, parent: Player, protocol_ids: set[str]) -> None:
678 """
679 Persist protocol ownership on a new parent without requiring active links.
680
681 This is used when protocol ownership moves from one parent to another during
682 promotion/merge flows. Active protocols are already linked in memory, but
683 disabled or temporarily unavailable protocols still need their cached parent
684 relationship moved as well.
685 """
686 if not protocol_ids:
687 return
688
689 if parent.provider.domain == "universal_player" and isinstance(parent, UniversalPlayer):
690 for protocol_id in protocol_ids:
691 parent.add_protocol_player(protocol_id)
692 self._save_universal_player_data(parent)
693 else:
694 conf_key = f"{CONF_PLAYERS}/{parent.player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
695 cached_ids = self._get_cached_protocol_ids(parent.player_id)
696 changed = False
697 for protocol_id in protocol_ids:
698 if protocol_id not in cached_ids:
699 cached_ids.append(protocol_id)
700 changed = True
701 if changed:
702 self.mass.config.set(conf_key, cached_ids)
703
704 for protocol_id in protocol_ids:
705 if self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}"):
706 self._save_protocol_parent_id(protocol_id, parent.player_id)
707
708 def _remove_protocol_ids_from_parent(self, parent: Player, protocol_ids: set[str]) -> None:
709 """
710 Remove protocol ownership from a parent before it is permanently cleaned up.
711
712 This prevents `_cleanup_protocol_links` from treating already-migrated
713 protocols as orphaned when the obsolete parent is unregistered.
714 """
715 if not protocol_ids:
716 return
717
718 remaining_links = [
719 link
720 for link in parent.linked_output_protocols
721 if link.output_protocol_id not in protocol_ids
722 ]
723 if len(remaining_links) != len(parent.linked_output_protocols):
724 parent.set_linked_output_protocols(remaining_links)
725
726 if parent.provider.domain == "universal_player" and isinstance(parent, UniversalPlayer):
727 for protocol_id in protocol_ids:
728 parent.remove_protocol_player(protocol_id)
729 if self.mass.config.get(f"{CONF_PLAYERS}/{parent.player_id}"):
730 self.mass.config.set(
731 f"{CONF_PLAYERS}/{parent.player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
732 parent._protocol_player_ids,
733 )
734 else:
735 for protocol_id in protocol_ids:
736 self._remove_protocol_id_from_cache(parent.player_id, protocol_id)
737
738 def _check_merge_universal_players(self, universal_player: UniversalPlayer) -> None:
739 """
740 Check if another universal player should be merged into this one.
741
742 Called after identifiers are copied from a protocol player to a universal player.
743 When a protocol player brings new identifiers (e.g., MAC from ARP enrichment),
744 the universal player may now match another universal player that was created
745 from a different protocol (e.g., DLNA-based universal player now matches
746 AirPlay-based universal player because they share the same MAC address).
747
748 The oldest universal player absorbs the other one. Only one merge is performed
749 per call; re-evaluation will catch cascading merges.
750 """
751 if not (match := self._find_mergeable_universal_player(universal_player)):
752 return
753 keep, remove = self._select_merge_winner(universal_player, match)
754 self.logger.info(
755 "Merging universal player %s into %s (shared identifiers)",
756 remove.player_id,
757 keep.player_id,
758 )
759 self._merge_universal_players(keep, remove)
760
761 def _find_mergeable_universal_player(
762 self, universal_player: UniversalPlayer
763 ) -> UniversalPlayer | None:
764 """
765 Return the first other universal player that represents the same device, if any.
766
767 Players that share a protocol domain are not returned: they are separate devices
768 that merely share an identifier, so merging them would orphan a protocol.
769
770 :param universal_player: The universal player to find a merge candidate for.
771 """
772 for player in list(self._players.values()):
773 if player.provider.domain != "universal_player":
774 continue
775 if player.player_id == universal_player.player_id:
776 continue
777 if not isinstance(player, UniversalPlayer):
778 continue
779
780 if not self._identifiers_match(universal_player, player, ""):
781 continue
782
783 # Do not merge if both UPs have protocols from the same domain.
784 # Multiple instances of the same protocol on one host (e.g., several
785 # squeezelite players on the same VM) are separate devices that happen
786 # to share an IP. Merging them would orphan one instance's protocol.
787 domains_a = {
788 link.protocol_domain
789 for link in universal_player.linked_output_protocols
790 if link.protocol_domain
791 }
792 domains_b = {
793 link.protocol_domain
794 for link in player.linked_output_protocols
795 if link.protocol_domain
796 }
797 if domains_a & domains_b:
798 self.logger.debug(
799 "Skipping merge of %s and %s: shared protocol domain(s) %s",
800 universal_player.player_id,
801 player.player_id,
802 domains_a & domains_b,
803 )
804 continue
805
806 return player
807
808 return None
809
810 def _select_merge_winner(
811 self, universal_player: UniversalPlayer, other_player: UniversalPlayer
812 ) -> tuple[UniversalPlayer, UniversalPlayer]:
813 """
814 Return which of the two universal players absorbs the other, as (keeper, absorbed).
815
816 The oldest player wins, on a tie the one with the most protocol links.
817
818 :param universal_player: The universal player the merge was triggered for.
819 :param other_player: The universal player it matched with.
820 """
821 # The outcome must be stable across server restarts. Without that, which
822 # UniversalPlayer "wins" depends on iteration order of self._players.values(),
823 # which can shift between runs and causes downstream player_id reshuffling
824 # (and broken entity bindings in consumers like the Home Assistant MA integration).
825 if self._merge_rank(universal_player) <= self._merge_rank(other_player):
826 return universal_player, other_player
827 return other_player, universal_player
828
829 def _merge_rank(self, universal_player: Player) -> tuple[int, int, str]:
830 """
831 Return the sort key of a universal player in a merge, lowest wins.
832
833 Age comes first because the keeper's player id is the one that survives, and the
834 oldest player is the one API consumers have been bound to the longest. The links
835 of the absorbed player move over either way, so nothing is lost by keeping the
836 smaller one. A player from before player ids were minted has no stored moment and
837 counts as the oldest, which is exactly what it is.
838 """
839 created_at = self.mass.config.get(
840 f"{CONF_PLAYERS}/{universal_player.player_id}/values/{CONF_CREATED_AT}", 0
841 )
842 return (
843 created_at if isinstance(created_at, int) else 0,
844 -len(universal_player.linked_output_protocols),
845 universal_player.player_id,
846 )
847
848 def _merge_universal_players(self, keep: UniversalPlayer, remove: UniversalPlayer) -> None:
849 """
850 Absorb one universal player into another and schedule the absorbed one's removal.
851
852 :param keep: The universal player that stays.
853 :param remove: The universal player that is absorbed.
854 """
855 known_protocol_ids = set(self._get_known_protocol_ids(remove))
856 active_protocol_ids = {link.output_protocol_id for link in remove.linked_output_protocols}
857 moved_protocol_ids: set[str] = set()
858
859 # Transfer protocol links from the removed player to the keeper
860 for linked in list(remove.linked_output_protocols):
861 if protocol_player := self.get_player(linked.output_protocol_id):
862 protocol_player.set_protocol_parent_id(None)
863 domain = linked.protocol_domain or protocol_player.provider.domain
864
865 # Check if keeper already has an active link from this domain
866 if self._parent_has_active_protocol_from_domain(keep, domain):
867 self.logger.debug(
868 "Skipping duplicate %s link during merge: %s",
869 domain,
870 linked.output_protocol_id,
871 )
872 continue
873
874 self._add_protocol_link(keep, protocol_player, domain)
875 if protocol_player.protocol_parent_id == keep.player_id:
876 moved_protocol_ids.add(protocol_player.player_id)
877 protocol_player.refresh_state()
878
879 # Move cached-only protocol ownership as well so old-parent cleanup
880 # does not wipe protocols that were intentionally preserved.
881 cached_only_ids = known_protocol_ids - active_protocol_ids
882 preserved_protocol_ids = moved_protocol_ids | cached_only_ids
883 self._migrate_protocol_ids_to_parent(keep, preserved_protocol_ids)
884 self._remove_protocol_ids_from_parent(remove, preserved_protocol_ids)
885
886 # Merge identifiers
887 for conn_type, value in remove.device_info.identifiers.items():
888 keep.device_info.add_identifier(conn_type, value)
889 keep.refresh_state()
890
891 # Persist updated data before the obsolete player is removed
892 self._save_universal_player_data(keep)
893
894 # Carry over the user's configuration and re-point group memberships
895 # before the permanent removal below deletes the losing wrapper's config
896 self._migrate_universal_player_config(remove.player_id, keep.player_id)
897 self._update_group_memberships(remove.player_id, keep.player_id)
898
899 # Stop playback and remove the obsolete player
900 self.mass.create_task(self._stop_and_unregister(remove, keep.player_id))
901
902 def _link_protocols_to_universal(
903 self, universal_player: Player, protocol_players: list[Player]
904 ) -> None:
905 """Link protocol players to a universal player, cleaning up existing links."""
906 for player in protocol_players:
907 # Clean up if linked to another player
908 if player.protocol_parent_id:
909 if parent := self.get_player(player.protocol_parent_id):
910 self._remove_protocol_link(parent, player.player_id)
911 player.set_protocol_parent_id(None)
912 # Link to universal player
913 self._add_protocol_link(universal_player, player, player.provider.domain)
914 player.refresh_state()
915
916 # Update availability from protocol players
917 universal_player.refresh_state()
918
919 async def _create_or_update_universal_player(self, protocol_players: list[Player]) -> None:
920 """
921 Create or update a UniversalPlayer for a set of protocol players.
922
923 Delegates to the universal player provider which handles orchestration,
924 locking, and player creation. The controller then links the protocols
925 to the universal player.
926 """
927 # Filter out players that got linked during the async delay
928 protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
929 if not protocol_players:
930 return
931
932 # Get the universal_player provider
933 universal_provider: UniversalPlayerProvider | None = None
934 for provider in self.mass.get_providers(ProviderType.PLAYER):
935 if provider.domain == "universal_player":
936 universal_provider = cast("UniversalPlayerProvider", provider)
937 break
938
939 if not universal_provider:
940 return
941
942 # Delegate to provider - it handles locking, create/update decision, etc.
943 # It reports which universal player each protocol player belongs to, as a
944 # device with several instances of one protocol domain gets more than one.
945 assignments = await universal_provider.ensure_universal_players_for_protocols(
946 protocol_players
947 )
948
949 # Link the protocols to their universal player (the controller manages
950 # cross-provider state), skipping players that were linked in the meantime.
951 by_universal_player: dict[str, list[Player]] = {}
952 for player in protocol_players:
953 if player.protocol_parent_id:
954 continue
955 if universal_player := assignments.get(player.player_id):
956 by_universal_player.setdefault(universal_player.player_id, []).append(player)
957
958 for universal_player_id, players in by_universal_player.items():
959 if universal_player := self.get_player(universal_player_id):
960 self._link_protocols_to_universal(universal_player, players)
961
962 def _try_link_protocols_to_native(self, native_player: Player) -> None:
963 """Try to link protocol players to a native player."""
964 # First, check if there's a universal player for this device that should be replaced
965 self._check_replace_universal_player(native_player)
966
967 # Look for protocol players that should be linked
968 for protocol_player in self.all_players(return_protocol_players=True):
969 if protocol_player.state.type != PlayerType.PROTOCOL:
970 continue
971 if protocol_player.protocol_parent_id:
972 # Already linked to a parent (could be this native player after replacement)
973 continue
974 if protocol_player.underlying_player_id:
975 # Derived protocol players link via their underlying player instead
976 continue
977 if self._awaits_unregistered_owner(protocol_player, native_player.player_id):
978 continue
979
980 protocol_domain = protocol_player.provider.domain
981
982 # Skip if this native player already has an active link from this domain
983 # (prevents a second instance of the same protocol from trying to link)
984 if self._parent_has_active_protocol_from_domain(native_player, protocol_domain):
985 continue
986
987 if self._identifiers_match(native_player, protocol_player, protocol_domain):
988 self._add_protocol_link(native_player, protocol_player, protocol_domain)
989 # Check if linking succeeded (may be refused for duplicate domain)
990 if protocol_player.protocol_parent_id is not None:
991 protocol_player.refresh_state()
992 native_player.refresh_state()
993
994 # Proactively recover disabled/missing protocols from config
995 # This ensures disabled protocols show up in the UI so they can be re-enabled
996 self._recover_cached_protocol_links(native_player)
997
998 # Second pass: match remaining unlinked protocol players via sibling identifiers.
999 # After cache recovery, the native player has linked protocols (e.g., AirPlay)
1000 # whose identifiers can be used to match new protocol players (e.g., Sendspin bridge)
1001 # that share the same device identifiers but couldn't match the native player directly.
1002 for protocol_player in self.all_players(return_protocol_players=True):
1003 if protocol_player.state.type != PlayerType.PROTOCOL:
1004 continue
1005 if protocol_player.protocol_parent_id:
1006 continue
1007 if protocol_player.underlying_player_id:
1008 continue
1009 if self._awaits_unregistered_owner(protocol_player, native_player.player_id):
1010 continue
1011 protocol_domain = protocol_player.provider.domain
1012 if self._parent_has_active_protocol_from_domain(native_player, protocol_domain):
1013 continue
1014 if self._match_via_linked_protocols(native_player, protocol_player, protocol_domain):
1015 self.logger.debug(
1016 "Linked %s to %s via sibling protocol identifiers",
1017 protocol_player.player_id,
1018 native_player.player_id,
1019 )
1020
1021 # Finally, link derived protocol players that ride directly on this
1022 # native player (derived players riding on the protocol players linked
1023 # above are handled by _add_protocol_link itself).
1024 self._link_derived_protocols_of(native_player)
1025
1026 def _awaits_unregistered_owner(self, protocol_player: Player, candidate_parent_id: str) -> bool:
1027 """
1028 Check if a protocol player is reserved for a persisted owner that is still starting up.
1029
1030 :param protocol_player: The unlinked protocol player.
1031 :param candidate_parent_id: The player that wants to claim it.
1032 """
1033 owner_id = self._get_cached_protocol_parent_id(protocol_player.player_id)
1034 if owner_id is None or owner_id == candidate_parent_id:
1035 return False
1036 return self.get_player(owner_id) is None
1037
1038 def _check_replace_universal_player(self, native_player: Player) -> None:
1039 """Check if a universal player should be replaced by this native player."""
1040 # Skip if native_player is itself a universal player (prevent self-replacement)
1041 if native_player.provider.domain == "universal_player":
1042 return
1043
1044 # Look for universal players that match this native player
1045 for player in list(self._players.values()):
1046 if player.provider.domain != "universal_player":
1047 continue
1048
1049 # Check by identifiers first
1050 identifiers_match = self._identifiers_match(native_player, player, "")
1051
1052 # Also check if native player's ID is in the universal player's stored protocol list
1053 # This handles players that changed type (e.g., sendspin web players changed from
1054 # PROTOCOL to PLAYER type) and have no identifiers to match against
1055 player_id_in_protocols = (
1056 isinstance(player, UniversalPlayer)
1057 and native_player.player_id in player._protocol_player_ids
1058 )
1059
1060 if not identifiers_match and not player_id_in_protocols:
1061 continue
1062
1063 known_protocol_ids = set(self._get_known_protocol_ids(player))
1064 refused_protocol_ids: set[str] = set()
1065 moved_protocol_ids: set[str] = set()
1066
1067 # Transfer the protocol links from the universal player to the native player.
1068 # A derived protocol rides on another output, so a base and everything riding
1069 # on it can only move together: refusing one of them holds back the group.
1070 for group in self._group_protocol_links(player):
1071 domains = {
1072 protocol_player.player_id: linked.protocol_domain
1073 or protocol_player.provider.domain
1074 for linked, protocol_player in group
1075 }
1076 if any(
1077 self._parent_has_active_protocol_from_domain(
1078 native_player, domain, exclude_player_id=protocol_id
1079 )
1080 for protocol_id, domain in domains.items()
1081 ):
1082 refused_protocol_ids.update(domains.keys())
1083 continue
1084 for _, protocol_player in group:
1085 protocol_player.set_protocol_parent_id(None)
1086 self._add_protocol_link(
1087 native_player, protocol_player, domains[protocol_player.player_id]
1088 )
1089 if protocol_player.protocol_parent_id != native_player.player_id:
1090 # Link refused, keep the protocol owned by the universal player.
1091 protocol_player.set_protocol_parent_id(player.player_id)
1092 refused_protocol_ids.add(protocol_player.player_id)
1093 continue
1094 protocol_player.refresh_state()
1095 moved_protocol_ids.add(protocol_player.player_id)
1096
1097 # A refused link leaves the universal player in charge, so only hand over what
1098 # actually moved: ownership that exists in config alone stays with it, which
1099 # keeps a protocol derived from a refused one with the parent it will link to.
1100 migrated_protocol_ids = (
1101 moved_protocol_ids if refused_protocol_ids else known_protocol_ids
1102 )
1103 # A device that kept its id across a type change lists itself here.
1104 # It must never become its own protocol, and it must also be dropped
1105 # from the obsolete universal player so the permanent cleanup below
1106 # doesn't treat it as an orphaned protocol (which would re-wrap the
1107 # native player in a fresh universal player).
1108 migrated_protocol_ids.discard(native_player.player_id)
1109 self._migrate_protocol_ids_to_parent(native_player, migrated_protocol_ids)
1110 self._remove_protocol_ids_from_parent(
1111 player, migrated_protocol_ids | {native_player.player_id}
1112 )
1113 native_player.refresh_state()
1114
1115 if refused_protocol_ids:
1116 # Registered protocols that the native player refused remain on the wrapper.
1117 continue
1118
1119 # Carry over the user's configuration and re-point group memberships
1120 # before the permanent removal below deletes the universal player's config
1121 self._migrate_universal_player_config(player.player_id, native_player.player_id)
1122 self._update_group_memberships(player.player_id, native_player.player_id)
1123
1124 # Stop playback and remove the now-obsolete universal player
1125 self.mass.create_task(self._stop_and_unregister(player, native_player.player_id))
1126
1127 def _group_protocol_links(
1128 self, parent: Player
1129 ) -> list[list[tuple[LinkedOutputProtocol, Player]]]:
1130 """
1131 Group a parent's registered protocol links with the protocols riding on them.
1132
1133 Each group holds one base protocol followed by the derived protocols that ride
1134 on it. A protocol whose underlying player is not one of the parent's own links
1135 forms a group of its own.
1136
1137 :param parent: The parent player whose protocol links should be grouped.
1138 """
1139 registered = [
1140 (linked, protocol_player)
1141 for linked in parent.linked_output_protocols
1142 if (protocol_player := self.get_player(linked.output_protocol_id))
1143 ]
1144 link_ids = {protocol_player.player_id for _, protocol_player in registered}
1145 riders: dict[str, list[tuple[LinkedOutputProtocol, Player]]] = {}
1146 bases: list[tuple[LinkedOutputProtocol, Player]] = []
1147 for linked, protocol_player in registered:
1148 underlying_id = protocol_player.underlying_player_id
1149 if underlying_id and underlying_id in link_ids:
1150 riders.setdefault(underlying_id, []).append((linked, protocol_player))
1151 else:
1152 bases.append((linked, protocol_player))
1153
1154 groups = [
1155 [(linked, protocol_player), *riders.get(protocol_player.player_id, [])]
1156 for linked, protocol_player in bases
1157 ]
1158 # A derived protocol riding on another derived protocol has no base group here,
1159 # so it moves on its own rather than being dropped from the transfer.
1160 grouped_ids = {
1161 protocol_player.player_id for group in groups for _, protocol_player in group
1162 }
1163 groups.extend([entry] for entry in registered if entry[1].player_id not in grouped_ids)
1164 return groups
1165
1166 def _migrate_universal_player_config(self, universal_id: str, native_id: str) -> None:
1167 """
1168 Carry over user-set configuration from a replaced universal player.
1169
1170 Copies the custom display name, player config values, DSP settings and
1171 per-queue settings of the (obsolete) universal player onto the native
1172 player that replaces it, without overwriting values explicitly set on
1173 the native player itself. Must be called while the universal player's
1174 config still exists, as the permanent removal deletes it.
1175
1176 :param universal_id: Player id of the obsolete universal player being replaced.
1177 :param native_id: Player id of the native player that replaces it.
1178 """
1179 source_raw = self.mass.config.get(f"{CONF_PLAYERS}/{universal_id}")
1180 source_raw = source_raw if isinstance(source_raw, dict) else {}
1181 target_key = f"{CONF_PLAYERS}/{native_id}"
1182 target_raw = self.mass.config.get(target_key)
1183 target_raw = target_raw if isinstance(target_raw, dict) else {}
1184 player_config_changed = False
1185
1186 # only carry an actual user rename, not the auto-generated default name;
1187 # likewise a name on the native player only counts as a user override when
1188 # it differs from the default name
1189 custom_name = source_raw.get("name")
1190 target_name = target_raw.get("name")
1191 target_has_custom_name = bool(target_name) and target_name != target_raw.get("default_name")
1192 if (
1193 custom_name
1194 and custom_name != source_raw.get("default_name")
1195 and not target_has_custom_name
1196 ):
1197 self.mass.config.set(f"{target_key}/name", custom_name)
1198 player_config_changed = True
1199
1200 source_values = source_raw.get("values")
1201 source_values = source_values if isinstance(source_values, dict) else {}
1202 target_values = target_raw.get("values")
1203 target_values = target_values if isinstance(target_values, dict) else {}
1204 for key, value in source_values.items():
1205 if key in UNIVERSAL_PLAYER_INTERNAL_CONF_KEYS:
1206 continue
1207 if CONF_PROTOCOL_KEY_SPLITTER in key:
1208 # stale virtual mirror of a protocol player's own config
1209 continue
1210 if key in target_values:
1211 continue
1212 self.mass.config.set(f"{target_key}/values/{key}", deepcopy(value))
1213 player_config_changed = True
1214
1215 # DSP settings follow wholesale, unless the native player has its own
1216 dsp_changed = False
1217 source_dsp = self.mass.config.get(f"{CONF_PLAYER_DSP}/{universal_id}")
1218 if source_dsp and not self.mass.config.get(f"{CONF_PLAYER_DSP}/{native_id}"):
1219 self.mass.config.set(f"{CONF_PLAYER_DSP}/{native_id}", deepcopy(source_dsp))
1220 dsp_changed = True
1221
1222 queue_changed = self._migrate_universal_queue_config(universal_id, native_id)
1223
1224 if not (player_config_changed or dsp_changed or queue_changed):
1225 return
1226 self.logger.info(
1227 "Carried over configuration of universal player %s to %s", universal_id, native_id
1228 )
1229 if player_config_changed:
1230 # the native player's in-place config was loaded before the carry-over,
1231 # so reload it to make the migrated values (e.g. custom name) effective
1232 self.mass.create_task(self._reapply_player_config(native_id))
1233
1234 def _migrate_universal_queue_config(self, universal_id: str, native_id: str) -> bool:
1235 """
1236 Move the per-queue settings of a replaced universal player to its replacement.
1237
1238 Queue ids equal player ids, so the source entry is removed once it is carried over.
1239
1240 :param universal_id: Player id of the obsolete universal player.
1241 :param native_id: Player id of the native player that replaces it.
1242 :return: True if any queue setting was carried over.
1243 """
1244 queue_changed = False
1245 source_queue_raw = self.mass.config.get(f"{CONF_PLAYER_QUEUES}/{universal_id}")
1246 source_queue_raw = source_queue_raw if isinstance(source_queue_raw, dict) else None
1247 if source_queue_values := (source_queue_raw or {}).get("values"):
1248 target_queue_key = f"{CONF_PLAYER_QUEUES}/{native_id}"
1249 target_queue_raw = self.mass.config.get(target_queue_key)
1250 target_queue_raw = (
1251 deepcopy(target_queue_raw) if isinstance(target_queue_raw, dict) else {}
1252 )
1253 target_queue_values = target_queue_raw.setdefault("values", {})
1254 for key, value in source_queue_values.items():
1255 if key in target_queue_values:
1256 continue
1257 target_queue_values[key] = deepcopy(value)
1258 queue_changed = True
1259 if queue_changed:
1260 target_queue_raw["queue_id"] = native_id
1261 self.mass.config.set(target_queue_key, target_queue_raw)
1262 if source_queue_raw is not None:
1263 self.mass.config.remove(f"{CONF_PLAYER_QUEUES}/{universal_id}")
1264 return queue_changed
1265
1266 async def _reapply_player_config(self, player_id: str) -> None:
1267 """Reload the stored config onto a registered player and refresh its state."""
1268 if not (player := self.get_player(player_id)):
1269 return
1270 config = await self.mass.config.get_player_config(player_id)
1271 player.set_config(config)
1272 player.update_state()
1273 self.mass.signal_event(EventType.PLAYER_CONFIG_UPDATED, object_id=player_id, data=config)
1274
1275 def _update_group_memberships(self, old_player_id: str, new_player_id: str | None) -> None:
1276 """
1277 Hand a removed player's group memberships over to its successor, or drop them.
1278
1279 Other players that list the removed player as a group member (or allowed
1280 member) must follow its successor so those memberships are not silently
1281 lost. Without a successor the player is gone for good and its id is dropped
1282 from the group members instead, so it can not linger in a group and pull a
1283 device that returns under the same id back in. Its allow-list entry is left
1284 alone there, since an allow-list that runs empty stops restricting at all.
1285 Updates the persisted config and keeps any registered player whose
1286 membership changed in sync.
1287
1288 :param old_player_id: Player id that is being removed.
1289 :param new_player_id: Player id that replaces it, or None if there is none.
1290 """
1291 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
1292 if not isinstance(all_player_configs, dict):
1293 return
1294 for other_id, other_cfg in all_player_configs.items():
1295 if not isinstance(other_cfg, dict):
1296 continue
1297 other_values = other_cfg.get("values")
1298 if not isinstance(other_values, dict):
1299 continue
1300 other_player = self.get_player(other_id)
1301 changed = False
1302 for key in (CONF_GROUP_MEMBERS, CONF_ALLOWED_MEMBERS):
1303 members = other_values.get(key)
1304 if not isinstance(members, list) or old_player_id not in members:
1305 continue
1306 if new_player_id is None and key == CONF_ALLOWED_MEMBERS:
1307 # an allow-list that runs empty reads as "everyone may join", so the
1308 # entry of a removed player stays: it can never join again anyway
1309 continue
1310 new_members: list[str] = []
1311 for member_id in members:
1312 resolved = new_player_id if member_id == old_player_id else member_id
1313 if resolved is not None and resolved not in new_members:
1314 new_members.append(resolved)
1315 self.mass.config.set(f"{CONF_PLAYERS}/{other_id}/values/{key}", new_members)
1316 changed = True
1317 # keep a registered player's in-place config copy in sync
1318 if other_player and (entry := other_player.config.values.get(key)):
1319 entry.value = new_members
1320 if changed and other_player:
1321 self.mass.create_task(self._reload_group_members(other_player))
1322
1323 async def _reload_group_members(self, player: Player) -> None:
1324 """
1325 Let a group re-read its member config so its live member list follows along.
1326
1327 :param player: The group player whose stored member list changed.
1328 """
1329 await player.on_config_updated()
1330 player.refresh_state()
1331
1332 async def _stop_and_unregister(self, player: Player, replacement_player_id: str) -> None:
1333 """
1334 Stop active playback on a player and then permanently unregister it.
1335
1336 Used when an obsolete universal player is replaced or merged away: while
1337 it is not idle its protocol child keeps playing the dead queue's stream
1338 until the buffer drains, so playback is stopped first. Queue ownership is
1339 intentionally not transferred.
1340
1341 :param player: The obsolete player to stop and permanently remove.
1342 :param replacement_player_id: Player ID that takes the obsolete player's place.
1343 """
1344 if player.playback_state != PlaybackState.IDLE:
1345 with suppress(PlayerCommandFailed, PlayerUnavailableError):
1346 await self.mass.player_queues.stop(player.player_id)
1347 await self.unregister(
1348 player.player_id, permanent=True, replacement_player_id=replacement_player_id
1349 )
1350
1351 def _parent_has_active_protocol_from_domain(
1352 self, parent: Player, domain: str, exclude_player_id: str | None = None
1353 ) -> bool:
1354 """
1355 Check if a parent already has an active (registered) protocol player from a given domain.
1356
1357 This prevents a second protocol player of the same domain (e.g., a second AirPlay
1358 instance on the same host) from replacing the first one's link on the same parent.
1359
1360 :param parent: The parent player to check.
1361 :param domain: The protocol domain to check for (e.g., "airplay", "dlna").
1362 :param exclude_player_id: Optional player ID to exclude from the check
1363 (used when checking if a player's own domain is already linked).
1364 """
1365 for link in parent.linked_output_protocols:
1366 if link.protocol_domain != domain:
1367 continue
1368 if exclude_player_id and link.output_protocol_id == exclude_player_id:
1369 continue
1370 # A registered player from this domain blocks the link, even if unavailable.
1371 # Being offline doesn't make it a different device — it's still occupying
1372 # this domain slot. The provider should remove stale players explicitly.
1373 if self.get_player(link.output_protocol_id):
1374 return True
1375 return False
1376
1377 def _add_protocol_link(
1378 self, native_player: Player, protocol_player: Player, protocol_domain: str
1379 ) -> None:
1380 """Add a protocol link from native player to protocol player."""
1381 # Never link a player to itself (hides it as its own protocol child).
1382 if native_player.player_id == protocol_player.player_id:
1383 return
1384 # Guard: refuse to replace an existing active link from the same domain.
1385 # This prevents a second instance of the same protocol (e.g., two AirPlay
1386 # instances on the same host) from silently replacing the first one.
1387 if self._parent_has_active_protocol_from_domain(
1388 native_player, protocol_domain, exclude_player_id=protocol_player.player_id
1389 ):
1390 self.logger.debug(
1391 "Refusing to link %s to %s: parent already has an active %s link",
1392 protocol_player.player_id,
1393 native_player.player_id,
1394 protocol_domain,
1395 )
1396 return
1397
1398 # Remove any existing link for the same protocol domain
1399 updated_protocols = [
1400 link
1401 for link in native_player.linked_output_protocols
1402 if link.protocol_domain != protocol_domain
1403 ]
1404
1405 # Get priority for this protocol
1406 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
1407
1408 # Derived transports (e.g. a Sendspin bridge riding on an AirPlay player)
1409 # reference the base output they run on top of; "native" when they ride
1410 # on the parent player itself
1411 derived_from = protocol_player.underlying_player_id
1412 if derived_from == native_player.player_id:
1413 derived_from = "native"
1414
1415 # Add the new link
1416 updated_protocols.append(
1417 LinkedOutputProtocol(
1418 output_protocol_id=protocol_player.player_id,
1419 protocol_domain=protocol_domain,
1420 priority=priority,
1421 derived_from=derived_from,
1422 )
1423 )
1424 native_player.set_linked_output_protocols(updated_protocols)
1425
1426 # Set protocol player's parent
1427 protocol_player.set_protocol_parent_id(native_player.player_id)
1428 # Ownership is exclusive: a parent that still lists this protocol would show it
1429 # twice and hold its domain slot against a genuine protocol of that domain.
1430 self._evict_protocol_from_other_parents(protocol_player.player_id, native_player.player_id)
1431
1432 # Persist linked protocol IDs to config for fast restart
1433 # (only for non-universal players, as universal players handle this themselves)
1434 if native_player.provider.domain != "universal_player":
1435 self._save_linked_protocol_ids(native_player)
1436 # Always save the parent ID on the protocol player for reverse lookup on restart
1437 # (needed for both native and universal parents to enable fast restore)
1438 self._save_protocol_parent_id(protocol_player.player_id, native_player.player_id)
1439
1440 # The freshly linked player may have derived protocol players (e.g. a
1441 # Sendspin bridge riding on it) waiting to join the same parent.
1442 self._link_derived_protocols_of(protocol_player)
1443
1444 def _remove_protocol_link(
1445 self, native_player: Player, protocol_player_id: str, permanent: bool = False
1446 ) -> None:
1447 """
1448 Remove a protocol link.
1449
1450 :param native_player: The parent player to remove the link from.
1451 :param protocol_player_id: The protocol player ID to unlink.
1452 :param permanent: If True, also removes the protocol ID from the cached list.
1453 Use this when the protocol player config is being deleted. If False,
1454 the protocol ID remains in the cache so it can be shown as disabled
1455 and re-enabled later.
1456 """
1457 updated_protocols = [
1458 link
1459 for link in native_player.linked_output_protocols
1460 if link.output_protocol_id != protocol_player_id
1461 ]
1462 native_player.set_linked_output_protocols(updated_protocols)
1463
1464 # Clear parent reference on protocol player if it still exists
1465 if protocol_player := self.get_player(protocol_player_id):
1466 if protocol_player.protocol_parent_id == native_player.player_id:
1467 protocol_player.set_protocol_parent_id(None)
1468
1469 # Update persisted linked protocol IDs
1470 if native_player.provider.domain != "universal_player":
1471 if permanent:
1472 # Permanently remove from cache (player config is being deleted)
1473 self._remove_protocol_id_from_cache(native_player.player_id, protocol_player_id)
1474 # Note: we don't call _save_linked_protocol_ids here anymore for non-permanent
1475 # removals because the merge approach will preserve the ID in the cache
1476 # Always clear the cached parent ID (for both native and universal parents)
1477 self._clear_protocol_parent_id(protocol_player_id)
1478
1479 def _evict_protocol_from_other_parents(self, protocol_player_id: str, parent_id: str) -> None:
1480 """
1481 Drop a protocol's output entry from every parent except the one that owns it.
1482
1483 A parent that still holds an active entry while another parent takes the protocol
1484 is out of date, so its stored ownership is dropped as well. Parents that already gave
1485 up the active entry keep theirs and can still offer the protocol for re-enabling.
1486
1487 :param protocol_player_id: Player id of the protocol player that got a new parent.
1488 :param parent_id: Player id of the parent that now owns it.
1489 """
1490 for player in list(self._players.values()):
1491 if player.player_id == parent_id:
1492 continue
1493 if not any(
1494 link.output_protocol_id == protocol_player_id
1495 for link in player.linked_output_protocols
1496 ):
1497 continue
1498 self._remove_protocol_ids_from_parent(player, {protocol_player_id})
1499 self.logger.debug(
1500 "Removed stale output %s from %s: it is owned by %s",
1501 protocol_player_id,
1502 player.player_id,
1503 parent_id,
1504 )
1505
1506 def _save_linked_protocol_ids(self, native_player: Player) -> None:
1507 """
1508 Save linked protocol IDs to config for persistence across restarts.
1509
1510 This method merges active protocol IDs with existing cached IDs to preserve
1511 disabled protocol players in the cache. This allows disabled protocols to be
1512 shown in the UI so they can be re-enabled.
1513 """
1514 conf_key = f"{CONF_PLAYERS}/{native_player.player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
1515 # Get existing cached IDs to preserve disabled protocols
1516 existing_ids: list[str] = self.mass.config.get(conf_key, [])
1517 # Get currently active protocol IDs
1518 active_ids = {link.output_protocol_id for link in native_player.linked_output_protocols}
1519 # Merge: keep existing IDs and add any new active ones
1520 merged_ids = list(existing_ids)
1521 for protocol_id in active_ids:
1522 if protocol_id not in merged_ids:
1523 merged_ids.append(protocol_id)
1524 self.mass.config.set(conf_key, merged_ids)
1525
1526 def _get_cached_protocol_ids(self, player_id: str) -> list[str]:
1527 """Get cached linked protocol IDs from config."""
1528 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
1529 result = self.mass.config.get(conf_key, [])
1530 return list(result) if result else []
1531
1532 def _remove_protocol_id_from_cache(
1533 self, parent_player_id: str, protocol_player_id: str
1534 ) -> None:
1535 """
1536 Permanently remove a protocol player ID from the cached linked protocol IDs.
1537
1538 Use this when a protocol player config is being deleted, not just disabled.
1539 """
1540 conf_key = f"{CONF_PLAYERS}/{parent_player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
1541 cached_ids: list[str] = self.mass.config.get(conf_key, [])
1542 if protocol_player_id in cached_ids:
1543 cached_ids.remove(protocol_player_id)
1544 self.mass.config.set(conf_key, cached_ids)
1545
1546 def _save_protocol_parent_id(self, protocol_player_id: str, parent_id: str) -> None:
1547 """Save the parent ID for a protocol player for persistence across restarts."""
1548 # Only save if the player config still exists to avoid creating partial entries
1549 if not self.mass.config.get(f"{CONF_PLAYERS}/{protocol_player_id}"):
1550 return
1551 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
1552 self.mass.config.set(conf_key, parent_id)
1553
1554 def _save_underlying_player_id(self, player: Player) -> None:
1555 """
1556 Persist the derived-transport edge of a player to config.
1557
1558 Allows the edge to be resolved (e.g. by the config UI) even while the
1559 player is not registered. Clears a previously persisted edge when the
1560 player is no longer derived (e.g. a bridge client turned web player).
1561 """
1562 # Only save if the player config still exists to avoid creating partial entries
1563 if not self.mass.config.get(f"{CONF_PLAYERS}/{player.player_id}"):
1564 return
1565 conf_key = f"{CONF_PLAYERS}/{player.player_id}/values/{CONF_UNDERLYING_PLAYER_ID}"
1566 if player.underlying_player_id:
1567 self.mass.config.set(conf_key, player.underlying_player_id)
1568 elif self.mass.config.get(conf_key) is not None:
1569 self.mass.config.set(conf_key, None)
1570
1571 def _get_cached_protocol_parent_id(self, protocol_player_id: str) -> str | None:
1572 """Get cached parent ID for a protocol player from config."""
1573 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
1574 result = self.mass.config.get(conf_key, None)
1575 return str(result) if result else None
1576
1577 def _clear_protocol_parent_id(self, protocol_player_id: str) -> None:
1578 """Clear the cached parent ID for a protocol player."""
1579 # Only clear if the player config still exists to avoid creating partial entries
1580 if not self.mass.config.get(f"{CONF_PLAYERS}/{protocol_player_id}"):
1581 return
1582 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
1583 self.mass.config.set(conf_key, None)
1584
1585 def _recover_cached_protocol_links(self, native_player: Player) -> None:
1586 """
1587 Recover protocol links from config for disabled/missing protocols.
1588
1589 This ensures that disabled protocols show up in the output_protocols list
1590 so they can be re-enabled by the user. It also handles the case where
1591 protocol players haven't registered yet during startup.
1592 """
1593 # Get currently linked protocol IDs
1594 linked_protocol_ids = {
1595 link.output_protocol_id for link in native_player.linked_output_protocols
1596 }
1597
1598 # Get cached protocol IDs from config (includes protocols that were explicitly linked)
1599 cached_protocol_ids = self._get_cached_protocol_ids(native_player.player_id)
1600
1601 # Also check all protocol players that have protocol_parent_id pointing to this player
1602 # (this handles disabled protocols that may not be in linked_protocol_ids)
1603 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
1604 for protocol_id, protocol_config in all_player_configs.items():
1605 # Skip if not a protocol player
1606 if protocol_config.get("player_type") != "protocol":
1607 continue
1608 # Check if this protocol has a parent_id pointing to this native player
1609 protocol_values = protocol_config.get("values", {})
1610 protocol_parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
1611 if protocol_parent_id == native_player.player_id:
1612 if protocol_id not in cached_protocol_ids:
1613 cached_protocol_ids.append(protocol_id)
1614
1615 if not cached_protocol_ids:
1616 return
1617
1618 # Add link entries for any cached protocols that aren't currently linked
1619 updated_protocols = list(native_player.linked_output_protocols)
1620 for protocol_id in cached_protocol_ids:
1621 if protocol_id in linked_protocol_ids:
1622 continue # Already linked
1623
1624 protocol_player = self.get_player(protocol_id)
1625 # A protocol that another parent owns is not ours to claim: it would show up
1626 # on both parents and occupy this parent's domain slot, keeping a genuine
1627 # protocol of that domain out. The live owner leads; a protocol that is still
1628 # waiting for its owner to register only has the persisted one. The cached id
1629 # is kept, so the protocol is recovered once its owner releases it.
1630 owner_id = protocol_player.protocol_parent_id if protocol_player else None
1631 if owner_id is None:
1632 owner_id = self._get_cached_protocol_parent_id(protocol_id)
1633 if owner_id is not None and owner_id != native_player.player_id:
1634 continue
1635
1636 # Get protocol player config to determine the protocol domain
1637 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
1638 if not protocol_config:
1639 continue
1640
1641 # Determine protocol domain from provider
1642 protocol_provider: str = protocol_config.get("provider")
1643 if not protocol_provider:
1644 continue
1645
1646 # Extract domain from provider instance_id (e.g., "airplay--uuid" -> "airplay")
1647 protocol_domain = protocol_provider.split("--", maxsplit=1)[0]
1648
1649 # Skip if parent already has a link from this domain
1650 existing_domains = {link.protocol_domain for link in updated_protocols}
1651 if protocol_domain in existing_domains:
1652 continue
1653
1654 # Get priority for this protocol
1655 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
1656
1657 # Resolve the derived-transport edge from the live player when
1658 # registered, else from the persisted edge in config
1659 derived_from = (
1660 protocol_player.underlying_player_id
1661 if protocol_player
1662 else protocol_config.get("values", {}).get(CONF_UNDERLYING_PLAYER_ID)
1663 )
1664 if derived_from == native_player.player_id:
1665 derived_from = "native"
1666
1667 updated_protocols.append(
1668 LinkedOutputProtocol(
1669 output_protocol_id=protocol_id,
1670 protocol_domain=protocol_domain,
1671 priority=priority,
1672 derived_from=derived_from,
1673 )
1674 )
1675 self.logger.debug(
1676 "Recovered cached protocol link %s -> %s",
1677 native_player.player_id,
1678 protocol_id,
1679 )
1680
1681 if len(updated_protocols) != len(native_player.linked_output_protocols):
1682 native_player.set_linked_output_protocols(updated_protocols)
1683
1684 def _cleanup_protocol_links(self, player: Player) -> None:
1685 """Clean up protocol links when a player is permanently removed."""
1686 if player.state.type == PlayerType.PROTOCOL:
1687 self._unlink_from_protocol_parent(player)
1688 return
1689 self._detach_owned_protocols(player)
1690
1691 def _unlink_from_protocol_parent(self, player: Player) -> None:
1692 """Release a protocol player from the parent it is attached to."""
1693 if parent_id := player.protocol_parent_id:
1694 if parent_player := self.get_player(parent_id):
1695 # Use permanent=True to also remove from cached protocol IDs
1696 self._remove_protocol_link(parent_player, player.player_id, permanent=True)
1697 if (
1698 parent_player.provider.domain == "universal_player"
1699 and len(parent_player.linked_output_protocols) == 0
1700 ):
1701 # No protocols left - the universal player has nothing to play
1702 # on. Its config is deliberately kept: the player id is opaque
1703 # and cannot be recreated, so deleting it here would orphan the
1704 # entities API consumers bound to it. Only an explicit removal
1705 # by the user deletes a universal player for good.
1706 self.logger.info(
1707 "Universal player %s has no protocols left",
1708 parent_id,
1709 )
1710 self.mass.create_task(self.mass.players.unregister(parent_id, permanent=False))
1711 else:
1712 parent_player.refresh_state()
1713 else:
1714 # Parent not registered yet — still purge the cached id
1715 self._remove_protocol_id_from_cache(parent_id, player.player_id)
1716
1717 def _detach_owned_protocols(self, player: Player) -> None:
1718 """Detach the protocol players a parent owns so they can find a new parent."""
1719 # collect the ids from both the active links and the cached state, since
1720 # disabled/inactive protocols may only exist in the cached parent data
1721 all_protocol_ids = set(self._get_known_protocol_ids(player))
1722 for protocol_id in all_protocol_ids:
1723 if protocol_player := self.get_player(protocol_id):
1724 # Protocol player is available: clear parent and schedule re-evaluation
1725 # so it can be matched to a new parent or a new universal player
1726 self.logger.debug(
1727 "Player %s no longer owns protocol %s - scheduling evaluation",
1728 player.player_id,
1729 protocol_id,
1730 )
1731 self._detach_protocol_child(protocol_player)
1732 else:
1733 # Clear cached parent ID in config so protocol won't try to
1734 # restore a link to its former parent on next restart
1735 self._clear_protocol_parent_id(protocol_id)
1736 # Protocol player is not registered yet — it may still be
1737 # mid-discovery (e.g., DLNA connecting via SSDP). Don't delete
1738 # its config as that would cause a KeyError when it finishes
1739 # registering. Stale configs are harmless and get cleaned up
1740 # naturally on subsequent restarts.
1741 self.logger.debug(
1742 "Player %s no longer owns protocol %s - not registered, skipping cleanup",
1743 player.player_id,
1744 protocol_id,
1745 )
1746
1747 def _cleanup_player_type_transition(self, existing: Player, *, becomes_protocol: bool) -> None:
1748 """
1749 Release the protocol topology a player owned before its type changed.
1750
1751 :param existing: The registered player instance for the changed player.
1752 :param becomes_protocol: True if the player moves into the protocol role,
1753 False if it leaves it.
1754 """
1755 if not becomes_protocol:
1756 # a provider may announce the new type with the live parent link already
1757 # dropped, so fall back to the persisted one to still reach the parent
1758 parent_id = existing.protocol_parent_id or self._get_cached_protocol_parent_id(
1759 existing.player_id
1760 )
1761 if not parent_id:
1762 return
1763 parent = self.get_player(parent_id)
1764 if parent is not None and parent.provider.domain == "universal_player":
1765 # drop only the active edge and leave the rest to the link evaluation,
1766 # which replaces the wrapper with this player: a leftover edge makes it
1767 # hand the player over to itself, which it refuses, abandoning the swap
1768 self._remove_protocol_link(parent, existing.player_id)
1769 return
1770 existing.set_protocol_parent_id(parent_id)
1771 # unlink at the parent and drop the persisted parent id, which would
1772 # otherwise heal the player's type back to protocol
1773 self._unlink_from_protocol_parent(existing)
1774 # a player leaving the protocol role has no parent, also when that parent
1775 # is not registered (anymore) and only the cached link could be cleaned up
1776 existing.set_protocol_parent_id(None)
1777 return
1778 # the player becomes a child itself: detach the protocol players it owned so they
1779 # can find a new parent, then give up their ownership in its (kept) config - the
1780 # reverse of the removal path, which drops the ownership before the detach
1781 protocol_ids = set(self._get_known_protocol_ids(existing))
1782 self._detach_owned_protocols(existing)
1783 self._remove_protocol_ids_from_parent(existing, protocol_ids)
1784
1785 def _detach_protocol_children(self, parent_id: str) -> None:
1786 """
1787 Detach the registered protocol players of a parent player that is going away.
1788
1789 Covers the removal paths that don't unregister the parent first (e.g. its
1790 provider is unloaded), where the parent is not around anymore to enumerate
1791 its protocol players.
1792
1793 :param parent_id: Player id of the parent that is being removed.
1794 """
1795 for protocol_player in list(self._players.values()):
1796 if protocol_player.state.type != PlayerType.PROTOCOL:
1797 continue
1798 # a protocol player waiting for a parent that never registered only has
1799 # the link in its config, so fall back to the cached parent
1800 linked_parent_id = protocol_player.protocol_parent_id or (
1801 self._get_cached_protocol_parent_id(protocol_player.player_id)
1802 )
1803 if linked_parent_id != parent_id:
1804 continue
1805 self.logger.debug(
1806 "Player %s removed - scheduling evaluation for protocol %s",
1807 parent_id,
1808 protocol_player.player_id,
1809 )
1810 self._detach_protocol_child(protocol_player)
1811
1812 def _detach_protocol_child(self, protocol_player: Player) -> None:
1813 """Clear a protocol player's parent link and schedule a fresh evaluation."""
1814 self._clear_protocol_parent_id(protocol_player.player_id)
1815 protocol_player.set_protocol_parent_id(None)
1816 protocol_player.refresh_state()
1817 self._schedule_protocol_evaluation(protocol_player)
1818
1819 def _identifiers_match(
1820 self, player_a: Player, player_b: Player, protocol_domain: str = ""
1821 ) -> bool:
1822 """
1823 Check if identifiers match between two players.
1824
1825 Matching is done by comparing connection identifiers (MAC, serial, UUID).
1826 As a last resort, IP address is used when at least one player has a
1827 locally-administered MAC, indicating the device uses MAC randomization
1828 and ARP could not resolve the real hardware address.
1829
1830 Invalid identifiers (e.g., 00:00:00:00:00:00 MAC addresses) are filtered out
1831 to prevent false matches between unrelated devices.
1832 """
1833 identifiers_a = player_a.device_info.identifiers
1834 identifiers_b = player_b.device_info.identifiers
1835
1836 # Check identifiers in order of reliability
1837 # MAC_ADDRESS > SERIAL_NUMBER > UUID > CAST_UUID > AIRPLAY_ID
1838 for conn_type in (
1839 IdentifierType.MAC_ADDRESS,
1840 IdentifierType.SERIAL_NUMBER,
1841 IdentifierType.UUID,
1842 IdentifierType.CAST_UUID,
1843 IdentifierType.AIRPLAY_ID,
1844 ):
1845 val_a = identifiers_a.get(conn_type)
1846 val_b = identifiers_b.get(conn_type)
1847
1848 if not val_a or not val_b:
1849 continue
1850
1851 # Filter out invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
1852 if conn_type == IdentifierType.MAC_ADDRESS:
1853 if not is_valid_mac_address(val_a) or not is_valid_mac_address(val_b):
1854 self.logger.log(
1855 VERBOSE_LOG_LEVEL,
1856 "Skipping invalid MAC address for matching: %s=%s, %s=%s",
1857 player_a.display_name,
1858 val_a,
1859 player_b.display_name,
1860 val_b,
1861 )
1862 continue
1863
1864 # Normalize values for comparison
1865 if conn_type == IdentifierType.MAC_ADDRESS:
1866 # Use MAC normalization that handles locally-administered bit differences
1867 # Some protocols (like AirPlay) report a locally-administered MAC variant
1868 # where bit 1 of the first octet is set (e.g., 54:78:... vs 56:78:...)
1869 val_a_norm = normalize_mac_for_matching(val_a)
1870 val_b_norm = normalize_mac_for_matching(val_b)
1871
1872 # Direct match on current MAC
1873 if val_a_norm == val_b_norm:
1874 return True
1875
1876 # Multi-MAC matching: also check original reported MACs.
1877 # Devices with multiple interfaces (WiFi + Ethernet) may have ARP
1878 # resolve one MAC while the protocol reports a different one.
1879 macs_a = {val_a_norm}
1880 macs_b = {val_b_norm}
1881 reported_a = player_a.extra_data.get("reported_mac")
1882 reported_b = player_b.extra_data.get("reported_mac")
1883 if reported_a and is_valid_mac_address(reported_a):
1884 macs_a.add(normalize_mac_for_matching(reported_a))
1885 if reported_b and is_valid_mac_address(reported_b):
1886 macs_b.add(normalize_mac_for_matching(reported_b))
1887 if macs_a & macs_b:
1888 return True
1889
1890 # No MAC match - continue to next identifier type
1891 continue
1892
1893 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
1894 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
1895
1896 # Direct match
1897 if val_a_norm == val_b_norm:
1898 return True
1899
1900 # Special case: Sonos UUID matching with DLNA _MR suffix
1901 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
1902 if conn_type == IdentifierType.UUID:
1903 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
1904 return True
1905 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
1906 return True
1907
1908 # Last resort: IP-based matching.
1909 # Two players on the same IP are very likely the same physical device.
1910 # This handles two cases:
1911 # 1. MAC randomization: at least one player has no real MAC (LA or missing),
1912 # so ARP couldn't resolve a usable address.
1913 # 2. Different MACs per protocol: some devices (e.g., Yamaha MusicCast) report
1914 # different valid globally-unique MACs per protocol (DLNA vs AirPlay differ
1915 # by 1 in the last octet). IP matching is safe here because two different
1916 # devices on a LAN cannot share the same IP simultaneously.
1917 # To avoid false positives between unrelated native players, this path
1918 # requires at least one player to be a protocol or universal player.
1919 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
1920 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
1921 if ip_a and ip_b and ip_a == ip_b:
1922 mac_a = identifiers_a.get(IdentifierType.MAC_ADDRESS)
1923 mac_b = identifiers_b.get(IdentifierType.MAC_ADDRESS)
1924 a_is_real = (
1925 mac_a is not None
1926 and is_valid_mac_address(mac_a)
1927 and not is_locally_administered_mac(mac_a)
1928 )
1929 b_is_real = (
1930 mac_b is not None
1931 and is_valid_mac_address(mac_b)
1932 and not is_locally_administered_mac(mac_b)
1933 )
1934 # Case 1: at least one player has no real hardware MAC
1935 if not (a_is_real and b_is_real):
1936 return True
1937 # Case 2: both have real MACs but at least one is a protocol/universal player
1938 a_is_protocol = (
1939 player_a.type == PlayerType.PROTOCOL
1940 or player_a.provider.domain == "universal_player"
1941 )
1942 b_is_protocol = (
1943 player_b.type == PlayerType.PROTOCOL
1944 or player_b.provider.domain == "universal_player"
1945 )
1946 if a_is_protocol or b_is_protocol:
1947 return True
1948
1949 return False
1950
1951 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
1952 """
1953 Select the best available output protocol for a player.
1954
1955 Selection priority:
1956 1. Output protocol that is currently grouped/synced with other players.
1957 2. User's preferred output protocol (from player settings).
1958 3. Native playback (if player supports PLAY_MEDIA).
1959 4. The player's declared default output protocol domain, if available.
1960 5. Best available protocol by priority.
1961
1962 Returns tuple of (target_player, output_protocol).
1963 output_protocol is None when using native playback.
1964 """
1965 self.logger.log(
1966 VERBOSE_LOG_LEVEL,
1967 "Selecting output protocol for %s",
1968 player.state.name,
1969 )
1970
1971 # 1. Check if any output protocol is currently grouped
1972 for linked in player.linked_output_protocols:
1973 if protocol_player := self.get_player(linked.output_protocol_id):
1974 if protocol_player.available_for_playback and self._is_protocol_grouped(
1975 protocol_player
1976 ):
1977 self.logger.log(
1978 VERBOSE_LOG_LEVEL,
1979 "Selected protocol for %s: %s (grouped)",
1980 player.state.name,
1981 protocol_player.state.name,
1982 )
1983 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
1984
1985 # 2. Check for user's preferred output protocol.
1986 # The value is only stored while it differs from the entry's default: "native" when a
1987 # native output is available, otherwise "auto". A player without a native output (e.g. a
1988 # LinkPlay shell) therefore has no stored preference by default and gets its default
1989 # output domain applied in step 4.
1990 preferred = self.mass.config.get_raw_player_config_value(
1991 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
1992 )
1993 if preferred and preferred != "auto":
1994 if preferred == "native":
1995 if PlayerFeature.PLAY_MEDIA in player.supported_features:
1996 self.logger.log(
1997 VERBOSE_LOG_LEVEL,
1998 "Selected protocol for %s: native (user preference)",
1999 player.state.name,
2000 )
2001 return player, None
2002 else:
2003 for linked in player.linked_output_protocols:
2004 if linked.output_protocol_id == preferred:
2005 if protocol_player := self.get_player(linked.output_protocol_id):
2006 if protocol_player.available_for_playback:
2007 self.logger.log(
2008 VERBOSE_LOG_LEVEL,
2009 "Selected protocol for %s: %s (user preference)",
2010 player.state.name,
2011 protocol_player.state.name,
2012 )
2013 return protocol_player, player.get_linked_protocol(
2014 linked.output_protocol_id
2015 )
2016 break
2017
2018 # 3. Use native playback if available
2019 if PlayerFeature.PLAY_MEDIA in player.supported_features:
2020 self.logger.log(
2021 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
2022 )
2023 return player, None
2024
2025 # 4. Use the player's preferred default protocol domain, if it declares one and a
2026 # matching linked protocol is available (e.g. a LinkPlay shell prefers DLNA). This
2027 # never influences grouping; it only steers the default output for playback. "Auto"
2028 # is the entry default here, so it consistently resolves to this domain default.
2029 if default_domain := player.default_output_protocol_domain:
2030 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2031 if linked.protocol_domain != default_domain:
2032 continue
2033 if (protocol_player := self.get_player(linked.output_protocol_id)) and (
2034 protocol_player.available_for_playback
2035 ):
2036 self.logger.log(
2037 VERBOSE_LOG_LEVEL,
2038 "Selected protocol for %s: %s (default domain %s)",
2039 player.state.name,
2040 protocol_player.state.name,
2041 default_domain,
2042 )
2043 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
2044
2045 # 5. Fall back to best protocol by priority
2046 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2047 if protocol_player := self.get_player(linked.output_protocol_id):
2048 if protocol_player.available_for_playback:
2049 self.logger.log(
2050 VERBOSE_LOG_LEVEL,
2051 "Selected protocol for %s: %s (priority-based)",
2052 player.state.name,
2053 protocol_player.state.name,
2054 )
2055 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
2056
2057 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
2058
2059 def _get_control_target(
2060 self,
2061 player: Player,
2062 required_feature: PlayerFeature,
2063 require_active: bool = False,
2064 ) -> Player | None:
2065 """
2066 Get the best player(protocol) to send audio-path commands to.
2067
2068 Resolves commands that travel with the audio (enqueue, pause, announcements),
2069 so the output that renders the audio outranks the native player. Volume and
2070 mute are control-plane instead and resolve through
2071 :meth:`Player._get_protocol_player_for_feature`, which orders differently.
2072
2073 :param player: The player the command was issued on.
2074 :param required_feature: The feature the resolved target has to support.
2075 :param require_active: Only accept the output that is already rendering,
2076 instead of falling back to an idle one.
2077 """
2078 # If we have an active protocol, use that
2079 if (
2080 player.active_output_protocol
2081 and player.active_output_protocol != "native"
2082 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
2083 and required_feature in protocol_player.supported_features
2084 ):
2085 return protocol_player
2086
2087 # if the player natively supports the required feature, use that
2088 if (
2089 player.active_output_protocol == "native"
2090 and required_feature in player.supported_features
2091 ):
2092 return player
2093
2094 # If require_active is set, and no active protocol found, return None
2095 if require_active:
2096 return None
2097
2098 # if the player natively supports the required feature, use that
2099 if required_feature in player.supported_features:
2100 return player
2101
2102 # An output the user explicitly picked owns the audio, so a command that has to
2103 # start playback on an idle player follows it rather than the priority below.
2104 # The stored value survives a relink, so it only counts while it still names one
2105 # of this player's own outputs.
2106 preferred = self.mass.config.get_raw_player_config_value(
2107 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2108 )
2109 if preferred and preferred not in ("auto", "native"):
2110 for linked in player.linked_output_protocols:
2111 if linked.output_protocol_id != preferred:
2112 continue
2113 if (
2114 (preferred_player := self.mass.players.get_player(str(preferred)))
2115 and preferred_player.available_for_playback
2116 and required_feature in preferred_player.supported_features
2117 ):
2118 return preferred_player
2119 break
2120
2121 # Otherwise, use the best available linked protocol, ordered by the same
2122 # priority that regular playback selection applies.
2123 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2124 if (
2125 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
2126 and protocol_player.available_for_playback
2127 and required_feature in protocol_player.supported_features
2128 ):
2129 return protocol_player
2130
2131 return None
2132
2133 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
2134 """
2135 Check if a protocol player is currently grouped/synced with other players.
2136
2137 Used to prefer protocols that are actively participating in a group,
2138 ensuring consistent playback across grouped players.
2139 """
2140 is_grouped = bool(
2141 protocol_player.state.synced_to
2142 or (
2143 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
2144 )
2145 or protocol_player.state.active_group
2146 )
2147 if is_grouped:
2148 self.logger.log(
2149 VERBOSE_LOG_LEVEL,
2150 "Protocol player %s is grouped",
2151 protocol_player.state.name,
2152 )
2153 return is_grouped
2154
2155 def _translate_members_to_remove_for_protocols(
2156 self,
2157 parent_player: Player,
2158 player_ids: list[str],
2159 parent_protocol_player: Player | None,
2160 parent_protocol_domain: str | None,
2161 ) -> tuple[list[str], list[str]]:
2162 """
2163 Translate member IDs to remove into protocol and native lists.
2164
2165 :param parent_player: The parent player to remove members from.
2166 :param player_ids: List of visible player IDs to remove.
2167 :param parent_protocol_player: The parent's protocol player if available.
2168 :param parent_protocol_domain: The parent's protocol domain if available.
2169 """
2170 self.logger.debug(
2171 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
2172 parent_player.state.name,
2173 player_ids,
2174 parent_protocol_domain,
2175 )
2176 protocol_members: list[str] = []
2177 native_members: list[str] = []
2178
2179 for child_player_id in player_ids:
2180 child_player = self.get_player(child_player_id)
2181 if not child_player:
2182 continue
2183
2184 # Check if this member is in the parent's group via protocol
2185 if parent_protocol_domain and parent_protocol_player:
2186 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2187 if child_protocol and child_protocol.available:
2188 # For native protocol players, use the child's player_id directly
2189 child_protocol_id = (
2190 child_player.player_id
2191 if child_protocol.is_native
2192 else child_protocol.output_protocol_id
2193 )
2194 if child_protocol_id in parent_protocol_player.group_members:
2195 self.logger.debug(
2196 "Translating removal: %s -> protocol %s",
2197 child_player_id,
2198 child_protocol_id,
2199 )
2200 protocol_members.append(child_protocol_id)
2201 continue
2202
2203 # Check if child's protocol player is in parent's native group_members
2204 # This handles native protocol players (e.g., native AirPlay player like Apple TV)
2205 # where the parent itself contains protocol player IDs in its group_members
2206 translated = False
2207 for linked in child_player.linked_output_protocols:
2208 if linked.output_protocol_id in parent_player.group_members:
2209 self.logger.debug(
2210 "Translating removal (native parent): %s -> protocol %s",
2211 child_player_id,
2212 linked.output_protocol_id,
2213 )
2214 native_members.append(linked.output_protocol_id)
2215 translated = True
2216 break
2217
2218 if not translated:
2219 native_members.append(child_player_id)
2220
2221 return protocol_members, native_members
2222
2223 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
2224 """Filter member IDs to only include players from the same protocol domain."""
2225 return [
2226 pid
2227 for pid in member_ids
2228 if (p := self.get_player(pid)) and p.provider.domain == protocol_player.provider.domain
2229 ]
2230
2231 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
2232 """Filter member IDs to only include players compatible with the parent."""
2233 return [
2234 pid
2235 for pid in member_ids
2236 if (p := self.get_player(pid))
2237 and (
2238 p.provider.instance_id == parent_player.provider.instance_id
2239 or pid in parent_player._attr_can_group_with
2240 or p.provider.instance_id in parent_player._attr_can_group_with
2241 )
2242 ]
2243
2244 def _try_child_preferred_protocol(
2245 self,
2246 child_player: Player,
2247 parent_player: Player,
2248 ) -> tuple[str | None, str | None]:
2249 """
2250 Try to use child's preferred output protocol for grouping.
2251
2252 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
2253 """
2254 child_preferred = self.mass.config.get_raw_player_config_value(
2255 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2256 )
2257 if not child_preferred or child_preferred in {"auto", "native"}:
2258 return None, None
2259
2260 # Find child's preferred protocol, with its current availability
2261 child_protocol = None
2262 for output_protocol in child_player.output_protocols:
2263 if output_protocol.output_protocol_id == child_preferred:
2264 child_protocol = output_protocol
2265 break
2266
2267 if not child_protocol or not child_protocol.available:
2268 return None, None
2269
2270 # Check if parent supports this protocol (including native protocol)
2271 parent_protocol = parent_player.get_output_protocol_by_domain(
2272 child_protocol.protocol_domain
2273 )
2274 if not parent_protocol or not parent_protocol.available:
2275 return None, None
2276
2277 # Check if this protocol supports set_members
2278 protocol_player = parent_player.get_protocol_player(parent_protocol.output_protocol_id)
2279 if (
2280 not protocol_player
2281 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
2282 ):
2283 return None, None
2284
2285 return child_protocol.output_protocol_id, child_protocol.protocol_domain
2286
2287 def _can_use_native_grouping(
2288 self,
2289 child_player: Player,
2290 parent_player: Player,
2291 parent_supports_native: bool,
2292 ) -> bool:
2293 """Check if child can be grouped with parent using native grouping."""
2294 if not parent_supports_native:
2295 return False
2296 return (
2297 parent_player.is_native_group_compatible(child_player)
2298 or child_player.player_id in parent_player._attr_can_group_with
2299 or child_player.provider.instance_id in parent_player._attr_can_group_with
2300 )
2301
2302 def _try_find_common_protocol(
2303 self, child_player: Player, parent_player: Player
2304 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
2305 """
2306 Find common protocol that supports set_members.
2307
2308 Returns tuple of (parent_protocol, child_protocol) or (None, None).
2309 """
2310 for parent_output_protocol in parent_player.output_protocols:
2311 if not parent_output_protocol.available:
2312 continue
2313 child_protocol = child_player.get_output_protocol_by_domain(
2314 parent_output_protocol.protocol_domain
2315 )
2316 if not child_protocol or not child_protocol.available:
2317 continue
2318 protocol_player = parent_player.get_protocol_player(
2319 parent_output_protocol.output_protocol_id
2320 )
2321 if protocol_player and PlayerFeature.SET_MEMBERS in protocol_player.supported_features:
2322 return parent_output_protocol, child_protocol
2323 return None, None
2324
2325 def _parent_has_live_native_session(self, parent_player: Player) -> bool:
2326 """
2327 Return True when the parent currently holds a live native playback session.
2328
2329 The active output protocol lingers for a few seconds after stop, so a non-idle
2330 playback state is required to distinguish a real session from a just-stopped one.
2331 """
2332 return parent_player.active_output_protocol == "native" and (
2333 parent_player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2334 )
2335
2336 def _order_members_for_native_join(
2337 self,
2338 player_ids: list[str],
2339 parent_player: Player,
2340 parent_supports_native_grouping: bool,
2341 ) -> list[str]:
2342 """
2343 Order members so a live native session can be joined without splitting the group.
2344
2345 When the parent already holds a live native session, children that cannot group
2346 natively are evaluated first: they may force a shared protocol for the whole group,
2347 and processing them before the native-capable children lets those join that same
2348 protocol instead of being stranded in a separate native sub-group. The order is left
2349 untouched when the parent is not playing natively, so fresh-group selection is unchanged.
2350
2351 :param player_ids: The member IDs to be added, in their original order.
2352 :param parent_player: The parent player being joined.
2353 :param parent_supports_native_grouping: Whether the parent can group natively.
2354 """
2355 if not self._parent_has_live_native_session(parent_player):
2356 return player_ids
2357 return sorted(
2358 player_ids,
2359 key=lambda pid: bool(
2360 (child := self.get_player(pid))
2361 and self._can_use_native_grouping(
2362 child, parent_player, parent_supports_native_grouping
2363 )
2364 ),
2365 )
2366
2367 def _try_join_active_native_session(
2368 self,
2369 child_player: Player,
2370 parent_player: Player,
2371 parent_protocol_domain: str | None,
2372 parent_supports_native_grouping: bool,
2373 native_members: list[str],
2374 ) -> bool:
2375 """
2376 Add the child to native_members if it can join the parent's active native session.
2377
2378 A child's preferred output protocol must only steer protocol selection when the child
2379 initiates its own playback; when it joins a parent that is already playing natively it
2380 should adopt native grouping if compatible, rather than forcing the whole group onto
2381 the child's preferred protocol. Skipped once a protocol has been selected for the group,
2382 so mixed batches stay cohesive on a single protocol.
2383
2384 :param child_player: The player being added to the group.
2385 :param parent_player: The parent player being joined.
2386 :param parent_protocol_domain: The protocol domain already selected for the group, if any.
2387 :param parent_supports_native_grouping: Whether the parent can group natively.
2388 :param native_members: The native members list to append to when the child joins.
2389 """
2390 if not (
2391 self._parent_has_live_native_session(parent_player)
2392 and not parent_protocol_domain
2393 and self._can_use_native_grouping(
2394 child_player, parent_player, parent_supports_native_grouping
2395 )
2396 ):
2397 return False
2398 native_members.append(child_player.player_id)
2399 self.logger.log(
2400 VERBOSE_LOG_LEVEL,
2401 "Joining parent's active native session for %s",
2402 child_player.state.name,
2403 )
2404 return True
2405
2406 def _translate_native_members_to_protocol(
2407 self, parent_player: Player, protocol_domain: str, member_ids: list[str]
2408 ) -> list[str]:
2409 """
2410 Translate natively grouped members onto the protocol domain the parent plays through.
2411
2412 Members that do not have that protocol cannot follow the parent at all and are
2413 dropped with a warning instead.
2414
2415 :param parent_player: The parent player the members are grouped with.
2416 :param protocol_domain: The protocol domain selected for the group.
2417 :param member_ids: The member IDs that were selected for native grouping.
2418 """
2419 translated: list[str] = []
2420 for member_id in member_ids:
2421 member_player = self.get_player(member_id)
2422 if not member_player:
2423 continue
2424 # a native group's members may be listed by their protocol player id
2425 if member_player.protocol_parent_id:
2426 member_player = self.get_player(member_player.protocol_parent_id) or member_player
2427 member_protocol = member_player.get_output_protocol_by_domain(protocol_domain)
2428 if not member_protocol or not member_protocol.available:
2429 self.logger.warning(
2430 "Cannot group %s with %s: the group plays through the %s protocol, "
2431 "which %s does not support",
2432 member_player.state.name,
2433 parent_player.state.name,
2434 protocol_domain,
2435 member_player.state.name,
2436 )
2437 continue
2438 # For native protocol players, use the member's player_id directly
2439 translated.append(
2440 member_player.player_id
2441 if member_protocol.is_native
2442 else member_protocol.output_protocol_id
2443 )
2444 self.logger.log(
2445 VERBOSE_LOG_LEVEL,
2446 "Moving %s from native grouping to the %s protocol",
2447 member_player.state.name,
2448 protocol_domain,
2449 )
2450 return translated
2451
2452 def _move_native_members_to_group_protocol(
2453 self,
2454 parent_player: Player,
2455 parent_protocol_player: Player | None,
2456 parent_protocol_domain: str | None,
2457 protocol_members: list[str],
2458 native_members: list[str],
2459 ) -> None:
2460 """
2461 Move the members selected for native grouping onto the protocol the group ended up on.
2462
2463 Does nothing unless the group ends up on one of the parent's protocols while the
2464 parent's native grouping needs the parent's own stream: only then do those members
2465 have no session left to attach to.
2466
2467 :param parent_player: The parent player being joined.
2468 :param parent_protocol_player: The protocol player selected for the group, if any.
2469 :param parent_protocol_domain: The protocol domain selected for the group, if any.
2470 :param protocol_members: The protocol member list the translated IDs are added to.
2471 :param native_members: The native member IDs, emptied when they are moved over.
2472 """
2473 if not (
2474 native_members
2475 and parent_protocol_domain
2476 and parent_protocol_player
2477 and parent_protocol_player.player_id != parent_player.player_id
2478 and parent_player.native_grouping_requires_own_stream
2479 ):
2480 return
2481 protocol_members.extend(
2482 self._translate_native_members_to_protocol(
2483 parent_player, parent_protocol_domain, native_members
2484 )
2485 )
2486 native_members.clear()
2487
2488 def _migrate_stranded_native_members(
2489 self,
2490 parent_player: Player,
2491 parent_protocol_player: Player,
2492 protocol_members: list[str],
2493 ) -> list[str]:
2494 """
2495 Move the native members that a switch to the given protocol strands onto that protocol.
2496
2497 Returns the member IDs that are still attached to the parent's own stream, so the
2498 caller can release them from it. Empty unless the parent renders that stream itself
2499 while its native grouping attaches the members to exactly that stream: only then are
2500 they left without anything to play.
2501
2502 :param parent_player: The parent player that is about to switch protocol.
2503 :param parent_protocol_player: The protocol player the parent will render through.
2504 :param protocol_members: The protocol member list the translated IDs are added to.
2505 """
2506 if parent_protocol_player.player_id == parent_player.player_id:
2507 return []
2508 if not parent_player.native_grouping_requires_own_stream:
2509 return []
2510 if parent_player.active_output_protocol not in (None, "native"):
2511 return []
2512 if parent_player.state.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
2513 return []
2514 stranded = [
2515 member_id
2516 for member_id in parent_player.group_members
2517 if member_id != parent_player.player_id
2518 ]
2519 for protocol_id in self._translate_native_members_to_protocol(
2520 parent_player, parent_protocol_player.provider.domain, stranded
2521 ):
2522 if protocol_id not in protocol_members:
2523 protocol_members.append(protocol_id)
2524 return stranded
2525
2526 async def _stop_native_session(
2527 self,
2528 parent_player: Player,
2529 parent_protocol_player: Player,
2530 stranded_native_members: list[str],
2531 ) -> None:
2532 """
2533 Release the given members from the parent's own stream and stop it.
2534
2535 :param parent_player: The parent player whose native session is handed over.
2536 :param parent_protocol_player: The protocol player taking the output over.
2537 :param stranded_native_members: The members to release, already migrated.
2538 """
2539 self.logger.debug(
2540 "Stopping the native session of %s before switching to %s, migrated members: %s",
2541 parent_player.state.name,
2542 parent_protocol_player.state.name,
2543 stranded_native_members,
2544 )
2545 # The members already joined the protocol group, so the native session only has to
2546 # release them and stop. Releasing them first also clears the native group, which
2547 # keeps a later native playback command from resurrecting it. Both calls take the
2548 # provider's own lock, so they must run one after the other.
2549 await parent_player.set_members(player_ids_to_remove=stranded_native_members)
2550 await parent_player.stop()
2551
2552 def _translate_members_for_protocols(
2553 self,
2554 parent_player: Player,
2555 player_ids: list[str],
2556 parent_protocol_player: Player | None,
2557 parent_protocol_domain: str | None,
2558 ) -> tuple[list[str], list[str], Player | None, str | None]:
2559 """
2560 Translate member IDs to protocol or native IDs.
2561
2562 The grouping method is picked per member, see _select_grouping_for_member.
2563
2564 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
2565 """
2566 protocol_members: list[str] = []
2567 native_members: list[str] = []
2568 parent_supports_native_grouping = (
2569 PlayerFeature.SET_MEMBERS in parent_player.supported_features
2570 )
2571 player_ids = self._order_members_for_native_join(
2572 player_ids, parent_player, parent_supports_native_grouping
2573 )
2574
2575 self.logger.log(
2576 VERBOSE_LOG_LEVEL,
2577 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
2578 parent_player.state.name,
2579 parent_supports_native_grouping,
2580 parent_protocol_player.state.name if parent_protocol_player else "none",
2581 parent_protocol_domain or "none",
2582 )
2583
2584 for child_player_id in player_ids:
2585 child_player = self.get_player(child_player_id)
2586 if not child_player:
2587 continue
2588
2589 self.logger.log(
2590 VERBOSE_LOG_LEVEL,
2591 "Processing child %s (type=%s, protocols=%s)",
2592 child_player.state.name,
2593 child_player.state.type,
2594 [p.protocol_domain for p in child_player.output_protocols],
2595 )
2596
2597 parent_protocol_player, parent_protocol_domain = self._select_grouping_for_member(
2598 child_player,
2599 parent_player,
2600 parent_protocol_player,
2601 parent_protocol_domain,
2602 parent_supports_native_grouping,
2603 protocol_members,
2604 native_members,
2605 )
2606
2607 # Post-pass: the protocol selected for the group is only known once every child has
2608 # been processed, so the members picked for native grouping are corrected here.
2609 self._move_native_members_to_group_protocol(
2610 parent_player,
2611 parent_protocol_player,
2612 parent_protocol_domain,
2613 protocol_members,
2614 native_members,
2615 )
2616
2617 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
2618
2619 def _select_grouping_for_member(
2620 self,
2621 child_player: Player,
2622 parent_player: Player,
2623 parent_protocol_player: Player | None,
2624 parent_protocol_domain: str | None,
2625 parent_supports_native_grouping: bool,
2626 protocol_members: list[str],
2627 native_members: list[str],
2628 ) -> tuple[Player | None, str | None]:
2629 """
2630 Pick the grouping method for a single member and add it to the matching member list.
2631
2632 Selection priority when grouping:
2633 0. If the parent is already playing natively and the child can be grouped
2634 natively, join that native session (a child joining an existing group must
2635 not force the whole group onto its own preferred output protocol)
2636 1. Try child's preferred output protocol (from player settings)
2637 2. Try parent's active output protocol (if any and child supports it)
2638 3. Try native grouping (if parent and child are compatible)
2639 4. Search for common protocol that supports set_members
2640 5. Log warning if no option works
2641
2642 Returns the protocol player/domain the group is on, which the picked method may have
2643 changed.
2644
2645 :param child_player: The player being added to the group.
2646 :param parent_player: The parent player being joined.
2647 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2648 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2649 :param parent_supports_native_grouping: Whether the parent can group natively.
2650 :param protocol_members: The protocol member list to append to.
2651 :param native_members: The native member list to append to.
2652 """
2653 # Priority 0: The parent is already playing natively and the child can join
2654 # that native session directly - adopt it before considering the child's own
2655 # preferred output protocol.
2656 if self._try_join_active_native_session(
2657 child_player,
2658 parent_player,
2659 parent_protocol_domain,
2660 parent_supports_native_grouping,
2661 native_members,
2662 ):
2663 return parent_protocol_player, parent_protocol_domain
2664
2665 # Priority 0.5: a player that runs its own multiroom (e.g. a LinkPlay control shell)
2666 # keeps grouping on its native path rather than routing it through a linked protocol
2667 # that is merely its preferred playback output. Native compatibility still decides
2668 # whether this is possible, so an incompatible/cross-backend pair falls through.
2669 if child_player.prefer_native_grouping and self._can_use_native_grouping(
2670 child_player, parent_player, parent_supports_native_grouping
2671 ):
2672 native_members.append(child_player.player_id)
2673 self.logger.log(
2674 VERBOSE_LOG_LEVEL,
2675 "Using native grouping (preferred) for %s",
2676 child_player.state.name,
2677 )
2678 return parent_protocol_player, parent_protocol_domain
2679
2680 # Priority 1: the child's preferred output protocol
2681 grouped, parent_protocol_player, parent_protocol_domain = (
2682 self._try_group_via_preferred_protocol(
2683 child_player,
2684 parent_player,
2685 parent_protocol_player,
2686 parent_protocol_domain,
2687 protocol_members,
2688 )
2689 )
2690 if grouped:
2691 return parent_protocol_player, parent_protocol_domain
2692
2693 # Priority 2: the protocol the group is already on
2694 grouped, parent_protocol_player, parent_protocol_domain = (
2695 self._try_group_via_active_protocol(
2696 child_player,
2697 parent_protocol_player,
2698 parent_protocol_domain,
2699 protocol_members,
2700 )
2701 )
2702 if grouped:
2703 return parent_protocol_player, parent_protocol_domain
2704
2705 # Priority 3: native grouping
2706 if self._can_use_native_grouping(
2707 child_player, parent_player, parent_supports_native_grouping
2708 ):
2709 native_members.append(child_player.player_id)
2710 self.logger.log(
2711 VERBOSE_LOG_LEVEL,
2712 "Using native grouping for %s",
2713 child_player.state.name,
2714 )
2715 return parent_protocol_player, parent_protocol_domain
2716
2717 # Priority 4: a protocol both players share that supports set_members
2718 grouped, parent_protocol_player, parent_protocol_domain = (
2719 self._try_group_via_common_protocol(
2720 child_player,
2721 parent_player,
2722 parent_protocol_player,
2723 parent_protocol_domain,
2724 protocol_members,
2725 )
2726 )
2727 if grouped:
2728 return parent_protocol_player, parent_protocol_domain
2729
2730 # Priority 5: no option worked
2731 self.logger.warning(
2732 "Cannot group %s with %s: no compatible grouping method found "
2733 "(tried: child preferred protocol, parent active protocol, "
2734 "native grouping, common protocols)",
2735 child_player.state.name,
2736 parent_player.state.name,
2737 )
2738 return parent_protocol_player, parent_protocol_domain
2739
2740 def _try_group_via_preferred_protocol(
2741 self,
2742 child_player: Player,
2743 parent_player: Player,
2744 parent_protocol_player: Player | None,
2745 parent_protocol_domain: str | None,
2746 protocol_members: list[str],
2747 ) -> tuple[bool, Player | None, str | None]:
2748 """
2749 Try to group the child through the output protocol it prefers in its player settings.
2750
2751 Only used when the group is not on a protocol yet or is already on that same protocol.
2752 Returns whether the child was grouped, together with the protocol player/domain the
2753 group is on: the child's preferred protocol may become the group's protocol.
2754
2755 :param child_player: The player being added to the group.
2756 :param parent_player: The parent player being joined.
2757 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2758 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2759 :param protocol_members: The protocol member list to append to.
2760 """
2761 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
2762 child_player, parent_player
2763 )
2764 if not (
2765 child_protocol_id
2766 and protocol_domain
2767 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
2768 ):
2769 return False, parent_protocol_player, parent_protocol_domain
2770
2771 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
2772 parent_protocol = parent_player.get_output_protocol_by_domain(protocol_domain)
2773 if parent_protocol:
2774 parent_protocol_player = parent_player.get_protocol_player(
2775 parent_protocol.output_protocol_id
2776 )
2777 parent_protocol_domain = protocol_domain
2778 protocol_members.append(child_protocol_id)
2779 self.logger.log(
2780 VERBOSE_LOG_LEVEL,
2781 "Using child's preferred protocol %s for %s",
2782 protocol_domain,
2783 child_player.state.name,
2784 )
2785 return True, parent_protocol_player, parent_protocol_domain
2786
2787 def _try_group_via_active_protocol(
2788 self,
2789 child_player: Player,
2790 parent_protocol_player: Player | None,
2791 parent_protocol_domain: str | None,
2792 protocol_members: list[str],
2793 ) -> tuple[bool, Player | None, str | None]:
2794 """
2795 Try to group the child through the protocol the group is already on.
2796
2797 Returns whether the child was grouped, together with the protocol player/domain the
2798 group is on: the selection is dropped when that protocol cannot group members itself,
2799 so a later grouping method can select another one.
2800
2801 :param child_player: The player being added to the group.
2802 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2803 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2804 :param protocol_members: The protocol member list to append to.
2805 """
2806 if not parent_protocol_domain or not parent_protocol_player:
2807 return False, parent_protocol_player, parent_protocol_domain
2808
2809 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2810 self.logger.log(
2811 VERBOSE_LOG_LEVEL,
2812 "Parent's active protocol %s does not support SET_MEMBERS, "
2813 "will search for alternative",
2814 parent_protocol_domain,
2815 )
2816 # Drop the selection so a later grouping method can select a new protocol
2817 return False, None, None
2818
2819 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2820 if not child_protocol or not child_protocol.available:
2821 return False, parent_protocol_player, parent_protocol_domain
2822
2823 # For native protocol players, use the child's player_id directly
2824 # (e.g., a native sendspin web player IS the protocol player)
2825 child_protocol_id = (
2826 child_player.player_id
2827 if child_protocol.is_native
2828 else child_protocol.output_protocol_id
2829 )
2830 protocol_members.append(child_protocol_id)
2831 self.logger.log(
2832 VERBOSE_LOG_LEVEL,
2833 "Using parent's active protocol %s for %s",
2834 parent_protocol_domain,
2835 child_player.state.name,
2836 )
2837 return True, parent_protocol_player, parent_protocol_domain
2838
2839 def _try_group_via_common_protocol(
2840 self,
2841 child_player: Player,
2842 parent_player: Player,
2843 parent_protocol_player: Player | None,
2844 parent_protocol_domain: str | None,
2845 protocol_members: list[str],
2846 ) -> tuple[bool, Player | None, str | None]:
2847 """
2848 Try to group the child through a protocol both players share.
2849
2850 Returns whether the child was grouped, together with the protocol player/domain the
2851 group is on: the shared protocol may become the group's protocol.
2852
2853 :param child_player: The player being added to the group.
2854 :param parent_player: The parent player being joined.
2855 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2856 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2857 :param protocol_members: The protocol member list to append to.
2858 """
2859 parent_protocol, child_protocol = self._try_find_common_protocol(
2860 child_player, parent_player
2861 )
2862 if not parent_protocol or not child_protocol:
2863 return False, parent_protocol_player, parent_protocol_domain
2864
2865 if not parent_protocol_player or parent_protocol_domain != parent_protocol.protocol_domain:
2866 parent_protocol_player = parent_player.get_protocol_player(
2867 parent_protocol.output_protocol_id
2868 )
2869 if parent_protocol_player:
2870 parent_protocol_domain = parent_protocol_player.provider.domain
2871 # For native protocol players, use the child's player_id directly
2872 child_protocol_id = (
2873 child_player.player_id
2874 if child_protocol.is_native
2875 else child_protocol.output_protocol_id
2876 )
2877 protocol_members.append(child_protocol_id)
2878 self.logger.log(
2879 VERBOSE_LOG_LEVEL,
2880 "Selected common protocol %s for grouping %s with %s",
2881 parent_protocol.protocol_domain,
2882 child_player.state.name,
2883 parent_player.state.name,
2884 )
2885 return True, parent_protocol_player, parent_protocol_domain
2886
2887 async def _forward_protocol_set_members(
2888 self,
2889 parent_player: Player,
2890 parent_protocol_player: Player,
2891 protocol_members_to_add: list[str],
2892 protocol_members_to_remove: list[str],
2893 ) -> None:
2894 """
2895 Forward protocol members to protocol player's set_members and manage active output protocol.
2896
2897 :param parent_player: The parent player (native/universal).
2898 :param parent_protocol_player: The protocol player to forward commands to.
2899 :param protocol_members_to_add: Protocol player IDs to add.
2900 :param protocol_members_to_remove: Protocol player IDs to remove.
2901 """
2902 filtered_protocol_add = self._filter_protocol_members(
2903 protocol_members_to_add, parent_protocol_player
2904 )
2905 filtered_protocol_remove = self._filter_protocol_members(
2906 protocol_members_to_remove, parent_protocol_player
2907 )
2908 self.logger.debug(
2909 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
2910 parent_protocol_player.state.name,
2911 filtered_protocol_add,
2912 filtered_protocol_remove,
2913 )
2914
2915 if not filtered_protocol_add and not filtered_protocol_remove:
2916 return
2917
2918 # Safety check: verify protocol player supports SET_MEMBERS
2919 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2920 self.logger.error(
2921 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
2922 "This should have been caught earlier in the flow.",
2923 parent_protocol_player.state.name,
2924 )
2925 return
2926
2927 # Members that ride the parent's own stream are stranded by the protocol switch below,
2928 # so they join the protocol group in this very same call and their native session is
2929 # torn down afterwards.
2930 stranded_native_members = (
2931 self._migrate_stranded_native_members(
2932 parent_player, parent_protocol_player, filtered_protocol_add
2933 )
2934 if filtered_protocol_add
2935 else []
2936 )
2937
2938 # This runs before set_members because a member's own stream starts inside that call
2939 # and the provider resolves the member's volume control as it starts: unless its parent
2940 # already points at this protocol, that resolution picks a sibling interface of the same
2941 # device (e.g. its cast side) over the one carrying the audio.
2942 self._activate_protocol_on_added_children(filtered_protocol_add)
2943
2944 self.logger.debug(
2945 "Calling set_members on protocol player %s with add=%s, remove=%s",
2946 parent_protocol_player.state.name,
2947 filtered_protocol_add,
2948 filtered_protocol_remove,
2949 )
2950 await parent_protocol_player.set_members(
2951 player_ids_to_add=filtered_protocol_add or None,
2952 player_ids_to_remove=filtered_protocol_remove or None,
2953 )
2954
2955 if filtered_protocol_add:
2956 await self._activate_group_output_protocol(
2957 parent_player, parent_protocol_player, stranded_native_members
2958 )
2959
2960 self.logger.debug(
2961 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
2962 parent_protocol_player.state.name,
2963 parent_protocol_player.group_members,
2964 parent_protocol_player.synced_to,
2965 )
2966
2967 def _activate_protocol_on_added_children(self, protocol_member_ids: list[str]) -> None:
2968 """
2969 Point the parent of each given protocol member at the protocol carrying the group audio.
2970
2971 :param protocol_member_ids: The protocol player IDs joining the group.
2972 """
2973 for child_protocol_id in protocol_member_ids:
2974 if not (child_protocol := self.get_player(child_protocol_id)):
2975 continue
2976 if not child_protocol.protocol_parent_id:
2977 continue
2978 if not (child_player := self.get_player(child_protocol.protocol_parent_id)):
2979 continue
2980 if child_player.active_output_protocol == child_protocol_id:
2981 continue
2982 self.logger.debug(
2983 "Setting active output protocol on child %s to %s",
2984 child_player.state.name,
2985 child_protocol_id,
2986 )
2987 child_player.set_active_output_protocol(child_protocol_id)
2988
2989 async def _activate_group_output_protocol(
2990 self,
2991 parent_player: Player,
2992 parent_protocol_player: Player,
2993 stranded_native_members: list[str],
2994 ) -> None:
2995 """
2996 Mark the given protocol as the parent's output and hand the playback over to it.
2997
2998 The handover only runs when the parent is actually switching protocol while it is
2999 rendering; playback is resumed only for a parent that was playing, so adding a member
3000 never starts playback on its own.
3001
3002 :param parent_player: The parent player that just gained protocol members.
3003 :param parent_protocol_player: The protocol player the members joined.
3004 :param stranded_native_members: The members left without a stream by the switch.
3005 """
3006 previous_protocol = parent_player.active_output_protocol
3007 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
3008 # A paused player still holds its output, so the handover has to run for it too.
3009 was_rendering = was_playing or parent_player.state.playback_state == PlaybackState.PAUSED
3010
3011 # Native protocol: parent_protocol_player is the same as parent_player
3012 is_native_protocol = parent_protocol_player.player_id == parent_player.player_id
3013 already_using_native = previous_protocol in (None, "native")
3014 already_using_this_protocol = previous_protocol == parent_protocol_player.player_id
3015 switching_protocols = not (
3016 (is_native_protocol and already_using_native) or already_using_this_protocol
3017 )
3018
3019 self.logger.debug(
3020 "Protocol grouping: is_native=%s, already_native=%s, already_this=%s, "
3021 "switching=%s, was_rendering=%s",
3022 is_native_protocol,
3023 already_using_native,
3024 already_using_this_protocol,
3025 switching_protocols,
3026 was_rendering,
3027 )
3028
3029 if not (is_native_protocol and already_using_native):
3030 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
3031
3032 if not (was_rendering and switching_protocols):
3033 return
3034
3035 self.logger.info(
3036 "Handing the output of %s over to the %s protocol%s",
3037 parent_player.state.name,
3038 parent_protocol_player.provider.domain,
3039 " and resuming playback" if was_playing else "",
3040 )
3041 if stranded_native_members:
3042 await self._stop_native_session(
3043 parent_player, parent_protocol_player, stranded_native_members
3044 )
3045 old_parent_members = await self._stop_previous_protocol(
3046 parent_player, parent_protocol_player, previous_protocol
3047 )
3048 if was_playing:
3049 await self.mass.players.cmd_resume(parent_player.player_id)
3050 if old_parent_members:
3051 self.logger.debug(
3052 "Re-adding migrated members %s to %s on new protocol",
3053 old_parent_members,
3054 parent_player.state.name,
3055 )
3056 # Use internal handler because we are already inside a
3057 # _handle_set_members call chain that holds the play lock.
3058 await self.mass.players._handle_set_members(
3059 parent_player,
3060 player_ids_to_add=old_parent_members,
3061 )
3062
3063 async def _stop_previous_protocol(
3064 self,
3065 parent_player: Player,
3066 parent_protocol_player: Player,
3067 previous_protocol: str | None,
3068 ) -> list[str]:
3069 """
3070 Stop the protocol player the parent was rendering through and return its members.
3071
3072 The returned IDs are parent player IDs, so the caller can re-add them to the group
3073 once the new protocol carries the audio. Empty if there is nothing to hand over.
3074
3075 :param parent_player: The parent player that is switching protocol.
3076 :param parent_protocol_player: The protocol player taking the output over.
3077 :param previous_protocol: The parent's previous active output protocol, if any.
3078 """
3079 if previous_protocol in (None, "native"):
3080 return []
3081 if not (old_protocol_player := self.get_player(previous_protocol)):
3082 return []
3083 if old_protocol_player.player_id == parent_protocol_player.player_id:
3084 return []
3085 # Translate the old protocol's child members back to parent player IDs
3086 old_parent_members: list[str] = []
3087 for member_id in old_protocol_player.group_members:
3088 if member_id == old_protocol_player.player_id:
3089 continue
3090 if not (member_player := self.get_player(member_id)):
3091 continue
3092 parent_id = member_player.protocol_parent_id or member_id
3093 if parent_id != parent_player.player_id:
3094 old_parent_members.append(parent_id)
3095 self.logger.debug(
3096 "Stopping old protocol player %s before switching to %s, migrating members: %s",
3097 old_protocol_player.state.name,
3098 parent_protocol_player.state.name,
3099 old_parent_members,
3100 )
3101 # Use internal handler to stop the specific protocol player,
3102 # bypassing group/sync redirect and queue redirect logic.
3103 await self.mass.players._handle_cmd_stop(old_protocol_player.player_id)
3104 return old_parent_members
3105