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