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