/
/
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 # drop only the active edge and leave the rest to the link evaluation,
1780 # which replaces the wrapper with this player: a leftover edge makes it
1781 # hand the player over to itself, which it refuses, abandoning the swap
1782 self._remove_protocol_link(parent, existing.player_id)
1783 return
1784 existing.set_protocol_parent_id(parent_id)
1785 # unlink at the parent and drop the persisted parent id, which would
1786 # otherwise heal the player's type back to protocol
1787 self._unlink_from_protocol_parent(existing)
1788 # a player leaving the protocol role has no parent, also when that parent
1789 # is not registered (anymore) and only the cached link could be cleaned up
1790 existing.set_protocol_parent_id(None)
1791 return
1792 # the player becomes a child itself: detach the protocol players it owned so they
1793 # can find a new parent, then give up their ownership in its (kept) config - the
1794 # reverse of the removal path, which drops the ownership before the detach
1795 protocol_ids = set(self._get_known_protocol_ids(existing))
1796 self._detach_owned_protocols(existing)
1797 self._remove_protocol_ids_from_parent(existing, protocol_ids)
1798
1799 def _detach_protocol_children(self, parent_id: str) -> None:
1800 """
1801 Detach the registered protocol players of a parent player that is going away.
1802
1803 Covers the removal paths that don't unregister the parent first (e.g. its
1804 provider is unloaded), where the parent is not around anymore to enumerate
1805 its protocol players.
1806
1807 :param parent_id: Player id of the parent that is being removed.
1808 """
1809 for protocol_player in list(self._players.values()):
1810 if protocol_player.state.type != PlayerType.PROTOCOL:
1811 continue
1812 # a protocol player waiting for a parent that never registered only has
1813 # the link in its config, so fall back to the cached parent
1814 linked_parent_id = protocol_player.protocol_parent_id or (
1815 self._get_cached_protocol_parent_id(protocol_player.player_id)
1816 )
1817 if linked_parent_id != parent_id:
1818 continue
1819 self.logger.debug(
1820 "Player %s removed - scheduling evaluation for protocol %s",
1821 parent_id,
1822 protocol_player.player_id,
1823 )
1824 self._detach_protocol_child(protocol_player)
1825
1826 def _detach_protocol_child(self, protocol_player: Player) -> None:
1827 """Clear a protocol player's parent link and schedule a fresh evaluation."""
1828 self._clear_protocol_parent_id(protocol_player.player_id)
1829 protocol_player.set_protocol_parent_id(None)
1830 protocol_player.refresh_state()
1831 self._schedule_protocol_evaluation(protocol_player)
1832
1833 def _identifiers_match(
1834 self, player_a: Player, player_b: Player, protocol_domain: str = ""
1835 ) -> bool:
1836 """
1837 Check if identifiers match between two players.
1838
1839 Matching is done by comparing connection identifiers (MAC, serial, UUID).
1840 As a last resort, IP address is used when at least one player has a
1841 locally-administered MAC, indicating the device uses MAC randomization
1842 and ARP could not resolve the real hardware address.
1843
1844 Invalid identifiers (e.g., 00:00:00:00:00:00 MAC addresses) are filtered out
1845 to prevent false matches between unrelated devices.
1846 """
1847 identifiers_a = player_a.device_info.identifiers
1848 identifiers_b = player_b.device_info.identifiers
1849
1850 # Check identifiers in order of reliability
1851 # MAC_ADDRESS > SERIAL_NUMBER > UUID > CAST_UUID > AIRPLAY_ID
1852 for conn_type in (
1853 IdentifierType.MAC_ADDRESS,
1854 IdentifierType.SERIAL_NUMBER,
1855 IdentifierType.UUID,
1856 IdentifierType.CAST_UUID,
1857 IdentifierType.AIRPLAY_ID,
1858 ):
1859 val_a = identifiers_a.get(conn_type)
1860 val_b = identifiers_b.get(conn_type)
1861
1862 if not val_a or not val_b:
1863 continue
1864
1865 # Filter out invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
1866 if conn_type == IdentifierType.MAC_ADDRESS:
1867 if not is_valid_mac_address(val_a) or not is_valid_mac_address(val_b):
1868 self.logger.log(
1869 VERBOSE_LOG_LEVEL,
1870 "Skipping invalid MAC address for matching: %s=%s, %s=%s",
1871 player_a.display_name,
1872 val_a,
1873 player_b.display_name,
1874 val_b,
1875 )
1876 continue
1877
1878 # Normalize values for comparison
1879 if conn_type == IdentifierType.MAC_ADDRESS:
1880 # Use MAC normalization that handles locally-administered bit differences
1881 # Some protocols (like AirPlay) report a locally-administered MAC variant
1882 # where bit 1 of the first octet is set (e.g., 54:78:... vs 56:78:...)
1883 val_a_norm = normalize_mac_for_matching(val_a)
1884 val_b_norm = normalize_mac_for_matching(val_b)
1885
1886 # Direct match on current MAC
1887 if val_a_norm == val_b_norm:
1888 return True
1889
1890 # Multi-MAC matching: also check original reported MACs.
1891 # Devices with multiple interfaces (WiFi + Ethernet) may have ARP
1892 # resolve one MAC while the protocol reports a different one.
1893 macs_a = {val_a_norm}
1894 macs_b = {val_b_norm}
1895 reported_a = player_a.extra_data.get("reported_mac")
1896 reported_b = player_b.extra_data.get("reported_mac")
1897 if reported_a and is_valid_mac_address(reported_a):
1898 macs_a.add(normalize_mac_for_matching(reported_a))
1899 if reported_b and is_valid_mac_address(reported_b):
1900 macs_b.add(normalize_mac_for_matching(reported_b))
1901 if macs_a & macs_b:
1902 return True
1903
1904 # No MAC match - continue to next identifier type
1905 continue
1906
1907 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
1908 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
1909
1910 # Direct match
1911 if val_a_norm == val_b_norm:
1912 return True
1913
1914 # Special case: Sonos UUID matching with DLNA _MR suffix
1915 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
1916 if conn_type == IdentifierType.UUID:
1917 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
1918 return True
1919 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
1920 return True
1921
1922 # Last resort: IP-based matching.
1923 # Two players on the same IP are very likely the same physical device.
1924 # This handles two cases:
1925 # 1. MAC randomization: at least one player has no real MAC (LA or missing),
1926 # so ARP couldn't resolve a usable address.
1927 # 2. Different MACs per protocol: some devices (e.g., Yamaha MusicCast) report
1928 # different valid globally-unique MACs per protocol (DLNA vs AirPlay differ
1929 # by 1 in the last octet). IP matching is safe here because two different
1930 # devices on a LAN cannot share the same IP simultaneously.
1931 # To avoid false positives between unrelated native players, this path
1932 # requires at least one player to be a protocol or universal player.
1933 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
1934 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
1935 if ip_a and ip_b and ip_a == ip_b:
1936 mac_a = identifiers_a.get(IdentifierType.MAC_ADDRESS)
1937 mac_b = identifiers_b.get(IdentifierType.MAC_ADDRESS)
1938 a_is_real = (
1939 mac_a is not None
1940 and is_valid_mac_address(mac_a)
1941 and not is_locally_administered_mac(mac_a)
1942 )
1943 b_is_real = (
1944 mac_b is not None
1945 and is_valid_mac_address(mac_b)
1946 and not is_locally_administered_mac(mac_b)
1947 )
1948 # Case 1: at least one player has no real hardware MAC
1949 if not (a_is_real and b_is_real):
1950 return True
1951 # Case 2: both have real MACs but at least one is a protocol/universal player
1952 a_is_protocol = (
1953 player_a.type == PlayerType.PROTOCOL
1954 or player_a.provider.domain == "universal_player"
1955 )
1956 b_is_protocol = (
1957 player_b.type == PlayerType.PROTOCOL
1958 or player_b.provider.domain == "universal_player"
1959 )
1960 if a_is_protocol or b_is_protocol:
1961 return True
1962
1963 return False
1964
1965 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
1966 """
1967 Select the best available output protocol for a player.
1968
1969 Selection priority:
1970 1. Output protocol that is currently grouped/synced with other players.
1971 2. User's preferred output protocol (from player settings).
1972 3. Native playback (if player supports PLAY_MEDIA).
1973 4. The player's declared default output protocol domain, if available.
1974 5. Best available protocol by priority.
1975
1976 Returns tuple of (target_player, output_protocol).
1977 output_protocol is None when using native playback.
1978 """
1979 self.logger.log(
1980 VERBOSE_LOG_LEVEL,
1981 "Selecting output protocol for %s",
1982 player.state.name,
1983 )
1984
1985 # 1. Check if any output protocol is currently grouped
1986 for linked in player.linked_output_protocols:
1987 if protocol_player := self.get_player(linked.output_protocol_id):
1988 if protocol_player.available_for_playback and self._is_protocol_grouped(
1989 protocol_player
1990 ):
1991 self.logger.log(
1992 VERBOSE_LOG_LEVEL,
1993 "Selected protocol for %s: %s (grouped)",
1994 player.state.name,
1995 protocol_player.state.name,
1996 )
1997 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
1998
1999 # 2. Check for user's preferred output protocol.
2000 # The value is only stored while it differs from the entry's default: "native" when a
2001 # native output is available, otherwise "auto". A player without a native output (e.g. a
2002 # LinkPlay shell) therefore has no stored preference by default and gets its default
2003 # output domain applied in step 4.
2004 preferred = self.mass.config.get_raw_player_config_value(
2005 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2006 )
2007 if preferred and preferred != "auto":
2008 if preferred == "native":
2009 if PlayerFeature.PLAY_MEDIA in player.supported_features:
2010 self.logger.log(
2011 VERBOSE_LOG_LEVEL,
2012 "Selected protocol for %s: native (user preference)",
2013 player.state.name,
2014 )
2015 return player, None
2016 else:
2017 for linked in player.linked_output_protocols:
2018 if linked.output_protocol_id == preferred:
2019 if protocol_player := self.get_player(linked.output_protocol_id):
2020 if protocol_player.available_for_playback:
2021 self.logger.log(
2022 VERBOSE_LOG_LEVEL,
2023 "Selected protocol for %s: %s (user preference)",
2024 player.state.name,
2025 protocol_player.state.name,
2026 )
2027 return protocol_player, player.get_linked_protocol(
2028 linked.output_protocol_id
2029 )
2030 break
2031
2032 # 3. Use native playback if available
2033 if PlayerFeature.PLAY_MEDIA in player.supported_features:
2034 self.logger.log(
2035 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
2036 )
2037 return player, None
2038
2039 # 4. Use the player's preferred default protocol domain, if it declares one and a
2040 # matching linked protocol is available (e.g. a LinkPlay shell prefers DLNA). This
2041 # never influences grouping; it only steers the default output for playback. "Auto"
2042 # is the entry default here, so it consistently resolves to this domain default.
2043 if default_domain := player.default_output_protocol_domain:
2044 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2045 if linked.protocol_domain != default_domain:
2046 continue
2047 if (protocol_player := self.get_player(linked.output_protocol_id)) and (
2048 protocol_player.available_for_playback
2049 ):
2050 self.logger.log(
2051 VERBOSE_LOG_LEVEL,
2052 "Selected protocol for %s: %s (default domain %s)",
2053 player.state.name,
2054 protocol_player.state.name,
2055 default_domain,
2056 )
2057 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
2058
2059 # 5. Fall back to best protocol by priority
2060 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2061 if protocol_player := self.get_player(linked.output_protocol_id):
2062 if protocol_player.available_for_playback:
2063 self.logger.log(
2064 VERBOSE_LOG_LEVEL,
2065 "Selected protocol for %s: %s (priority-based)",
2066 player.state.name,
2067 protocol_player.state.name,
2068 )
2069 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
2070
2071 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
2072
2073 def _get_control_target(
2074 self,
2075 player: Player,
2076 required_feature: PlayerFeature,
2077 require_active: bool = False,
2078 ) -> Player | None:
2079 """
2080 Get the best player(protocol) to send audio-path commands to.
2081
2082 Resolves commands that travel with the audio (enqueue, pause, announcements),
2083 so the output that renders the audio outranks the native player. Volume and
2084 mute are control-plane instead and resolve through
2085 :meth:`Player._get_protocol_player_for_feature`, which orders differently.
2086
2087 :param player: The player the command was issued on.
2088 :param required_feature: The feature the resolved target has to support.
2089 :param require_active: Only accept the output that is already rendering,
2090 instead of falling back to an idle one.
2091 """
2092 # If we have an active protocol, use that
2093 if (
2094 player.active_output_protocol
2095 and player.active_output_protocol != "native"
2096 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
2097 and required_feature in protocol_player.supported_features
2098 ):
2099 return protocol_player
2100
2101 # if the player natively supports the required feature, use that
2102 if (
2103 player.active_output_protocol == "native"
2104 and required_feature in player.supported_features
2105 ):
2106 return player
2107
2108 # If require_active is set, and no active protocol found, return None
2109 if require_active:
2110 return None
2111
2112 # if the player natively supports the required feature, use that
2113 if required_feature in player.supported_features:
2114 return player
2115
2116 # An output the user explicitly picked owns the audio, so a command that has to
2117 # start playback on an idle player follows it rather than the priority below.
2118 # The stored value survives a relink, so it only counts while it still names one
2119 # of this player's own outputs.
2120 preferred = self.mass.config.get_raw_player_config_value(
2121 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2122 )
2123 if preferred and preferred not in ("auto", "native"):
2124 for linked in player.linked_output_protocols:
2125 if linked.output_protocol_id != preferred:
2126 continue
2127 if (
2128 (preferred_player := self.mass.players.get_player(str(preferred)))
2129 and preferred_player.available_for_playback
2130 and required_feature in preferred_player.supported_features
2131 ):
2132 return preferred_player
2133 break
2134
2135 # Otherwise, use the best available linked protocol, ordered by the same
2136 # priority that regular playback selection applies.
2137 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
2138 if (
2139 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
2140 and protocol_player.available_for_playback
2141 and required_feature in protocol_player.supported_features
2142 ):
2143 return protocol_player
2144
2145 return None
2146
2147 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
2148 """
2149 Check if a protocol player is currently grouped/synced with other players.
2150
2151 Used to prefer protocols that are actively participating in a group,
2152 ensuring consistent playback across grouped players.
2153 """
2154 is_grouped = bool(
2155 protocol_player.state.synced_to
2156 or (
2157 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
2158 )
2159 or protocol_player.state.active_group
2160 )
2161 if is_grouped:
2162 self.logger.log(
2163 VERBOSE_LOG_LEVEL,
2164 "Protocol player %s is grouped",
2165 protocol_player.state.name,
2166 )
2167 return is_grouped
2168
2169 def _translate_members_to_remove_for_protocols(
2170 self,
2171 parent_player: Player,
2172 player_ids: list[str],
2173 parent_protocol_player: Player | None,
2174 parent_protocol_domain: str | None,
2175 ) -> tuple[list[str], list[str]]:
2176 """
2177 Translate member IDs to remove into protocol and native lists.
2178
2179 :param parent_player: The parent player to remove members from.
2180 :param player_ids: List of visible player IDs to remove.
2181 :param parent_protocol_player: The parent's protocol player if available.
2182 :param parent_protocol_domain: The parent's protocol domain if available.
2183 """
2184 self.logger.debug(
2185 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
2186 parent_player.state.name,
2187 player_ids,
2188 parent_protocol_domain,
2189 )
2190 protocol_members: list[str] = []
2191 native_members: list[str] = []
2192
2193 for child_player_id in player_ids:
2194 child_player = self.get_player(child_player_id)
2195 if not child_player:
2196 continue
2197
2198 # Check if this member is in the parent's group via protocol
2199 if parent_protocol_domain and parent_protocol_player:
2200 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2201 if child_protocol and child_protocol.available:
2202 # For native protocol players, use the child's player_id directly
2203 child_protocol_id = (
2204 child_player.player_id
2205 if child_protocol.is_native
2206 else child_protocol.output_protocol_id
2207 )
2208 if child_protocol_id in parent_protocol_player.group_members:
2209 self.logger.debug(
2210 "Translating removal: %s -> protocol %s",
2211 child_player_id,
2212 child_protocol_id,
2213 )
2214 protocol_members.append(child_protocol_id)
2215 continue
2216
2217 # Check if child's protocol player is in parent's native group_members
2218 # This handles native protocol players (e.g., native AirPlay player like Apple TV)
2219 # where the parent itself contains protocol player IDs in its group_members
2220 translated = False
2221 for linked in child_player.linked_output_protocols:
2222 if linked.output_protocol_id in parent_player.group_members:
2223 self.logger.debug(
2224 "Translating removal (native parent): %s -> protocol %s",
2225 child_player_id,
2226 linked.output_protocol_id,
2227 )
2228 native_members.append(linked.output_protocol_id)
2229 translated = True
2230 break
2231
2232 if not translated:
2233 native_members.append(child_player_id)
2234
2235 return protocol_members, native_members
2236
2237 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
2238 """Filter member IDs to only include players from the same protocol domain."""
2239 return [
2240 pid
2241 for pid in member_ids
2242 if (p := self.get_player(pid)) and p.provider.domain == protocol_player.provider.domain
2243 ]
2244
2245 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
2246 """Filter member IDs to only include players compatible with the parent."""
2247 return [
2248 pid
2249 for pid in member_ids
2250 if (p := self.get_player(pid))
2251 and (
2252 p.provider.instance_id == parent_player.provider.instance_id
2253 or pid in parent_player._attr_can_group_with
2254 or p.provider.instance_id in parent_player._attr_can_group_with
2255 )
2256 ]
2257
2258 def _try_child_preferred_protocol(
2259 self,
2260 child_player: Player,
2261 parent_player: Player,
2262 ) -> tuple[str | None, str | None]:
2263 """
2264 Try to use child's preferred output protocol for grouping.
2265
2266 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
2267 """
2268 child_preferred = self.mass.config.get_raw_player_config_value(
2269 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2270 )
2271 if not child_preferred or child_preferred in {"auto", "native"}:
2272 return None, None
2273
2274 # Find child's preferred protocol, with its current availability
2275 child_protocol = None
2276 for output_protocol in child_player.output_protocols:
2277 if output_protocol.output_protocol_id == child_preferred:
2278 child_protocol = output_protocol
2279 break
2280
2281 if not child_protocol or not child_protocol.available:
2282 return None, None
2283
2284 # Check if parent supports this protocol (including native protocol)
2285 parent_protocol = parent_player.get_output_protocol_by_domain(
2286 child_protocol.protocol_domain
2287 )
2288 if not parent_protocol or not parent_protocol.available:
2289 return None, None
2290
2291 # Check if this protocol supports set_members
2292 protocol_player = parent_player.get_protocol_player(parent_protocol.output_protocol_id)
2293 if (
2294 not protocol_player
2295 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
2296 ):
2297 return None, None
2298
2299 return child_protocol.output_protocol_id, child_protocol.protocol_domain
2300
2301 def _can_use_native_grouping(
2302 self,
2303 child_player: Player,
2304 parent_player: Player,
2305 parent_supports_native: bool,
2306 ) -> bool:
2307 """Check if child can be grouped with parent using native grouping."""
2308 if not parent_supports_native:
2309 return False
2310 return (
2311 parent_player.is_native_group_compatible(child_player)
2312 or child_player.player_id in parent_player._attr_can_group_with
2313 or child_player.provider.instance_id in parent_player._attr_can_group_with
2314 )
2315
2316 def _try_find_common_protocol(
2317 self, child_player: Player, parent_player: Player
2318 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
2319 """
2320 Find common protocol that supports set_members.
2321
2322 Returns tuple of (parent_protocol, child_protocol) or (None, None).
2323 """
2324 for parent_output_protocol in parent_player.output_protocols:
2325 if not parent_output_protocol.available:
2326 continue
2327 child_protocol = child_player.get_output_protocol_by_domain(
2328 parent_output_protocol.protocol_domain
2329 )
2330 if not child_protocol or not child_protocol.available:
2331 continue
2332 protocol_player = parent_player.get_protocol_player(
2333 parent_output_protocol.output_protocol_id
2334 )
2335 if protocol_player and PlayerFeature.SET_MEMBERS in protocol_player.supported_features:
2336 return parent_output_protocol, child_protocol
2337 return None, None
2338
2339 def _parent_has_live_native_session(self, parent_player: Player) -> bool:
2340 """
2341 Return True when the parent currently holds a live native playback session.
2342
2343 The active output protocol lingers for a few seconds after stop, so a non-idle
2344 playback state is required to distinguish a real session from a just-stopped one.
2345 """
2346 return parent_player.active_output_protocol == "native" and (
2347 parent_player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2348 )
2349
2350 def _order_members_for_native_join(
2351 self,
2352 player_ids: list[str],
2353 parent_player: Player,
2354 parent_supports_native_grouping: bool,
2355 ) -> list[str]:
2356 """
2357 Order members so a live native session can be joined without splitting the group.
2358
2359 When the parent already holds a live native session, children that cannot group
2360 natively are evaluated first: they may force a shared protocol for the whole group,
2361 and processing them before the native-capable children lets those join that same
2362 protocol instead of being stranded in a separate native sub-group. The order is left
2363 untouched when the parent is not playing natively, so fresh-group selection is unchanged.
2364
2365 :param player_ids: The member IDs to be added, in their original order.
2366 :param parent_player: The parent player being joined.
2367 :param parent_supports_native_grouping: Whether the parent can group natively.
2368 """
2369 if not self._parent_has_live_native_session(parent_player):
2370 return player_ids
2371 return sorted(
2372 player_ids,
2373 key=lambda pid: bool(
2374 (child := self.get_player(pid))
2375 and self._can_use_native_grouping(
2376 child, parent_player, parent_supports_native_grouping
2377 )
2378 ),
2379 )
2380
2381 def _try_join_active_native_session(
2382 self,
2383 child_player: Player,
2384 parent_player: Player,
2385 parent_protocol_domain: str | None,
2386 parent_supports_native_grouping: bool,
2387 native_members: list[str],
2388 ) -> bool:
2389 """
2390 Add the child to native_members if it can join the parent's active native session.
2391
2392 A child's preferred output protocol must only steer protocol selection when the child
2393 initiates its own playback; when it joins a parent that is already playing natively it
2394 should adopt native grouping if compatible, rather than forcing the whole group onto
2395 the child's preferred protocol. Skipped once a protocol has been selected for the group,
2396 so mixed batches stay cohesive on a single protocol.
2397
2398 :param child_player: The player being added to the group.
2399 :param parent_player: The parent player being joined.
2400 :param parent_protocol_domain: The protocol domain already selected for the group, if any.
2401 :param parent_supports_native_grouping: Whether the parent can group natively.
2402 :param native_members: The native members list to append to when the child joins.
2403 """
2404 if not (
2405 self._parent_has_live_native_session(parent_player)
2406 and not parent_protocol_domain
2407 and self._can_use_native_grouping(
2408 child_player, parent_player, parent_supports_native_grouping
2409 )
2410 ):
2411 return False
2412 native_members.append(child_player.player_id)
2413 self.logger.log(
2414 VERBOSE_LOG_LEVEL,
2415 "Joining parent's active native session for %s",
2416 child_player.state.name,
2417 )
2418 return True
2419
2420 def _translate_native_members_to_protocol(
2421 self, parent_player: Player, protocol_domain: str, member_ids: list[str]
2422 ) -> list[str]:
2423 """
2424 Translate natively grouped members onto the protocol domain the parent plays through.
2425
2426 Members that do not have that protocol cannot follow the parent at all and are
2427 dropped with a warning instead.
2428
2429 :param parent_player: The parent player the members are grouped with.
2430 :param protocol_domain: The protocol domain selected for the group.
2431 :param member_ids: The member IDs that were selected for native grouping.
2432 """
2433 translated: list[str] = []
2434 for member_id in member_ids:
2435 member_player = self.get_player(member_id)
2436 if not member_player:
2437 continue
2438 # a native group's members may be listed by their protocol player id
2439 if member_player.protocol_parent_id:
2440 member_player = self.get_player(member_player.protocol_parent_id) or member_player
2441 member_protocol = member_player.get_output_protocol_by_domain(protocol_domain)
2442 if not member_protocol or not member_protocol.available:
2443 self.logger.warning(
2444 "Cannot group %s with %s: the group plays through the %s protocol, "
2445 "which %s does not support",
2446 member_player.state.name,
2447 parent_player.state.name,
2448 protocol_domain,
2449 member_player.state.name,
2450 )
2451 continue
2452 # For native protocol players, use the member's player_id directly
2453 translated.append(
2454 member_player.player_id
2455 if member_protocol.is_native
2456 else member_protocol.output_protocol_id
2457 )
2458 self.logger.log(
2459 VERBOSE_LOG_LEVEL,
2460 "Moving %s from native grouping to the %s protocol",
2461 member_player.state.name,
2462 protocol_domain,
2463 )
2464 return translated
2465
2466 def _move_native_members_to_group_protocol(
2467 self,
2468 parent_player: Player,
2469 parent_protocol_player: Player | None,
2470 parent_protocol_domain: str | None,
2471 protocol_members: list[str],
2472 native_members: list[str],
2473 ) -> None:
2474 """
2475 Move the members selected for native grouping onto the protocol the group ended up on.
2476
2477 Does nothing unless the group ends up on one of the parent's protocols while the
2478 parent's native grouping needs the parent's own stream: only then do those members
2479 have no session left to attach to.
2480
2481 :param parent_player: The parent player being joined.
2482 :param parent_protocol_player: The protocol player selected for the group, if any.
2483 :param parent_protocol_domain: The protocol domain selected for the group, if any.
2484 :param protocol_members: The protocol member list the translated IDs are added to.
2485 :param native_members: The native member IDs, emptied when they are moved over.
2486 """
2487 if not (
2488 native_members
2489 and parent_protocol_domain
2490 and parent_protocol_player
2491 and parent_protocol_player.player_id != parent_player.player_id
2492 and parent_player.native_grouping_requires_own_stream
2493 ):
2494 return
2495 protocol_members.extend(
2496 self._translate_native_members_to_protocol(
2497 parent_player, parent_protocol_domain, native_members
2498 )
2499 )
2500 native_members.clear()
2501
2502 def _migrate_stranded_native_members(
2503 self,
2504 parent_player: Player,
2505 parent_protocol_player: Player,
2506 protocol_members: list[str],
2507 ) -> list[str]:
2508 """
2509 Move the native members that a switch to the given protocol strands onto that protocol.
2510
2511 Returns the member IDs that are still attached to the parent's own stream, so the
2512 caller can release them from it. Empty unless the parent renders that stream itself
2513 while its native grouping attaches the members to exactly that stream: only then are
2514 they left without anything to play.
2515
2516 :param parent_player: The parent player that is about to switch protocol.
2517 :param parent_protocol_player: The protocol player the parent will render through.
2518 :param protocol_members: The protocol member list the translated IDs are added to.
2519 """
2520 if parent_protocol_player.player_id == parent_player.player_id:
2521 return []
2522 if not parent_player.native_grouping_requires_own_stream:
2523 return []
2524 if parent_player.active_output_protocol not in (None, "native"):
2525 return []
2526 if parent_player.state.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
2527 return []
2528 stranded = [
2529 member_id
2530 for member_id in parent_player.group_members
2531 if member_id != parent_player.player_id
2532 ]
2533 for protocol_id in self._translate_native_members_to_protocol(
2534 parent_player, parent_protocol_player.provider.domain, stranded
2535 ):
2536 if protocol_id not in protocol_members:
2537 protocol_members.append(protocol_id)
2538 return stranded
2539
2540 async def _stop_native_session(
2541 self,
2542 parent_player: Player,
2543 parent_protocol_player: Player,
2544 stranded_native_members: list[str],
2545 ) -> None:
2546 """
2547 Release the given members from the parent's own stream and stop it.
2548
2549 :param parent_player: The parent player whose native session is handed over.
2550 :param parent_protocol_player: The protocol player taking the output over.
2551 :param stranded_native_members: The members to release, already migrated.
2552 """
2553 self.logger.debug(
2554 "Stopping the native session of %s before switching to %s, migrated members: %s",
2555 parent_player.state.name,
2556 parent_protocol_player.state.name,
2557 stranded_native_members,
2558 )
2559 # The members already joined the protocol group, so the native session only has to
2560 # release them and stop. Releasing them first also clears the native group, which
2561 # keeps a later native playback command from resurrecting it. Both calls take the
2562 # provider's own lock, so they must run one after the other.
2563 await parent_player.set_members(player_ids_to_remove=stranded_native_members)
2564 await parent_player.stop()
2565
2566 def _translate_members_for_protocols(
2567 self,
2568 parent_player: Player,
2569 player_ids: list[str],
2570 parent_protocol_player: Player | None,
2571 parent_protocol_domain: str | None,
2572 ) -> tuple[list[str], list[str], Player | None, str | None]:
2573 """
2574 Translate member IDs to protocol or native IDs.
2575
2576 The grouping method is picked per member, see _select_grouping_for_member.
2577
2578 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
2579 """
2580 protocol_members: list[str] = []
2581 native_members: list[str] = []
2582 parent_supports_native_grouping = (
2583 PlayerFeature.SET_MEMBERS in parent_player.supported_features
2584 )
2585 player_ids = self._order_members_for_native_join(
2586 player_ids, parent_player, parent_supports_native_grouping
2587 )
2588
2589 self.logger.log(
2590 VERBOSE_LOG_LEVEL,
2591 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
2592 parent_player.state.name,
2593 parent_supports_native_grouping,
2594 parent_protocol_player.state.name if parent_protocol_player else "none",
2595 parent_protocol_domain or "none",
2596 )
2597
2598 for child_player_id in player_ids:
2599 child_player = self.get_player(child_player_id)
2600 if not child_player:
2601 continue
2602
2603 self.logger.log(
2604 VERBOSE_LOG_LEVEL,
2605 "Processing child %s (type=%s, protocols=%s)",
2606 child_player.state.name,
2607 child_player.state.type,
2608 [p.protocol_domain for p in child_player.output_protocols],
2609 )
2610
2611 parent_protocol_player, parent_protocol_domain = self._select_grouping_for_member(
2612 child_player,
2613 parent_player,
2614 parent_protocol_player,
2615 parent_protocol_domain,
2616 parent_supports_native_grouping,
2617 protocol_members,
2618 native_members,
2619 )
2620
2621 # Post-pass: the protocol selected for the group is only known once every child has
2622 # been processed, so the members picked for native grouping are corrected here.
2623 self._move_native_members_to_group_protocol(
2624 parent_player,
2625 parent_protocol_player,
2626 parent_protocol_domain,
2627 protocol_members,
2628 native_members,
2629 )
2630
2631 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
2632
2633 def _select_grouping_for_member(
2634 self,
2635 child_player: Player,
2636 parent_player: Player,
2637 parent_protocol_player: Player | None,
2638 parent_protocol_domain: str | None,
2639 parent_supports_native_grouping: bool,
2640 protocol_members: list[str],
2641 native_members: list[str],
2642 ) -> tuple[Player | None, str | None]:
2643 """
2644 Pick the grouping method for a single member and add it to the matching member list.
2645
2646 Selection priority when grouping:
2647 0. If the parent is already playing natively and the child can be grouped
2648 natively, join that native session (a child joining an existing group must
2649 not force the whole group onto its own preferred output protocol)
2650 1. Try child's preferred output protocol (from player settings)
2651 2. Try parent's active output protocol (if any and child supports it)
2652 3. Try native grouping (if parent and child are compatible)
2653 4. Search for common protocol that supports set_members
2654 5. Log warning if no option works
2655
2656 Returns the protocol player/domain the group is on, which the picked method may have
2657 changed.
2658
2659 :param child_player: The player being added to the group.
2660 :param parent_player: The parent player being joined.
2661 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2662 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2663 :param parent_supports_native_grouping: Whether the parent can group natively.
2664 :param protocol_members: The protocol member list to append to.
2665 :param native_members: The native member list to append to.
2666 """
2667 # Priority 0: The parent is already playing natively and the child can join
2668 # that native session directly - adopt it before considering the child's own
2669 # preferred output protocol.
2670 if self._try_join_active_native_session(
2671 child_player,
2672 parent_player,
2673 parent_protocol_domain,
2674 parent_supports_native_grouping,
2675 native_members,
2676 ):
2677 return parent_protocol_player, parent_protocol_domain
2678
2679 # Priority 0.5: a player that runs its own multiroom (e.g. a LinkPlay control shell)
2680 # keeps grouping on its native path rather than routing it through a linked protocol
2681 # that is merely its preferred playback output. Native compatibility still decides
2682 # whether this is possible, so an incompatible/cross-backend pair falls through.
2683 if child_player.prefer_native_grouping and self._can_use_native_grouping(
2684 child_player, parent_player, parent_supports_native_grouping
2685 ):
2686 native_members.append(child_player.player_id)
2687 self.logger.log(
2688 VERBOSE_LOG_LEVEL,
2689 "Using native grouping (preferred) for %s",
2690 child_player.state.name,
2691 )
2692 return parent_protocol_player, parent_protocol_domain
2693
2694 # Priority 1: the child's preferred output protocol
2695 grouped, parent_protocol_player, parent_protocol_domain = (
2696 self._try_group_via_preferred_protocol(
2697 child_player,
2698 parent_player,
2699 parent_protocol_player,
2700 parent_protocol_domain,
2701 protocol_members,
2702 )
2703 )
2704 if grouped:
2705 return parent_protocol_player, parent_protocol_domain
2706
2707 # Priority 2: the protocol the group is already on
2708 grouped, parent_protocol_player, parent_protocol_domain = (
2709 self._try_group_via_active_protocol(
2710 child_player,
2711 parent_protocol_player,
2712 parent_protocol_domain,
2713 protocol_members,
2714 )
2715 )
2716 if grouped:
2717 return parent_protocol_player, parent_protocol_domain
2718
2719 # Priority 3: native grouping
2720 if self._can_use_native_grouping(
2721 child_player, parent_player, parent_supports_native_grouping
2722 ):
2723 native_members.append(child_player.player_id)
2724 self.logger.log(
2725 VERBOSE_LOG_LEVEL,
2726 "Using native grouping for %s",
2727 child_player.state.name,
2728 )
2729 return parent_protocol_player, parent_protocol_domain
2730
2731 # Priority 4: a protocol both players share that supports set_members
2732 grouped, parent_protocol_player, parent_protocol_domain = (
2733 self._try_group_via_common_protocol(
2734 child_player,
2735 parent_player,
2736 parent_protocol_player,
2737 parent_protocol_domain,
2738 protocol_members,
2739 )
2740 )
2741 if grouped:
2742 return parent_protocol_player, parent_protocol_domain
2743
2744 # Priority 5: no option worked
2745 self.logger.warning(
2746 "Cannot group %s with %s: no compatible grouping method found "
2747 "(tried: child preferred protocol, parent active protocol, "
2748 "native grouping, common protocols)",
2749 child_player.state.name,
2750 parent_player.state.name,
2751 )
2752 return parent_protocol_player, parent_protocol_domain
2753
2754 def _try_group_via_preferred_protocol(
2755 self,
2756 child_player: Player,
2757 parent_player: Player,
2758 parent_protocol_player: Player | None,
2759 parent_protocol_domain: str | None,
2760 protocol_members: list[str],
2761 ) -> tuple[bool, Player | None, str | None]:
2762 """
2763 Try to group the child through the output protocol it prefers in its player settings.
2764
2765 Only used when the group is not on a protocol yet or is already on that same protocol.
2766 Returns whether the child was grouped, together with the protocol player/domain the
2767 group is on: the child's preferred protocol may become the group's protocol.
2768
2769 :param child_player: The player being added to the group.
2770 :param parent_player: The parent player being joined.
2771 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2772 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2773 :param protocol_members: The protocol member list to append to.
2774 """
2775 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
2776 child_player, parent_player
2777 )
2778 if not (
2779 child_protocol_id
2780 and protocol_domain
2781 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
2782 ):
2783 return False, parent_protocol_player, parent_protocol_domain
2784
2785 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
2786 parent_protocol = parent_player.get_output_protocol_by_domain(protocol_domain)
2787 if parent_protocol:
2788 parent_protocol_player = parent_player.get_protocol_player(
2789 parent_protocol.output_protocol_id
2790 )
2791 parent_protocol_domain = protocol_domain
2792 protocol_members.append(child_protocol_id)
2793 self.logger.log(
2794 VERBOSE_LOG_LEVEL,
2795 "Using child's preferred protocol %s for %s",
2796 protocol_domain,
2797 child_player.state.name,
2798 )
2799 return True, parent_protocol_player, parent_protocol_domain
2800
2801 def _try_group_via_active_protocol(
2802 self,
2803 child_player: Player,
2804 parent_protocol_player: Player | None,
2805 parent_protocol_domain: str | None,
2806 protocol_members: list[str],
2807 ) -> tuple[bool, Player | None, str | None]:
2808 """
2809 Try to group the child through the protocol the group is already on.
2810
2811 Returns whether the child was grouped, together with the protocol player/domain the
2812 group is on: the selection is dropped when that protocol cannot group members itself,
2813 so a later grouping method can select another one.
2814
2815 :param child_player: The player being added to the group.
2816 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2817 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2818 :param protocol_members: The protocol member list to append to.
2819 """
2820 if not parent_protocol_domain or not parent_protocol_player:
2821 return False, parent_protocol_player, parent_protocol_domain
2822
2823 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2824 self.logger.log(
2825 VERBOSE_LOG_LEVEL,
2826 "Parent's active protocol %s does not support SET_MEMBERS, "
2827 "will search for alternative",
2828 parent_protocol_domain,
2829 )
2830 # Drop the selection so a later grouping method can select a new protocol
2831 return False, None, None
2832
2833 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2834 if not child_protocol or not child_protocol.available:
2835 return False, parent_protocol_player, parent_protocol_domain
2836
2837 # For native protocol players, use the child's player_id directly
2838 # (e.g., a native sendspin web player IS the protocol player)
2839 child_protocol_id = (
2840 child_player.player_id
2841 if child_protocol.is_native
2842 else child_protocol.output_protocol_id
2843 )
2844 protocol_members.append(child_protocol_id)
2845 self.logger.log(
2846 VERBOSE_LOG_LEVEL,
2847 "Using parent's active protocol %s for %s",
2848 parent_protocol_domain,
2849 child_player.state.name,
2850 )
2851 return True, parent_protocol_player, parent_protocol_domain
2852
2853 def _try_group_via_common_protocol(
2854 self,
2855 child_player: Player,
2856 parent_player: Player,
2857 parent_protocol_player: Player | None,
2858 parent_protocol_domain: str | None,
2859 protocol_members: list[str],
2860 ) -> tuple[bool, Player | None, str | None]:
2861 """
2862 Try to group the child through a protocol both players share.
2863
2864 Returns whether the child was grouped, together with the protocol player/domain the
2865 group is on: the shared protocol may become the group's protocol.
2866
2867 :param child_player: The player being added to the group.
2868 :param parent_player: The parent player being joined.
2869 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2870 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2871 :param protocol_members: The protocol member list to append to.
2872 """
2873 parent_protocol, child_protocol = self._try_find_common_protocol(
2874 child_player, parent_player
2875 )
2876 if not parent_protocol or not child_protocol:
2877 return False, parent_protocol_player, parent_protocol_domain
2878
2879 if not parent_protocol_player or parent_protocol_domain != parent_protocol.protocol_domain:
2880 parent_protocol_player = parent_player.get_protocol_player(
2881 parent_protocol.output_protocol_id
2882 )
2883 if parent_protocol_player:
2884 parent_protocol_domain = parent_protocol_player.provider.domain
2885 # For native protocol players, use the child's player_id directly
2886 child_protocol_id = (
2887 child_player.player_id
2888 if child_protocol.is_native
2889 else child_protocol.output_protocol_id
2890 )
2891 protocol_members.append(child_protocol_id)
2892 self.logger.log(
2893 VERBOSE_LOG_LEVEL,
2894 "Selected common protocol %s for grouping %s with %s",
2895 parent_protocol.protocol_domain,
2896 child_player.state.name,
2897 parent_player.state.name,
2898 )
2899 return True, parent_protocol_player, parent_protocol_domain
2900
2901 async def _forward_protocol_set_members(
2902 self,
2903 parent_player: Player,
2904 parent_protocol_player: Player,
2905 protocol_members_to_add: list[str],
2906 protocol_members_to_remove: list[str],
2907 ) -> None:
2908 """
2909 Forward protocol members to protocol player's set_members and manage active output protocol.
2910
2911 :param parent_player: The parent player (native/universal).
2912 :param parent_protocol_player: The protocol player to forward commands to.
2913 :param protocol_members_to_add: Protocol player IDs to add.
2914 :param protocol_members_to_remove: Protocol player IDs to remove.
2915 """
2916 filtered_protocol_add = self._filter_protocol_members(
2917 protocol_members_to_add, parent_protocol_player
2918 )
2919 filtered_protocol_remove = self._filter_protocol_members(
2920 protocol_members_to_remove, parent_protocol_player
2921 )
2922 self.logger.debug(
2923 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
2924 parent_protocol_player.state.name,
2925 filtered_protocol_add,
2926 filtered_protocol_remove,
2927 )
2928
2929 if not filtered_protocol_add and not filtered_protocol_remove:
2930 return
2931
2932 # Safety check: verify protocol player supports SET_MEMBERS
2933 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2934 self.logger.error(
2935 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
2936 "This should have been caught earlier in the flow.",
2937 parent_protocol_player.state.name,
2938 )
2939 return
2940
2941 # Members that ride the parent's own stream are stranded by the protocol switch below,
2942 # so they join the protocol group in this very same call and their native session is
2943 # torn down afterwards.
2944 stranded_native_members = (
2945 self._migrate_stranded_native_members(
2946 parent_player, parent_protocol_player, filtered_protocol_add
2947 )
2948 if filtered_protocol_add
2949 else []
2950 )
2951
2952 # This runs before set_members because a member's own stream starts inside that call
2953 # and the provider resolves the member's volume control as it starts: unless its parent
2954 # already points at this protocol, that resolution picks a sibling interface of the same
2955 # device (e.g. its cast side) over the one carrying the audio.
2956 self._activate_protocol_on_added_children(filtered_protocol_add)
2957
2958 self.logger.debug(
2959 "Calling set_members on protocol player %s with add=%s, remove=%s",
2960 parent_protocol_player.state.name,
2961 filtered_protocol_add,
2962 filtered_protocol_remove,
2963 )
2964 await parent_protocol_player.set_members(
2965 player_ids_to_add=filtered_protocol_add or None,
2966 player_ids_to_remove=filtered_protocol_remove or None,
2967 )
2968
2969 if filtered_protocol_add:
2970 await self._activate_group_output_protocol(
2971 parent_player, parent_protocol_player, stranded_native_members
2972 )
2973
2974 self.logger.debug(
2975 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
2976 parent_protocol_player.state.name,
2977 parent_protocol_player.group_members,
2978 parent_protocol_player.synced_to,
2979 )
2980
2981 def _activate_protocol_on_added_children(self, protocol_member_ids: list[str]) -> None:
2982 """
2983 Point the parent of each given protocol member at the protocol carrying the group audio.
2984
2985 :param protocol_member_ids: The protocol player IDs joining the group.
2986 """
2987 for child_protocol_id in protocol_member_ids:
2988 if not (child_protocol := self.get_player(child_protocol_id)):
2989 continue
2990 if not child_protocol.protocol_parent_id:
2991 continue
2992 if not (child_player := self.get_player(child_protocol.protocol_parent_id)):
2993 continue
2994 if child_player.active_output_protocol == child_protocol_id:
2995 continue
2996 self.logger.debug(
2997 "Setting active output protocol on child %s to %s",
2998 child_player.state.name,
2999 child_protocol_id,
3000 )
3001 child_player.set_active_output_protocol(child_protocol_id)
3002
3003 async def _activate_group_output_protocol(
3004 self,
3005 parent_player: Player,
3006 parent_protocol_player: Player,
3007 stranded_native_members: list[str],
3008 ) -> None:
3009 """
3010 Mark the given protocol as the parent's output and hand the playback over to it.
3011
3012 The handover only runs when the parent is actually switching protocol while it is
3013 rendering; playback is resumed only for a parent that was playing, so adding a member
3014 never starts playback on its own.
3015
3016 :param parent_player: The parent player that just gained protocol members.
3017 :param parent_protocol_player: The protocol player the members joined.
3018 :param stranded_native_members: The members left without a stream by the switch.
3019 """
3020 previous_protocol = parent_player.active_output_protocol
3021 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
3022 # A paused player still holds its output, so the handover has to run for it too.
3023 was_rendering = was_playing or parent_player.state.playback_state == PlaybackState.PAUSED
3024
3025 # Native protocol: parent_protocol_player is the same as parent_player
3026 is_native_protocol = parent_protocol_player.player_id == parent_player.player_id
3027 already_using_native = previous_protocol in (None, "native")
3028 already_using_this_protocol = previous_protocol == parent_protocol_player.player_id
3029 switching_protocols = not (
3030 (is_native_protocol and already_using_native) or already_using_this_protocol
3031 )
3032
3033 self.logger.debug(
3034 "Protocol grouping: is_native=%s, already_native=%s, already_this=%s, "
3035 "switching=%s, was_rendering=%s",
3036 is_native_protocol,
3037 already_using_native,
3038 already_using_this_protocol,
3039 switching_protocols,
3040 was_rendering,
3041 )
3042
3043 if not (is_native_protocol and already_using_native):
3044 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
3045
3046 if not (was_rendering and switching_protocols):
3047 return
3048
3049 self.logger.info(
3050 "Handing the output of %s over to the %s protocol%s",
3051 parent_player.state.name,
3052 parent_protocol_player.provider.domain,
3053 " and resuming playback" if was_playing else "",
3054 )
3055 if stranded_native_members:
3056 await self._stop_native_session(
3057 parent_player, parent_protocol_player, stranded_native_members
3058 )
3059 old_parent_members = await self._stop_previous_protocol(
3060 parent_player, parent_protocol_player, previous_protocol
3061 )
3062 if was_playing:
3063 await self.mass.players.cmd_resume(parent_player.player_id)
3064 if old_parent_members:
3065 self.logger.debug(
3066 "Re-adding migrated members %s to %s on new protocol",
3067 old_parent_members,
3068 parent_player.state.name,
3069 )
3070 # Use internal handler because we are already inside a
3071 # _handle_set_members call chain that holds the play lock.
3072 await self.mass.players._handle_set_members(
3073 parent_player,
3074 player_ids_to_add=old_parent_members,
3075 )
3076
3077 async def _stop_previous_protocol(
3078 self,
3079 parent_player: Player,
3080 parent_protocol_player: Player,
3081 previous_protocol: str | None,
3082 ) -> list[str]:
3083 """
3084 Stop the protocol player the parent was rendering through and return its members.
3085
3086 The returned IDs are parent player IDs, so the caller can re-add them to the group
3087 once the new protocol carries the audio. Empty if there is nothing to hand over.
3088
3089 :param parent_player: The parent player that is switching protocol.
3090 :param parent_protocol_player: The protocol player taking the output over.
3091 :param previous_protocol: The parent's previous active output protocol, if any.
3092 """
3093 if previous_protocol in (None, "native"):
3094 return []
3095 if not (old_protocol_player := self.get_player(previous_protocol)):
3096 return []
3097 if old_protocol_player.player_id == parent_protocol_player.player_id:
3098 return []
3099 # Translate the old protocol's child members back to parent player IDs
3100 old_parent_members: list[str] = []
3101 for member_id in old_protocol_player.group_members:
3102 if member_id == old_protocol_player.player_id:
3103 continue
3104 if not (member_player := self.get_player(member_id)):
3105 continue
3106 parent_id = member_player.protocol_parent_id or member_id
3107 if parent_id != parent_player.player_id:
3108 old_parent_members.append(parent_id)
3109 self.logger.debug(
3110 "Stopping old protocol player %s before switching to %s, migrating members: %s",
3111 old_protocol_player.state.name,
3112 parent_protocol_player.state.name,
3113 old_parent_members,
3114 )
3115 # Use internal handler to stop the specific protocol player,
3116 # bypassing group/sync redirect and queue redirect logic.
3117 await self.mass.players._handle_cmd_stop(old_protocol_player.player_id)
3118 return old_parent_members
3119