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