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