/
/
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, announcements),
2085 so the output that renders the audio outranks the native player. Volume and
2086 mute are control-plane instead and resolve through
2087 :meth:`Player._get_protocol_player_for_feature`, which orders differently.
2088
2089 :param player: The player the command was issued on.
2090 :param required_feature: The feature the resolved target has to support.
2091 :param require_active: Only accept the output that is already rendering,
2092 instead of falling back to an idle one.
2093 """
2094 # If we have an active protocol, use that
2095 if (
2096 player.active_output_protocol
2097 and player.active_output_protocol != "native"
2098 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
2099 and required_feature in protocol_player.supported_features
2100 ):
2101 return protocol_player
2102
2103 # if the player natively supports the required feature, use that
2104 if (
2105 player.active_output_protocol == "native"
2106 and required_feature in player.supported_features
2107 ):
2108 return player
2109
2110 # If require_active is set, and no active protocol found, return None
2111 if require_active:
2112 return None
2113
2114 # if the player natively supports the required feature, use that
2115 if required_feature in player.supported_features:
2116 return player
2117
2118 # An output the user explicitly picked owns the audio, so a command that has to
2119 # start playback on an idle player follows it rather than the priority below.
2120 # The stored value survives a relink, so it only counts while it still names one
2121 # of this player's own outputs.
2122 preferred = self.mass.config.get_raw_player_config_value(
2123 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2124 )
2125 if preferred and preferred not in ("auto", "native"):
2126 for linked in player.linked_output_protocols:
2127 if linked.output_protocol_id != preferred:
2128 continue
2129 if (
2130 (preferred_player := self.mass.players.get_player(str(preferred)))
2131 and preferred_player.available_for_playback
2132 and required_feature in preferred_player.supported_features
2133 ):
2134 return preferred_player
2135 break
2136
2137 # Otherwise, use the best available linked protocol, ordered by the same
2138 # priority that regular playback selection applies.
2139 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2140 if (
2141 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
2142 and protocol_player.available_for_playback
2143 and required_feature in protocol_player.supported_features
2144 ):
2145 return protocol_player
2146
2147 return None
2148
2149 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
2150 """
2151 Check if a protocol player is currently grouped/synced with other players.
2152
2153 Used to prefer protocols that are actively participating in a group,
2154 ensuring consistent playback across grouped players.
2155 """
2156 is_grouped = bool(
2157 protocol_player.state.synced_to
2158 or (
2159 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
2160 )
2161 or protocol_player.state.active_group
2162 )
2163 if is_grouped:
2164 self.logger.log(
2165 VERBOSE_LOG_LEVEL,
2166 "Protocol player %s is grouped",
2167 protocol_player.state.name,
2168 )
2169 return is_grouped
2170
2171 def _translate_members_to_remove_for_protocols(
2172 self,
2173 parent_player: Player,
2174 player_ids: list[str],
2175 parent_protocol_player: Player | None,
2176 parent_protocol_domain: str | None,
2177 ) -> tuple[list[str], list[str]]:
2178 """
2179 Translate member IDs to remove into protocol and native lists.
2180
2181 :param parent_player: The parent player to remove members from.
2182 :param player_ids: List of visible player IDs to remove.
2183 :param parent_protocol_player: The parent's protocol player if available.
2184 :param parent_protocol_domain: The parent's protocol domain if available.
2185 """
2186 self.logger.debug(
2187 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
2188 parent_player.state.name,
2189 player_ids,
2190 parent_protocol_domain,
2191 )
2192 protocol_members: list[str] = []
2193 native_members: list[str] = []
2194
2195 for child_player_id in player_ids:
2196 child_player = self.get_player(child_player_id)
2197 if not child_player:
2198 continue
2199
2200 # Check if this member is in the parent's group via protocol
2201 if parent_protocol_domain and parent_protocol_player:
2202 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2203 if child_protocol and child_protocol.available:
2204 # For native protocol players, use the child's player_id directly
2205 child_protocol_id = (
2206 child_player.player_id
2207 if child_protocol.is_native
2208 else child_protocol.output_protocol_id
2209 )
2210 if child_protocol_id in parent_protocol_player.group_members:
2211 self.logger.debug(
2212 "Translating removal: %s -> protocol %s",
2213 child_player_id,
2214 child_protocol_id,
2215 )
2216 protocol_members.append(child_protocol_id)
2217 continue
2218
2219 # Check if child's protocol player is in parent's native group_members
2220 # This handles native protocol players (e.g., native AirPlay player like Apple TV)
2221 # where the parent itself contains protocol player IDs in its group_members
2222 translated = False
2223 for linked in child_player.linked_output_protocols:
2224 if linked.output_protocol_id in parent_player.group_members:
2225 self.logger.debug(
2226 "Translating removal (native parent): %s -> protocol %s",
2227 child_player_id,
2228 linked.output_protocol_id,
2229 )
2230 native_members.append(linked.output_protocol_id)
2231 translated = True
2232 break
2233
2234 if not translated:
2235 native_members.append(child_player_id)
2236
2237 return protocol_members, native_members
2238
2239 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
2240 """Filter member IDs to only include players from the same protocol domain."""
2241 return [
2242 pid
2243 for pid in member_ids
2244 if (p := self.get_player(pid)) and p.provider.domain == protocol_player.provider.domain
2245 ]
2246
2247 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
2248 """Filter member IDs to only include players compatible with the parent."""
2249 return [
2250 pid
2251 for pid in member_ids
2252 if (p := self.get_player(pid))
2253 and (
2254 p.provider.instance_id == parent_player.provider.instance_id
2255 or pid in parent_player._attr_can_group_with
2256 or p.provider.instance_id in parent_player._attr_can_group_with
2257 )
2258 ]
2259
2260 def _try_child_preferred_protocol(
2261 self,
2262 child_player: Player,
2263 parent_player: Player,
2264 ) -> tuple[str | None, str | None]:
2265 """
2266 Try to use child's preferred output protocol for grouping.
2267
2268 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
2269 """
2270 child_preferred = self.mass.config.get_raw_player_config_value(
2271 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2272 )
2273 if not child_preferred or child_preferred in {"auto", "native"}:
2274 return None, None
2275
2276 # Find child's preferred protocol, with its current availability
2277 child_protocol = None
2278 for output_protocol in child_player.output_protocols:
2279 if output_protocol.output_protocol_id == child_preferred:
2280 child_protocol = output_protocol
2281 break
2282
2283 if not child_protocol or not child_protocol.available:
2284 return None, None
2285
2286 # Check if parent supports this protocol (including native protocol)
2287 parent_protocol = parent_player.get_output_protocol_by_domain(
2288 child_protocol.protocol_domain
2289 )
2290 if not parent_protocol or not parent_protocol.available:
2291 return None, None
2292
2293 # Check if this protocol supports set_members
2294 protocol_player = parent_player.get_protocol_player(parent_protocol.output_protocol_id)
2295 if (
2296 not protocol_player
2297 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
2298 ):
2299 return None, None
2300
2301 return child_protocol.output_protocol_id, child_protocol.protocol_domain
2302
2303 def _can_use_native_grouping(
2304 self,
2305 child_player: Player,
2306 parent_player: Player,
2307 parent_supports_native: bool,
2308 ) -> bool:
2309 """Check if child can be grouped with parent using native grouping."""
2310 if not parent_supports_native:
2311 return False
2312 return (
2313 parent_player.is_native_group_compatible(child_player)
2314 or child_player.player_id in parent_player._attr_can_group_with
2315 or child_player.provider.instance_id in parent_player._attr_can_group_with
2316 )
2317
2318 def _try_find_common_protocol(
2319 self, child_player: Player, parent_player: Player
2320 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
2321 """
2322 Find common protocol that supports set_members.
2323
2324 Returns tuple of (parent_protocol, child_protocol) or (None, None).
2325 """
2326 for parent_output_protocol in parent_player.output_protocols:
2327 if not parent_output_protocol.available:
2328 continue
2329 child_protocol = child_player.get_output_protocol_by_domain(
2330 parent_output_protocol.protocol_domain
2331 )
2332 if not child_protocol or not child_protocol.available:
2333 continue
2334 protocol_player = parent_player.get_protocol_player(
2335 parent_output_protocol.output_protocol_id
2336 )
2337 if protocol_player and PlayerFeature.SET_MEMBERS in protocol_player.supported_features:
2338 return parent_output_protocol, child_protocol
2339 return None, None
2340
2341 def _parent_has_live_native_session(self, parent_player: Player) -> bool:
2342 """
2343 Return True when the parent currently holds a live native playback session.
2344
2345 The active output protocol lingers for a few seconds after stop, so a non-idle
2346 playback state is required to distinguish a real session from a just-stopped one.
2347 """
2348 return parent_player.active_output_protocol == "native" and (
2349 parent_player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2350 )
2351
2352 def _order_members_for_native_join(
2353 self,
2354 player_ids: list[str],
2355 parent_player: Player,
2356 parent_supports_native_grouping: bool,
2357 ) -> list[str]:
2358 """
2359 Order members so a live native session can be joined without splitting the group.
2360
2361 When the parent already holds a live native session, children that cannot group
2362 natively are evaluated first: they may force a shared protocol for the whole group,
2363 and processing them before the native-capable children lets those join that same
2364 protocol instead of being stranded in a separate native sub-group. The order is left
2365 untouched when the parent is not playing natively, so fresh-group selection is unchanged.
2366
2367 :param player_ids: The member IDs to be added, in their original order.
2368 :param parent_player: The parent player being joined.
2369 :param parent_supports_native_grouping: Whether the parent can group natively.
2370 """
2371 if not self._parent_has_live_native_session(parent_player):
2372 return player_ids
2373 return sorted(
2374 player_ids,
2375 key=lambda pid: bool(
2376 (child := self.get_player(pid))
2377 and self._can_use_native_grouping(
2378 child, parent_player, parent_supports_native_grouping
2379 )
2380 ),
2381 )
2382
2383 def _try_join_active_native_session(
2384 self,
2385 child_player: Player,
2386 parent_player: Player,
2387 parent_protocol_domain: str | None,
2388 parent_supports_native_grouping: bool,
2389 native_members: list[str],
2390 ) -> bool:
2391 """
2392 Add the child to native_members if it can join the parent's active native session.
2393
2394 A child's preferred output protocol must only steer protocol selection when the child
2395 initiates its own playback; when it joins a parent that is already playing natively it
2396 should adopt native grouping if compatible, rather than forcing the whole group onto
2397 the child's preferred protocol. Skipped once a protocol has been selected for the group,
2398 so mixed batches stay cohesive on a single protocol.
2399
2400 :param child_player: The player being added to the group.
2401 :param parent_player: The parent player being joined.
2402 :param parent_protocol_domain: The protocol domain already selected for the group, if any.
2403 :param parent_supports_native_grouping: Whether the parent can group natively.
2404 :param native_members: The native members list to append to when the child joins.
2405 """
2406 if not (
2407 self._parent_has_live_native_session(parent_player)
2408 and not parent_protocol_domain
2409 and self._can_use_native_grouping(
2410 child_player, parent_player, parent_supports_native_grouping
2411 )
2412 ):
2413 return False
2414 native_members.append(child_player.player_id)
2415 self.logger.log(
2416 VERBOSE_LOG_LEVEL,
2417 "Joining parent's active native session for %s",
2418 child_player.state.name,
2419 )
2420 return True
2421
2422 def _translate_native_members_to_protocol(
2423 self, parent_player: Player, protocol_domain: str, member_ids: list[str]
2424 ) -> list[str]:
2425 """
2426 Translate natively grouped members onto the protocol domain the parent plays through.
2427
2428 Members that do not have that protocol cannot follow the parent at all and are
2429 dropped with a warning instead.
2430
2431 :param parent_player: The parent player the members are grouped with.
2432 :param protocol_domain: The protocol domain selected for the group.
2433 :param member_ids: The member IDs that were selected for native grouping.
2434 """
2435 translated: list[str] = []
2436 for member_id in member_ids:
2437 member_player = self.get_player(member_id)
2438 if not member_player:
2439 continue
2440 # a native group's members may be listed by their protocol player id
2441 if member_player.protocol_parent_id:
2442 member_player = self.get_player(member_player.protocol_parent_id) or member_player
2443 member_protocol = member_player.get_output_protocol_by_domain(protocol_domain)
2444 if not member_protocol or not member_protocol.available:
2445 self.logger.warning(
2446 "Cannot group %s with %s: the group plays through the %s protocol, "
2447 "which %s does not support",
2448 member_player.state.name,
2449 parent_player.state.name,
2450 protocol_domain,
2451 member_player.state.name,
2452 )
2453 continue
2454 # For native protocol players, use the member's player_id directly
2455 translated.append(
2456 member_player.player_id
2457 if member_protocol.is_native
2458 else member_protocol.output_protocol_id
2459 )
2460 self.logger.log(
2461 VERBOSE_LOG_LEVEL,
2462 "Moving %s from native grouping to the %s protocol",
2463 member_player.state.name,
2464 protocol_domain,
2465 )
2466 return translated
2467
2468 def _move_native_members_to_group_protocol(
2469 self,
2470 parent_player: Player,
2471 parent_protocol_player: Player | None,
2472 parent_protocol_domain: str | None,
2473 protocol_members: list[str],
2474 native_members: list[str],
2475 ) -> None:
2476 """
2477 Move the members selected for native grouping onto the protocol the group ended up on.
2478
2479 Does nothing unless the group ends up on one of the parent's protocols while the
2480 parent's native grouping needs the parent's own stream: only then do those members
2481 have no session left to attach to.
2482
2483 :param parent_player: The parent player being joined.
2484 :param parent_protocol_player: The protocol player selected for the group, if any.
2485 :param parent_protocol_domain: The protocol domain selected for the group, if any.
2486 :param protocol_members: The protocol member list the translated IDs are added to.
2487 :param native_members: The native member IDs, emptied when they are moved over.
2488 """
2489 if not (
2490 native_members
2491 and parent_protocol_domain
2492 and parent_protocol_player
2493 and parent_protocol_player.player_id != parent_player.player_id
2494 and parent_player.native_grouping_requires_own_stream
2495 ):
2496 return
2497 protocol_members.extend(
2498 self._translate_native_members_to_protocol(
2499 parent_player, parent_protocol_domain, native_members
2500 )
2501 )
2502 native_members.clear()
2503
2504 def _migrate_stranded_native_members(
2505 self,
2506 parent_player: Player,
2507 parent_protocol_player: Player,
2508 protocol_members: list[str],
2509 ) -> list[str]:
2510 """
2511 Move the native members that a switch to the given protocol strands onto that protocol.
2512
2513 Returns the member IDs that are still attached to the parent's own stream, so the
2514 caller can release them from it. Empty unless the parent renders that stream itself
2515 while its native grouping attaches the members to exactly that stream: only then are
2516 they left without anything to play.
2517
2518 :param parent_player: The parent player that is about to switch protocol.
2519 :param parent_protocol_player: The protocol player the parent will render through.
2520 :param protocol_members: The protocol member list the translated IDs are added to.
2521 """
2522 if parent_protocol_player.player_id == parent_player.player_id:
2523 return []
2524 if not parent_player.native_grouping_requires_own_stream:
2525 return []
2526 if parent_player.active_output_protocol not in (None, "native"):
2527 return []
2528 if parent_player.state.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
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 _stop_native_session(
2543 self,
2544 parent_player: Player,
2545 parent_protocol_player: Player,
2546 stranded_native_members: list[str],
2547 ) -> None:
2548 """
2549 Release the given members from the parent's own stream and stop it.
2550
2551 :param parent_player: The parent player whose native session is handed over.
2552 :param parent_protocol_player: The protocol player taking the output over.
2553 :param stranded_native_members: The members to release, already migrated.
2554 """
2555 self.logger.debug(
2556 "Stopping the native session of %s before switching to %s, migrated members: %s",
2557 parent_player.state.name,
2558 parent_protocol_player.state.name,
2559 stranded_native_members,
2560 )
2561 # The members already joined the protocol group, so the native session only has to
2562 # release them and stop. Releasing them first also clears the native group, which
2563 # keeps a later native playback command from resurrecting it. Both calls take the
2564 # provider's own lock, so they must run one after the other.
2565 await parent_player.set_members(player_ids_to_remove=stranded_native_members)
2566 await parent_player.stop()
2567
2568 def _translate_members_for_protocols(
2569 self,
2570 parent_player: Player,
2571 player_ids: list[str],
2572 parent_protocol_player: Player | None,
2573 parent_protocol_domain: str | None,
2574 ) -> tuple[list[str], list[str], Player | None, str | None]:
2575 """
2576 Translate member IDs to protocol or native IDs.
2577
2578 The grouping method is picked per member, see _select_grouping_for_member.
2579
2580 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
2581 """
2582 protocol_members: list[str] = []
2583 native_members: list[str] = []
2584 parent_supports_native_grouping = (
2585 PlayerFeature.SET_MEMBERS in parent_player.supported_features
2586 )
2587 player_ids = self._order_members_for_native_join(
2588 player_ids, parent_player, parent_supports_native_grouping
2589 )
2590
2591 self.logger.log(
2592 VERBOSE_LOG_LEVEL,
2593 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
2594 parent_player.state.name,
2595 parent_supports_native_grouping,
2596 parent_protocol_player.state.name if parent_protocol_player else "none",
2597 parent_protocol_domain or "none",
2598 )
2599
2600 for child_player_id in player_ids:
2601 child_player = self.get_player(child_player_id)
2602 if not child_player:
2603 continue
2604
2605 self.logger.log(
2606 VERBOSE_LOG_LEVEL,
2607 "Processing child %s (type=%s, protocols=%s)",
2608 child_player.state.name,
2609 child_player.state.type,
2610 [p.protocol_domain for p in child_player.output_protocols],
2611 )
2612
2613 parent_protocol_player, parent_protocol_domain = self._select_grouping_for_member(
2614 child_player,
2615 parent_player,
2616 parent_protocol_player,
2617 parent_protocol_domain,
2618 parent_supports_native_grouping,
2619 protocol_members,
2620 native_members,
2621 )
2622
2623 # Post-pass: the protocol selected for the group is only known once every child has
2624 # been processed, so the members picked for native grouping are corrected here.
2625 self._move_native_members_to_group_protocol(
2626 parent_player,
2627 parent_protocol_player,
2628 parent_protocol_domain,
2629 protocol_members,
2630 native_members,
2631 )
2632
2633 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
2634
2635 def _select_grouping_for_member(
2636 self,
2637 child_player: Player,
2638 parent_player: Player,
2639 parent_protocol_player: Player | None,
2640 parent_protocol_domain: str | None,
2641 parent_supports_native_grouping: bool,
2642 protocol_members: list[str],
2643 native_members: list[str],
2644 ) -> tuple[Player | None, str | None]:
2645 """
2646 Pick the grouping method for a single member and add it to the matching member list.
2647
2648 Selection priority when grouping:
2649 0. If the parent is already playing natively and the child can be grouped
2650 natively, join that native session (a child joining an existing group must
2651 not force the whole group onto its own preferred output protocol)
2652 1. Try child's preferred output protocol (from player settings)
2653 2. Try parent's active output protocol (if any and child supports it)
2654 3. Try native grouping (if parent and child are compatible)
2655 4. Search for common protocol that supports set_members
2656 5. Log warning if no option works
2657
2658 Returns the protocol player/domain the group is on, which the picked method may have
2659 changed.
2660
2661 :param child_player: The player being added to the group.
2662 :param parent_player: The parent player being joined.
2663 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2664 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2665 :param parent_supports_native_grouping: Whether the parent can group natively.
2666 :param protocol_members: The protocol member list to append to.
2667 :param native_members: The native member list to append to.
2668 """
2669 # Priority 0: The parent is already playing natively and the child can join
2670 # that native session directly - adopt it before considering the child's own
2671 # preferred output protocol.
2672 if self._try_join_active_native_session(
2673 child_player,
2674 parent_player,
2675 parent_protocol_domain,
2676 parent_supports_native_grouping,
2677 native_members,
2678 ):
2679 return parent_protocol_player, parent_protocol_domain
2680
2681 # Priority 0.5: a player that runs its own multiroom (e.g. a LinkPlay control shell)
2682 # keeps grouping on its native path rather than routing it through a linked protocol
2683 # that is merely its preferred playback output. Native compatibility still decides
2684 # whether this is possible, so an incompatible/cross-backend pair falls through.
2685 if child_player.prefer_native_grouping and self._can_use_native_grouping(
2686 child_player, parent_player, parent_supports_native_grouping
2687 ):
2688 native_members.append(child_player.player_id)
2689 self.logger.log(
2690 VERBOSE_LOG_LEVEL,
2691 "Using native grouping (preferred) for %s",
2692 child_player.state.name,
2693 )
2694 return parent_protocol_player, parent_protocol_domain
2695
2696 # Priority 1: the child's preferred output protocol
2697 grouped, parent_protocol_player, parent_protocol_domain = (
2698 self._try_group_via_preferred_protocol(
2699 child_player,
2700 parent_player,
2701 parent_protocol_player,
2702 parent_protocol_domain,
2703 protocol_members,
2704 )
2705 )
2706 if grouped:
2707 return parent_protocol_player, parent_protocol_domain
2708
2709 # Priority 2: the protocol the group is already on
2710 grouped, parent_protocol_player, parent_protocol_domain = (
2711 self._try_group_via_active_protocol(
2712 child_player,
2713 parent_protocol_player,
2714 parent_protocol_domain,
2715 protocol_members,
2716 )
2717 )
2718 if grouped:
2719 return parent_protocol_player, parent_protocol_domain
2720
2721 # Priority 3: native grouping
2722 if self._can_use_native_grouping(
2723 child_player, parent_player, parent_supports_native_grouping
2724 ):
2725 native_members.append(child_player.player_id)
2726 self.logger.log(
2727 VERBOSE_LOG_LEVEL,
2728 "Using native grouping for %s",
2729 child_player.state.name,
2730 )
2731 return parent_protocol_player, parent_protocol_domain
2732
2733 # Priority 4: a protocol both players share that supports set_members
2734 grouped, parent_protocol_player, parent_protocol_domain = (
2735 self._try_group_via_common_protocol(
2736 child_player,
2737 parent_player,
2738 parent_protocol_player,
2739 parent_protocol_domain,
2740 protocol_members,
2741 )
2742 )
2743 if grouped:
2744 return parent_protocol_player, parent_protocol_domain
2745
2746 # Priority 5: no option worked
2747 self.logger.warning(
2748 "Cannot group %s with %s: no compatible grouping method found "
2749 "(tried: child preferred protocol, parent active protocol, "
2750 "native grouping, common protocols)",
2751 child_player.state.name,
2752 parent_player.state.name,
2753 )
2754 return parent_protocol_player, parent_protocol_domain
2755
2756 def _try_group_via_preferred_protocol(
2757 self,
2758 child_player: Player,
2759 parent_player: Player,
2760 parent_protocol_player: Player | None,
2761 parent_protocol_domain: str | None,
2762 protocol_members: list[str],
2763 ) -> tuple[bool, Player | None, str | None]:
2764 """
2765 Try to group the child through the output protocol it prefers in its player settings.
2766
2767 Only used when the group is not on a protocol yet or is already on that same protocol.
2768 Returns whether the child was grouped, together with the protocol player/domain the
2769 group is on: the child's preferred protocol may become the group's protocol.
2770
2771 :param child_player: The player being added to the group.
2772 :param parent_player: The parent player being joined.
2773 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2774 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2775 :param protocol_members: The protocol member list to append to.
2776 """
2777 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
2778 child_player, parent_player
2779 )
2780 if not (
2781 child_protocol_id
2782 and protocol_domain
2783 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
2784 ):
2785 return False, parent_protocol_player, parent_protocol_domain
2786
2787 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
2788 parent_protocol = parent_player.get_output_protocol_by_domain(protocol_domain)
2789 if parent_protocol:
2790 parent_protocol_player = parent_player.get_protocol_player(
2791 parent_protocol.output_protocol_id
2792 )
2793 parent_protocol_domain = protocol_domain
2794 protocol_members.append(child_protocol_id)
2795 self.logger.log(
2796 VERBOSE_LOG_LEVEL,
2797 "Using child's preferred protocol %s for %s",
2798 protocol_domain,
2799 child_player.state.name,
2800 )
2801 return True, parent_protocol_player, parent_protocol_domain
2802
2803 def _try_group_via_active_protocol(
2804 self,
2805 child_player: Player,
2806 parent_protocol_player: Player | None,
2807 parent_protocol_domain: str | None,
2808 protocol_members: list[str],
2809 ) -> tuple[bool, Player | None, str | None]:
2810 """
2811 Try to group the child through the protocol the group is already on.
2812
2813 Returns whether the child was grouped, together with the protocol player/domain the
2814 group is on: the selection is dropped when that protocol cannot group members itself,
2815 so a later grouping method can select another one.
2816
2817 :param child_player: The player being added to the group.
2818 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2819 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2820 :param protocol_members: The protocol member list to append to.
2821 """
2822 if not parent_protocol_domain or not parent_protocol_player:
2823 return False, parent_protocol_player, parent_protocol_domain
2824
2825 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2826 self.logger.log(
2827 VERBOSE_LOG_LEVEL,
2828 "Parent's active protocol %s does not support SET_MEMBERS, "
2829 "will search for alternative",
2830 parent_protocol_domain,
2831 )
2832 # Drop the selection so a later grouping method can select a new protocol
2833 return False, None, None
2834
2835 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2836 if not child_protocol or not child_protocol.available:
2837 return False, parent_protocol_player, parent_protocol_domain
2838
2839 # For native protocol players, use the child's player_id directly
2840 # (e.g., a native sendspin web player IS the protocol player)
2841 child_protocol_id = (
2842 child_player.player_id
2843 if child_protocol.is_native
2844 else child_protocol.output_protocol_id
2845 )
2846 protocol_members.append(child_protocol_id)
2847 self.logger.log(
2848 VERBOSE_LOG_LEVEL,
2849 "Using parent's active protocol %s for %s",
2850 parent_protocol_domain,
2851 child_player.state.name,
2852 )
2853 return True, parent_protocol_player, parent_protocol_domain
2854
2855 def _try_group_via_common_protocol(
2856 self,
2857 child_player: Player,
2858 parent_player: Player,
2859 parent_protocol_player: Player | None,
2860 parent_protocol_domain: str | None,
2861 protocol_members: list[str],
2862 ) -> tuple[bool, Player | None, str | None]:
2863 """
2864 Try to group the child through a protocol both players share.
2865
2866 Returns whether the child was grouped, together with the protocol player/domain the
2867 group is on: the shared protocol may become the group's protocol.
2868
2869 :param child_player: The player being added to the group.
2870 :param parent_player: The parent player being joined.
2871 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2872 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2873 :param protocol_members: The protocol member list to append to.
2874 """
2875 parent_protocol, child_protocol = self._try_find_common_protocol(
2876 child_player, parent_player
2877 )
2878 if not parent_protocol or not child_protocol:
2879 return False, parent_protocol_player, parent_protocol_domain
2880
2881 if not parent_protocol_player or parent_protocol_domain != parent_protocol.protocol_domain:
2882 parent_protocol_player = parent_player.get_protocol_player(
2883 parent_protocol.output_protocol_id
2884 )
2885 if parent_protocol_player:
2886 parent_protocol_domain = parent_protocol_player.provider.domain
2887 # For native protocol players, use the child's player_id directly
2888 child_protocol_id = (
2889 child_player.player_id
2890 if child_protocol.is_native
2891 else child_protocol.output_protocol_id
2892 )
2893 protocol_members.append(child_protocol_id)
2894 self.logger.log(
2895 VERBOSE_LOG_LEVEL,
2896 "Selected common protocol %s for grouping %s with %s",
2897 parent_protocol.protocol_domain,
2898 child_player.state.name,
2899 parent_player.state.name,
2900 )
2901 return True, parent_protocol_player, parent_protocol_domain
2902
2903 async def _forward_protocol_set_members(
2904 self,
2905 parent_player: Player,
2906 parent_protocol_player: Player,
2907 protocol_members_to_add: list[str],
2908 protocol_members_to_remove: list[str],
2909 ) -> None:
2910 """
2911 Forward protocol members to protocol player's set_members and manage active output protocol.
2912
2913 :param parent_player: The parent player (native/universal).
2914 :param parent_protocol_player: The protocol player to forward commands to.
2915 :param protocol_members_to_add: Protocol player IDs to add.
2916 :param protocol_members_to_remove: Protocol player IDs to remove.
2917 """
2918 filtered_protocol_add = self._filter_protocol_members(
2919 protocol_members_to_add, parent_protocol_player
2920 )
2921 filtered_protocol_remove = self._filter_protocol_members(
2922 protocol_members_to_remove, parent_protocol_player
2923 )
2924 self.logger.debug(
2925 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
2926 parent_protocol_player.state.name,
2927 filtered_protocol_add,
2928 filtered_protocol_remove,
2929 )
2930
2931 if not filtered_protocol_add and not filtered_protocol_remove:
2932 return
2933
2934 # Safety check: verify protocol player supports SET_MEMBERS
2935 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2936 self.logger.error(
2937 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
2938 "This should have been caught earlier in the flow.",
2939 parent_protocol_player.state.name,
2940 )
2941 return
2942
2943 # Members that ride the parent's own stream are stranded by the protocol switch below,
2944 # so they join the protocol group in this very same call and their native session is
2945 # torn down afterwards.
2946 stranded_native_members = (
2947 self._migrate_stranded_native_members(
2948 parent_player, parent_protocol_player, filtered_protocol_add
2949 )
2950 if filtered_protocol_add
2951 else []
2952 )
2953
2954 # This runs before set_members because a member's own stream starts inside that call
2955 # and the provider resolves the member's volume control as it starts: unless its parent
2956 # already points at this protocol, that resolution picks a sibling interface of the same
2957 # device (e.g. its cast side) over the one carrying the audio.
2958 self._activate_protocol_on_added_children(filtered_protocol_add)
2959
2960 self.logger.debug(
2961 "Calling set_members on protocol player %s with add=%s, remove=%s",
2962 parent_protocol_player.state.name,
2963 filtered_protocol_add,
2964 filtered_protocol_remove,
2965 )
2966 await parent_protocol_player.set_members(
2967 player_ids_to_add=filtered_protocol_add or None,
2968 player_ids_to_remove=filtered_protocol_remove or None,
2969 )
2970
2971 if filtered_protocol_add:
2972 await self._activate_group_output_protocol(
2973 parent_player, parent_protocol_player, stranded_native_members
2974 )
2975
2976 self.logger.debug(
2977 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
2978 parent_protocol_player.state.name,
2979 parent_protocol_player.group_members,
2980 parent_protocol_player.synced_to,
2981 )
2982
2983 def _activate_protocol_on_added_children(self, protocol_member_ids: list[str]) -> None:
2984 """
2985 Point the parent of each given protocol member at the protocol carrying the group audio.
2986
2987 :param protocol_member_ids: The protocol player IDs joining the group.
2988 """
2989 for child_protocol_id in protocol_member_ids:
2990 if not (child_protocol := self.get_player(child_protocol_id)):
2991 continue
2992 if not child_protocol.protocol_parent_id:
2993 continue
2994 if not (child_player := self.get_player(child_protocol.protocol_parent_id)):
2995 continue
2996 if child_player.active_output_protocol == child_protocol_id:
2997 continue
2998 self.logger.debug(
2999 "Setting active output protocol on child %s to %s",
3000 child_player.state.name,
3001 child_protocol_id,
3002 )
3003 child_player.set_active_output_protocol(child_protocol_id)
3004
3005 async def _activate_group_output_protocol(
3006 self,
3007 parent_player: Player,
3008 parent_protocol_player: Player,
3009 stranded_native_members: list[str],
3010 ) -> None:
3011 """
3012 Mark the given protocol as the parent's output and hand the playback over to it.
3013
3014 The handover only runs when the parent is actually switching protocol while it is
3015 rendering; playback is resumed only for a parent that was playing, so adding a member
3016 never starts playback on its own.
3017
3018 :param parent_player: The parent player that just gained protocol members.
3019 :param parent_protocol_player: The protocol player the members joined.
3020 :param stranded_native_members: The members left without a stream by the switch.
3021 """
3022 previous_protocol = parent_player.active_output_protocol
3023 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
3024 # A paused player still holds its output, so the handover has to run for it too.
3025 was_rendering = was_playing or parent_player.state.playback_state == PlaybackState.PAUSED
3026
3027 # Native protocol: parent_protocol_player is the same as parent_player
3028 is_native_protocol = parent_protocol_player.player_id == parent_player.player_id
3029 already_using_native = previous_protocol in (None, "native")
3030 already_using_this_protocol = previous_protocol == parent_protocol_player.player_id
3031 switching_protocols = not (
3032 (is_native_protocol and already_using_native) or already_using_this_protocol
3033 )
3034
3035 self.logger.debug(
3036 "Protocol grouping: is_native=%s, already_native=%s, already_this=%s, "
3037 "switching=%s, was_rendering=%s",
3038 is_native_protocol,
3039 already_using_native,
3040 already_using_this_protocol,
3041 switching_protocols,
3042 was_rendering,
3043 )
3044
3045 if not (is_native_protocol and already_using_native):
3046 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
3047
3048 if not (was_rendering and switching_protocols):
3049 return
3050
3051 self.logger.info(
3052 "Handing the output of %s over to the %s protocol%s",
3053 parent_player.state.name,
3054 parent_protocol_player.provider.domain,
3055 " and resuming playback" if was_playing else "",
3056 )
3057 if stranded_native_members:
3058 await self._stop_native_session(
3059 parent_player, parent_protocol_player, stranded_native_members
3060 )
3061 old_parent_members = await self._stop_previous_protocol(
3062 parent_player, parent_protocol_player, previous_protocol
3063 )
3064 if was_playing:
3065 await self.mass.players.cmd_resume(parent_player.player_id)
3066 if old_parent_members:
3067 self.logger.debug(
3068 "Re-adding migrated members %s to %s on new protocol",
3069 old_parent_members,
3070 parent_player.state.name,
3071 )
3072 # Use internal handler because we are already inside a
3073 # _handle_set_members call chain that holds the play lock.
3074 await self.mass.players._handle_set_members(
3075 parent_player,
3076 player_ids_to_add=old_parent_members,
3077 )
3078
3079 async def _stop_previous_protocol(
3080 self,
3081 parent_player: Player,
3082 parent_protocol_player: Player,
3083 previous_protocol: str | None,
3084 ) -> list[str]:
3085 """
3086 Stop the protocol player the parent was rendering through and return its members.
3087
3088 The returned IDs are parent player IDs, so the caller can re-add them to the group
3089 once the new protocol carries the audio. Empty if there is nothing to hand over.
3090
3091 :param parent_player: The parent player that is switching protocol.
3092 :param parent_protocol_player: The protocol player taking the output over.
3093 :param previous_protocol: The parent's previous active output protocol, if any.
3094 """
3095 if previous_protocol in (None, "native"):
3096 return []
3097 if not (old_protocol_player := self.get_player(previous_protocol)):
3098 return []
3099 if old_protocol_player.player_id == parent_protocol_player.player_id:
3100 return []
3101 # Translate the old protocol's child members back to parent player IDs
3102 old_parent_members: list[str] = []
3103 for member_id in old_protocol_player.group_members:
3104 if member_id == old_protocol_player.player_id:
3105 continue
3106 if not (member_player := self.get_player(member_id)):
3107 continue
3108 parent_id = member_player.protocol_parent_id or member_id
3109 if parent_id != parent_player.player_id:
3110 old_parent_members.append(parent_id)
3111 self.logger.debug(
3112 "Stopping old protocol player %s before switching to %s, migrating members: %s",
3113 old_protocol_player.state.name,
3114 parent_protocol_player.state.name,
3115 old_parent_members,
3116 )
3117 # Use internal handler to stop the specific protocol player,
3118 # bypassing group/sync redirect and queue redirect logic.
3119 await self.mass.players._handle_cmd_stop(old_protocol_player.player_id)
3120 return old_parent_members
3121