/
/
/
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, so the source entry is removed once it is carried over.
1120
1121 :param universal_id: Player id of the obsolete universal player.
1122 :param native_id: Player id of the native player that replaces it.
1123 :return: True if any queue setting was carried over.
1124 """
1125 queue_changed = False
1126 source_queue_raw = self.mass.config.get(f"{CONF_PLAYER_QUEUES}/{universal_id}")
1127 source_queue_raw = source_queue_raw if isinstance(source_queue_raw, dict) else None
1128 if source_queue_values := (source_queue_raw or {}).get("values"):
1129 target_queue_key = f"{CONF_PLAYER_QUEUES}/{native_id}"
1130 target_queue_raw = self.mass.config.get(target_queue_key)
1131 target_queue_raw = (
1132 deepcopy(target_queue_raw) if isinstance(target_queue_raw, dict) else {}
1133 )
1134 target_queue_values = target_queue_raw.setdefault("values", {})
1135 for key, value in source_queue_values.items():
1136 if key in target_queue_values:
1137 continue
1138 target_queue_values[key] = deepcopy(value)
1139 queue_changed = True
1140 if queue_changed:
1141 target_queue_raw["queue_id"] = native_id
1142 self.mass.config.set(target_queue_key, target_queue_raw)
1143 if source_queue_raw is not None:
1144 self.mass.config.remove(f"{CONF_PLAYER_QUEUES}/{universal_id}")
1145 return queue_changed
1146
1147 async def _reapply_player_config(self, player_id: str) -> None:
1148 """Reload the stored config onto a registered player and refresh its state."""
1149 if not (player := self.get_player(player_id)):
1150 return
1151 config = await self.mass.config.get_player_config(player_id)
1152 player.set_config(config)
1153 player.update_state()
1154 self.mass.signal_event(EventType.PLAYER_CONFIG_UPDATED, object_id=player_id, data=config)
1155
1156 def _repoint_group_memberships(self, old_player_id: str, new_player_id: str) -> None:
1157 """
1158 Re-point group memberships from a removed player to its successor.
1159
1160 When a universal player is replaced by a native player or merged into
1161 another universal player, other players that list the removed player as
1162 a group member (or allowed member) must follow its successor so those
1163 memberships are not silently lost. Updates the persisted config and keeps
1164 any registered player whose membership changed in sync.
1165
1166 :param old_player_id: Player id that is being removed.
1167 :param new_player_id: Player id that replaces it.
1168 """
1169 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
1170 if not isinstance(all_player_configs, dict):
1171 return
1172 for other_id, other_cfg in all_player_configs.items():
1173 if not isinstance(other_cfg, dict):
1174 continue
1175 other_values = other_cfg.get("values")
1176 if not isinstance(other_values, dict):
1177 continue
1178 for key in (CONF_GROUP_MEMBERS, CONF_ALLOWED_MEMBERS):
1179 members = other_values.get(key)
1180 if not isinstance(members, list) or old_player_id not in members:
1181 continue
1182 new_members: list[str] = []
1183 for member_id in members:
1184 resolved = new_player_id if member_id == old_player_id else member_id
1185 if resolved not in new_members:
1186 new_members.append(resolved)
1187 self.mass.config.set(f"{CONF_PLAYERS}/{other_id}/values/{key}", new_members)
1188 # keep a registered player's in-place config copy and state in sync
1189 if other_player := self.get_player(other_id):
1190 if entry := other_player.config.values.get(key):
1191 entry.value = new_members
1192 other_player.refresh_state()
1193
1194 async def _stop_and_unregister(self, player: Player) -> None:
1195 """
1196 Stop active playback on a player and then permanently unregister it.
1197
1198 Used when an obsolete universal player is replaced or merged away: while
1199 it is not idle its protocol child keeps playing the dead queue's stream
1200 until the buffer drains, so playback is stopped first. Queue ownership is
1201 intentionally not transferred.
1202
1203 :param player: The obsolete player to stop and permanently remove.
1204 """
1205 if player.playback_state != PlaybackState.IDLE:
1206 with suppress(PlayerCommandFailed, PlayerUnavailableError):
1207 await self.mass.player_queues.stop(player.player_id)
1208 await self.unregister(player.player_id, permanent=True)
1209
1210 def _parent_has_active_protocol_from_domain(
1211 self, parent: Player, domain: str, exclude_player_id: str | None = None
1212 ) -> bool:
1213 """
1214 Check if a parent already has an active (registered) protocol player from a given domain.
1215
1216 This prevents a second protocol player of the same domain (e.g., a second AirPlay
1217 instance on the same host) from replacing the first one's link on the same parent.
1218
1219 :param parent: The parent player to check.
1220 :param domain: The protocol domain to check for (e.g., "airplay", "dlna").
1221 :param exclude_player_id: Optional player ID to exclude from the check
1222 (used when checking if a player's own domain is already linked).
1223 """
1224 for link in parent.linked_output_protocols:
1225 if link.protocol_domain != domain:
1226 continue
1227 if exclude_player_id and link.output_protocol_id == exclude_player_id:
1228 continue
1229 # A registered player from this domain blocks the link, even if unavailable.
1230 # Being offline doesn't make it a different device — it's still occupying
1231 # this domain slot. The provider should remove stale players explicitly.
1232 if self.get_player(link.output_protocol_id):
1233 return True
1234 return False
1235
1236 def _add_protocol_link(
1237 self, native_player: Player, protocol_player: Player, protocol_domain: str
1238 ) -> None:
1239 """Add a protocol link from native player to protocol player."""
1240 # Never link a player to itself (hides it as its own protocol child).
1241 if native_player.player_id == protocol_player.player_id:
1242 return
1243 # Guard: refuse to replace an existing active link from the same domain.
1244 # This prevents a second instance of the same protocol (e.g., two AirPlay
1245 # instances on the same host) from silently replacing the first one.
1246 if self._parent_has_active_protocol_from_domain(
1247 native_player, protocol_domain, exclude_player_id=protocol_player.player_id
1248 ):
1249 self.logger.debug(
1250 "Refusing to link %s to %s: parent already has an active %s link",
1251 protocol_player.player_id,
1252 native_player.player_id,
1253 protocol_domain,
1254 )
1255 return
1256
1257 # Remove any existing link for the same protocol domain
1258 updated_protocols = [
1259 link
1260 for link in native_player.linked_output_protocols
1261 if link.protocol_domain != protocol_domain
1262 ]
1263
1264 # Get priority for this protocol
1265 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
1266
1267 # Derived transports (e.g. a Sendspin bridge riding on an AirPlay player)
1268 # reference the base output they run on top of; "native" when they ride
1269 # on the parent player itself
1270 derived_from = protocol_player.underlying_player_id
1271 if derived_from == native_player.player_id:
1272 derived_from = "native"
1273
1274 # Add the new link
1275 updated_protocols.append(
1276 OutputProtocol(
1277 output_protocol_id=protocol_player.player_id,
1278 name=protocol_player.provider.name,
1279 protocol_domain=protocol_domain,
1280 priority=priority,
1281 derived_from=derived_from,
1282 )
1283 )
1284 native_player.set_linked_output_protocols(updated_protocols)
1285
1286 # Set protocol player's parent
1287 protocol_player.set_protocol_parent_id(native_player.player_id)
1288
1289 # Persist linked protocol IDs to config for fast restart
1290 # (only for non-universal players, as universal players handle this themselves)
1291 if native_player.provider.domain != "universal_player":
1292 self._save_linked_protocol_ids(native_player)
1293 # Always save the parent ID on the protocol player for reverse lookup on restart
1294 # (needed for both native and universal parents to enable fast restore)
1295 self._save_protocol_parent_id(protocol_player.player_id, native_player.player_id)
1296
1297 # The freshly linked player may have derived protocol players (e.g. a
1298 # Sendspin bridge riding on it) waiting to join the same parent.
1299 self._link_derived_protocols_of(protocol_player)
1300
1301 def _remove_protocol_link(
1302 self, native_player: Player, protocol_player_id: str, permanent: bool = False
1303 ) -> None:
1304 """
1305 Remove a protocol link.
1306
1307 :param native_player: The parent player to remove the link from.
1308 :param protocol_player_id: The protocol player ID to unlink.
1309 :param permanent: If True, also removes the protocol ID from the cached list.
1310 Use this when the protocol player config is being deleted. If False,
1311 the protocol ID remains in the cache so it can be shown as disabled
1312 and re-enabled later.
1313 """
1314 updated_protocols = [
1315 link
1316 for link in native_player.linked_output_protocols
1317 if link.output_protocol_id != protocol_player_id
1318 ]
1319 native_player.set_linked_output_protocols(updated_protocols)
1320
1321 # Clear parent reference on protocol player if it still exists
1322 if protocol_player := self.get_player(protocol_player_id):
1323 if protocol_player.protocol_parent_id == native_player.player_id:
1324 protocol_player.set_protocol_parent_id(None)
1325
1326 # Update persisted linked protocol IDs
1327 if native_player.provider.domain != "universal_player":
1328 if permanent:
1329 # Permanently remove from cache (player config is being deleted)
1330 self._remove_protocol_id_from_cache(native_player.player_id, protocol_player_id)
1331 # Note: we don't call _save_linked_protocol_ids here anymore for non-permanent
1332 # removals because the merge approach will preserve the ID in the cache
1333 # Always clear the cached parent ID (for both native and universal parents)
1334 self._clear_protocol_parent_id(protocol_player_id)
1335
1336 def _save_linked_protocol_ids(self, native_player: Player) -> None:
1337 """
1338 Save linked protocol IDs to config for persistence across restarts.
1339
1340 This method merges active protocol IDs with existing cached IDs to preserve
1341 disabled protocol players in the cache. This allows disabled protocols to be
1342 shown in the UI so they can be re-enabled.
1343 """
1344 conf_key = f"{CONF_PLAYERS}/{native_player.player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
1345 # Get existing cached IDs to preserve disabled protocols
1346 existing_ids: list[str] = self.mass.config.get(conf_key, [])
1347 # Get currently active protocol IDs
1348 active_ids = {link.output_protocol_id for link in native_player.linked_output_protocols}
1349 # Merge: keep existing IDs and add any new active ones
1350 merged_ids = list(existing_ids)
1351 for protocol_id in active_ids:
1352 if protocol_id not in merged_ids:
1353 merged_ids.append(protocol_id)
1354 self.mass.config.set(conf_key, merged_ids)
1355
1356 def _get_cached_protocol_ids(self, player_id: str) -> list[str]:
1357 """Get cached linked protocol IDs from config."""
1358 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
1359 result = self.mass.config.get(conf_key, [])
1360 return list(result) if result else []
1361
1362 def _remove_protocol_id_from_cache(
1363 self, parent_player_id: str, protocol_player_id: str
1364 ) -> None:
1365 """
1366 Permanently remove a protocol player ID from the cached linked protocol IDs.
1367
1368 Use this when a protocol player config is being deleted, not just disabled.
1369 """
1370 conf_key = f"{CONF_PLAYERS}/{parent_player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
1371 cached_ids: list[str] = self.mass.config.get(conf_key, [])
1372 if protocol_player_id in cached_ids:
1373 cached_ids.remove(protocol_player_id)
1374 self.mass.config.set(conf_key, cached_ids)
1375
1376 def _save_protocol_parent_id(self, protocol_player_id: str, parent_id: str) -> None:
1377 """Save the parent ID for a protocol player for persistence across restarts."""
1378 # Only save if the player config still exists to avoid creating partial entries
1379 if not self.mass.config.get(f"{CONF_PLAYERS}/{protocol_player_id}"):
1380 return
1381 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
1382 self.mass.config.set(conf_key, parent_id)
1383
1384 def _save_underlying_player_id(self, player: Player) -> None:
1385 """
1386 Persist the derived-transport edge of a player to config.
1387
1388 Allows the edge to be resolved (e.g. by the config UI) even while the
1389 player is not registered. Clears a previously persisted edge when the
1390 player is no longer derived (e.g. a bridge client turned web player).
1391 """
1392 # Only save if the player config still exists to avoid creating partial entries
1393 if not self.mass.config.get(f"{CONF_PLAYERS}/{player.player_id}"):
1394 return
1395 conf_key = f"{CONF_PLAYERS}/{player.player_id}/values/{CONF_UNDERLYING_PLAYER_ID}"
1396 if player.underlying_player_id:
1397 self.mass.config.set(conf_key, player.underlying_player_id)
1398 elif self.mass.config.get(conf_key) is not None:
1399 self.mass.config.set(conf_key, None)
1400
1401 def _get_cached_protocol_parent_id(self, protocol_player_id: str) -> str | None:
1402 """Get cached parent ID for a protocol player from config."""
1403 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
1404 result = self.mass.config.get(conf_key, None)
1405 return str(result) if result else None
1406
1407 def _clear_protocol_parent_id(self, protocol_player_id: str) -> None:
1408 """Clear the cached parent ID for a protocol player."""
1409 # Only clear if the player config still exists to avoid creating partial entries
1410 if not self.mass.config.get(f"{CONF_PLAYERS}/{protocol_player_id}"):
1411 return
1412 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
1413 self.mass.config.set(conf_key, None)
1414
1415 def _recover_cached_protocol_links(self, native_player: Player) -> None:
1416 """
1417 Recover protocol links from config for disabled/missing protocols.
1418
1419 This ensures that disabled protocols show up in the output_protocols list
1420 so they can be re-enabled by the user. It also handles the case where
1421 protocol players haven't registered yet during startup.
1422 """
1423 # Get currently linked protocol IDs
1424 linked_protocol_ids = {
1425 link.output_protocol_id for link in native_player.linked_output_protocols
1426 }
1427
1428 # Get cached protocol IDs from config (includes protocols that were explicitly linked)
1429 cached_protocol_ids = self._get_cached_protocol_ids(native_player.player_id)
1430
1431 # Also check all protocol players that have protocol_parent_id pointing to this player
1432 # (this handles disabled protocols that may not be in linked_protocol_ids)
1433 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
1434 for protocol_id, protocol_config in all_player_configs.items():
1435 # Skip if not a protocol player
1436 if protocol_config.get("player_type") != "protocol":
1437 continue
1438 # Check if this protocol has a parent_id pointing to this native player
1439 protocol_values = protocol_config.get("values", {})
1440 protocol_parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
1441 if protocol_parent_id == native_player.player_id:
1442 if protocol_id not in cached_protocol_ids:
1443 cached_protocol_ids.append(protocol_id)
1444
1445 if not cached_protocol_ids:
1446 return
1447
1448 # Add OutputProtocol entries for any cached protocols that aren't currently linked
1449 for protocol_id in cached_protocol_ids:
1450 if protocol_id in linked_protocol_ids:
1451 continue # Already linked
1452
1453 # Get protocol player config to determine the protocol domain and availability
1454 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
1455 if not protocol_config:
1456 continue
1457
1458 # Determine protocol domain from provider
1459 protocol_provider: str = protocol_config.get("provider")
1460 if not protocol_provider:
1461 continue
1462
1463 # Extract domain from provider instance_id (e.g., "airplay--uuid" -> "airplay")
1464 protocol_domain = protocol_provider.split("--", maxsplit=1)[0]
1465
1466 # Skip if parent already has a link from this domain
1467 existing_domains = {
1468 link.protocol_domain for link in native_player.linked_output_protocols
1469 }
1470 if protocol_domain in existing_domains:
1471 continue
1472
1473 # Get provider name for display
1474 provider_name = protocol_domain.title() # Default fallback
1475 if provider := self.mass.get_provider(protocol_domain, return_unavailable=True):
1476 provider_name = provider.name
1477
1478 # Get priority for this protocol
1479 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
1480
1481 # Check if protocol player is available (registered)
1482 protocol_player = self.get_player(protocol_id)
1483 is_available = protocol_player is not None and protocol_player.available
1484
1485 # Resolve the derived-transport edge from the live player when
1486 # registered, else from the persisted edge in config
1487 derived_from = (
1488 protocol_player.underlying_player_id
1489 if protocol_player
1490 else protocol_config.get("values", {}).get(CONF_UNDERLYING_PLAYER_ID)
1491 )
1492 if derived_from == native_player.player_id:
1493 derived_from = "native"
1494
1495 # Add the OutputProtocol entry
1496 native_player.linked_output_protocols.append(
1497 OutputProtocol(
1498 output_protocol_id=protocol_id,
1499 name=provider_name,
1500 protocol_domain=protocol_domain,
1501 priority=priority,
1502 is_native=False,
1503 available=is_available,
1504 derived_from=derived_from,
1505 )
1506 )
1507 self.logger.debug(
1508 "Recovered cached protocol link %s -> %s (available: %s)",
1509 native_player.player_id,
1510 protocol_id,
1511 is_available,
1512 )
1513
1514 def _cleanup_protocol_links(self, player: Player) -> None:
1515 """Clean up protocol links when a player is permanently removed."""
1516 if player.state.type == PlayerType.PROTOCOL:
1517 # Protocol player being removed: remove link from parent
1518 if parent_id := player.protocol_parent_id:
1519 if parent_player := self.get_player(parent_id):
1520 # Use permanent=True to also remove from cached protocol IDs
1521 self._remove_protocol_link(parent_player, player.player_id, permanent=True)
1522 if (
1523 parent_player.provider.domain == "universal_player"
1524 and len(parent_player.linked_output_protocols) == 0
1525 ):
1526 # No protocols left - remove universal player
1527 self.logger.info(
1528 "Universal player %s has no protocols left, removing",
1529 parent_id,
1530 )
1531 self.mass.create_task(
1532 self.mass.players.unregister(parent_id, permanent=True)
1533 )
1534 else:
1535 parent_player.refresh_state()
1536 else:
1537 # Parent not registered yet — still purge the cached id
1538 self._remove_protocol_id_from_cache(parent_id, player.player_id)
1539 else:
1540 # Native/universal player being removed: handle all linked protocol players.
1541 # Collect all known protocol IDs from both active links and cached state,
1542 # since disabled/inactive protocols may only exist in the cached parent data.
1543 all_protocol_ids = set(self._get_known_protocol_ids(player))
1544 for protocol_id in all_protocol_ids:
1545 if protocol_player := self.get_player(protocol_id):
1546 # Protocol player is available: clear parent and schedule re-evaluation
1547 # so it can be matched to a new parent or a new universal player
1548 self.logger.debug(
1549 "Player %s removed - scheduling evaluation for protocol %s",
1550 player.player_id,
1551 protocol_id,
1552 )
1553 self._detach_protocol_child(protocol_player)
1554 else:
1555 # Clear cached parent ID in config so protocol won't try to
1556 # restore a link to the deleted player on next restart
1557 self._clear_protocol_parent_id(protocol_id)
1558 # Protocol player is not registered yet — it may still be
1559 # mid-discovery (e.g., DLNA connecting via SSDP). Don't delete
1560 # its config as that would cause a KeyError when it finishes
1561 # registering. Stale configs are harmless and get cleaned up
1562 # naturally on subsequent restarts.
1563 self.logger.debug(
1564 "Player %s removed - protocol %s not registered, skipping cleanup",
1565 player.player_id,
1566 protocol_id,
1567 )
1568
1569 def _detach_protocol_children(self, parent_id: str) -> None:
1570 """
1571 Detach the registered protocol players of a parent player that is going away.
1572
1573 Covers the removal paths that don't unregister the parent first (e.g. its
1574 provider is unloaded), where the parent is not around anymore to enumerate
1575 its protocol players.
1576
1577 :param parent_id: Player id of the parent that is being removed.
1578 """
1579 for protocol_player in list(self._players.values()):
1580 if protocol_player.state.type != PlayerType.PROTOCOL:
1581 continue
1582 # a protocol player waiting for a parent that never registered only has
1583 # the link in its config, so fall back to the cached parent
1584 linked_parent_id = protocol_player.protocol_parent_id or (
1585 self._get_cached_protocol_parent_id(protocol_player.player_id)
1586 )
1587 if linked_parent_id != parent_id:
1588 continue
1589 self.logger.debug(
1590 "Player %s removed - scheduling evaluation for protocol %s",
1591 parent_id,
1592 protocol_player.player_id,
1593 )
1594 self._detach_protocol_child(protocol_player)
1595
1596 def _detach_protocol_child(self, protocol_player: Player) -> None:
1597 """Clear a protocol player's parent link and schedule a fresh evaluation."""
1598 self._clear_protocol_parent_id(protocol_player.player_id)
1599 protocol_player.set_protocol_parent_id(None)
1600 protocol_player.refresh_state()
1601 self._schedule_protocol_evaluation(protocol_player)
1602
1603 def _identifiers_match(
1604 self, player_a: Player, player_b: Player, protocol_domain: str = ""
1605 ) -> bool:
1606 """
1607 Check if identifiers match between two players.
1608
1609 Matching is done by comparing connection identifiers (MAC, serial, UUID).
1610 As a last resort, IP address is used when at least one player has a
1611 locally-administered MAC, indicating the device uses MAC randomization
1612 and ARP could not resolve the real hardware address.
1613
1614 Invalid identifiers (e.g., 00:00:00:00:00:00 MAC addresses) are filtered out
1615 to prevent false matches between unrelated devices.
1616 """
1617 identifiers_a = player_a.device_info.identifiers
1618 identifiers_b = player_b.device_info.identifiers
1619
1620 # Check identifiers in order of reliability
1621 # MAC_ADDRESS > SERIAL_NUMBER > UUID > CAST_UUID > AIRPLAY_ID
1622 for conn_type in (
1623 IdentifierType.MAC_ADDRESS,
1624 IdentifierType.SERIAL_NUMBER,
1625 IdentifierType.UUID,
1626 IdentifierType.CAST_UUID,
1627 IdentifierType.AIRPLAY_ID,
1628 ):
1629 val_a = identifiers_a.get(conn_type)
1630 val_b = identifiers_b.get(conn_type)
1631
1632 if not val_a or not val_b:
1633 continue
1634
1635 # Filter out invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
1636 if conn_type == IdentifierType.MAC_ADDRESS:
1637 if not is_valid_mac_address(val_a) or not is_valid_mac_address(val_b):
1638 self.logger.log(
1639 VERBOSE_LOG_LEVEL,
1640 "Skipping invalid MAC address for matching: %s=%s, %s=%s",
1641 player_a.display_name,
1642 val_a,
1643 player_b.display_name,
1644 val_b,
1645 )
1646 continue
1647
1648 # Normalize values for comparison
1649 if conn_type == IdentifierType.MAC_ADDRESS:
1650 # Use MAC normalization that handles locally-administered bit differences
1651 # Some protocols (like AirPlay) report a locally-administered MAC variant
1652 # where bit 1 of the first octet is set (e.g., 54:78:... vs 56:78:...)
1653 val_a_norm = normalize_mac_for_matching(val_a)
1654 val_b_norm = normalize_mac_for_matching(val_b)
1655
1656 # Direct match on current MAC
1657 if val_a_norm == val_b_norm:
1658 return True
1659
1660 # Multi-MAC matching: also check original reported MACs.
1661 # Devices with multiple interfaces (WiFi + Ethernet) may have ARP
1662 # resolve one MAC while the protocol reports a different one.
1663 macs_a = {val_a_norm}
1664 macs_b = {val_b_norm}
1665 reported_a = player_a.extra_data.get("reported_mac")
1666 reported_b = player_b.extra_data.get("reported_mac")
1667 if reported_a and is_valid_mac_address(reported_a):
1668 macs_a.add(normalize_mac_for_matching(reported_a))
1669 if reported_b and is_valid_mac_address(reported_b):
1670 macs_b.add(normalize_mac_for_matching(reported_b))
1671 if macs_a & macs_b:
1672 return True
1673
1674 # No MAC match - continue to next identifier type
1675 continue
1676
1677 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
1678 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
1679
1680 # Direct match
1681 if val_a_norm == val_b_norm:
1682 return True
1683
1684 # Special case: Sonos UUID matching with DLNA _MR suffix
1685 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
1686 if conn_type == IdentifierType.UUID:
1687 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
1688 return True
1689 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
1690 return True
1691
1692 # Last resort: IP-based matching.
1693 # Two players on the same IP are very likely the same physical device.
1694 # This handles two cases:
1695 # 1. MAC randomization: at least one player has no real MAC (LA or missing),
1696 # so ARP couldn't resolve a usable address.
1697 # 2. Different MACs per protocol: some devices (e.g., Yamaha MusicCast) report
1698 # different valid globally-unique MACs per protocol (DLNA vs AirPlay differ
1699 # by 1 in the last octet). IP matching is safe here because two different
1700 # devices on a LAN cannot share the same IP simultaneously.
1701 # To avoid false positives between unrelated native players, this path
1702 # requires at least one player to be a protocol or universal player.
1703 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
1704 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
1705 if ip_a and ip_b and ip_a == ip_b:
1706 mac_a = identifiers_a.get(IdentifierType.MAC_ADDRESS)
1707 mac_b = identifiers_b.get(IdentifierType.MAC_ADDRESS)
1708 a_is_real = (
1709 mac_a is not None
1710 and is_valid_mac_address(mac_a)
1711 and not is_locally_administered_mac(mac_a)
1712 )
1713 b_is_real = (
1714 mac_b is not None
1715 and is_valid_mac_address(mac_b)
1716 and not is_locally_administered_mac(mac_b)
1717 )
1718 # Case 1: at least one player has no real hardware MAC
1719 if not (a_is_real and b_is_real):
1720 return True
1721 # Case 2: both have real MACs but at least one is a protocol/universal player
1722 a_is_protocol = (
1723 player_a.type == PlayerType.PROTOCOL
1724 or player_a.provider.domain == "universal_player"
1725 )
1726 b_is_protocol = (
1727 player_b.type == PlayerType.PROTOCOL
1728 or player_b.provider.domain == "universal_player"
1729 )
1730 if a_is_protocol or b_is_protocol:
1731 return True
1732
1733 return False
1734
1735 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
1736 """
1737 Select the best available output protocol for a player.
1738
1739 Selection priority:
1740 1. Output protocol that is currently grouped/synced with other players.
1741 2. User's preferred output protocol (from player settings).
1742 3. Native playback (if player supports PLAY_MEDIA).
1743 4. Best available protocol by priority.
1744
1745 Returns tuple of (target_player, output_protocol).
1746 output_protocol is None when using native playback.
1747 """
1748 self.logger.log(
1749 VERBOSE_LOG_LEVEL,
1750 "Selecting output protocol for %s",
1751 player.state.name,
1752 )
1753
1754 # 1. Check if any output protocol is currently grouped
1755 for linked in player.linked_output_protocols:
1756 if protocol_player := self.get_player(linked.output_protocol_id):
1757 if protocol_player.available_for_playback and self._is_protocol_grouped(
1758 protocol_player
1759 ):
1760 self.logger.log(
1761 VERBOSE_LOG_LEVEL,
1762 "Selected protocol for %s: %s (grouped)",
1763 player.state.name,
1764 protocol_player.state.name,
1765 )
1766 return protocol_player, linked
1767
1768 # 2. Check for user's preferred output protocol.
1769 # The value is only stored while it differs from the entry's default, which is computed
1770 # per player: "native" when a native output is available, otherwise "auto". Both of those
1771 # are handled identically by the steps below, so an absent value can safely fall through.
1772 preferred = self.mass.config.get_raw_player_config_value(
1773 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
1774 )
1775 if preferred and preferred != "auto":
1776 if preferred == "native":
1777 if PlayerFeature.PLAY_MEDIA in player.supported_features:
1778 self.logger.log(
1779 VERBOSE_LOG_LEVEL,
1780 "Selected protocol for %s: native (user preference)",
1781 player.state.name,
1782 )
1783 return player, None
1784 else:
1785 for linked in player.linked_output_protocols:
1786 if linked.output_protocol_id == preferred:
1787 if protocol_player := self.get_player(linked.output_protocol_id):
1788 if protocol_player.available_for_playback:
1789 self.logger.log(
1790 VERBOSE_LOG_LEVEL,
1791 "Selected protocol for %s: %s (user preference)",
1792 player.state.name,
1793 protocol_player.state.name,
1794 )
1795 return protocol_player, linked
1796 break
1797
1798 # 3. Use native playback if available
1799 if PlayerFeature.PLAY_MEDIA in player.supported_features:
1800 self.logger.log(
1801 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
1802 )
1803 return player, None
1804
1805 # 4. Fall back to best protocol by priority
1806 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
1807 if protocol_player := self.get_player(linked.output_protocol_id):
1808 if protocol_player.available_for_playback:
1809 self.logger.log(
1810 VERBOSE_LOG_LEVEL,
1811 "Selected protocol for %s: %s (priority-based)",
1812 player.state.name,
1813 protocol_player.state.name,
1814 )
1815 return protocol_player, linked
1816
1817 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
1818
1819 def _get_control_target(
1820 self,
1821 player: Player,
1822 required_feature: PlayerFeature,
1823 require_active: bool = False,
1824 ) -> Player | None:
1825 """
1826 Get the best player(protocol) to send control commands to.
1827
1828 Prefers the active output protocol, otherwise uses the first available
1829 protocol player that supports the needed feature.
1830 """
1831 # If we have an active protocol, use that
1832 if (
1833 player.active_output_protocol
1834 and player.active_output_protocol != "native"
1835 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
1836 and required_feature in protocol_player.supported_features
1837 ):
1838 return protocol_player
1839
1840 # if the player natively supports the required feature, use that
1841 if (
1842 player.active_output_protocol == "native"
1843 and required_feature in player.supported_features
1844 ):
1845 return player
1846
1847 # If require_active is set, and no active protocol found, return None
1848 if require_active:
1849 return None
1850
1851 # if the player natively supports the required feature, use that
1852 if required_feature in player.supported_features:
1853 return player
1854 # Otherwise, use the first available linked protocol
1855 for linked in player.linked_output_protocols:
1856 if (
1857 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
1858 and protocol_player.available_for_playback
1859 and required_feature in protocol_player.supported_features
1860 ):
1861 return protocol_player
1862
1863 return None
1864
1865 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
1866 """
1867 Check if a protocol player is currently grouped/synced with other players.
1868
1869 Used to prefer protocols that are actively participating in a group,
1870 ensuring consistent playback across grouped players.
1871 """
1872 is_grouped = bool(
1873 protocol_player.state.synced_to
1874 or (
1875 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
1876 )
1877 or protocol_player.state.active_group
1878 )
1879 if is_grouped:
1880 self.logger.log(
1881 VERBOSE_LOG_LEVEL,
1882 "Protocol player %s is grouped",
1883 protocol_player.state.name,
1884 )
1885 return is_grouped
1886
1887 def _translate_members_to_remove_for_protocols(
1888 self,
1889 parent_player: Player,
1890 player_ids: list[str],
1891 parent_protocol_player: Player | None,
1892 parent_protocol_domain: str | None,
1893 ) -> tuple[list[str], list[str]]:
1894 """
1895 Translate member IDs to remove into protocol and native lists.
1896
1897 :param parent_player: The parent player to remove members from.
1898 :param player_ids: List of visible player IDs to remove.
1899 :param parent_protocol_player: The parent's protocol player if available.
1900 :param parent_protocol_domain: The parent's protocol domain if available.
1901 """
1902 self.logger.debug(
1903 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
1904 parent_player.state.name,
1905 player_ids,
1906 parent_protocol_domain,
1907 )
1908 protocol_members: list[str] = []
1909 native_members: list[str] = []
1910
1911 for child_player_id in player_ids:
1912 child_player = self.get_player(child_player_id)
1913 if not child_player:
1914 continue
1915
1916 # Check if this member is in the parent's group via protocol
1917 if parent_protocol_domain and parent_protocol_player:
1918 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
1919 if child_protocol and child_protocol.available:
1920 # For native protocol players, use the child's player_id directly
1921 child_protocol_id = (
1922 child_player.player_id
1923 if child_protocol.is_native
1924 else child_protocol.output_protocol_id
1925 )
1926 if child_protocol_id in parent_protocol_player.group_members:
1927 self.logger.debug(
1928 "Translating removal: %s -> protocol %s",
1929 child_player_id,
1930 child_protocol_id,
1931 )
1932 protocol_members.append(child_protocol_id)
1933 continue
1934
1935 # Check if child's protocol player is in parent's native group_members
1936 # This handles native protocol players (e.g., native AirPlay player like Apple TV)
1937 # where the parent itself contains protocol player IDs in its group_members
1938 translated = False
1939 for linked in child_player.linked_output_protocols:
1940 if linked.output_protocol_id in parent_player.group_members:
1941 self.logger.debug(
1942 "Translating removal (native parent): %s -> protocol %s",
1943 child_player_id,
1944 linked.output_protocol_id,
1945 )
1946 native_members.append(linked.output_protocol_id)
1947 translated = True
1948 break
1949
1950 if not translated:
1951 native_members.append(child_player_id)
1952
1953 return protocol_members, native_members
1954
1955 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
1956 """Filter member IDs to only include players from the same protocol domain."""
1957 return [
1958 pid
1959 for pid in member_ids
1960 if (p := self.get_player(pid)) and p.provider.domain == protocol_player.provider.domain
1961 ]
1962
1963 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
1964 """Filter member IDs to only include players compatible with the parent."""
1965 return [
1966 pid
1967 for pid in member_ids
1968 if (p := self.get_player(pid))
1969 and (
1970 p.provider.instance_id == parent_player.provider.instance_id
1971 or pid in parent_player._attr_can_group_with
1972 or p.provider.instance_id in parent_player._attr_can_group_with
1973 )
1974 ]
1975
1976 def _try_child_preferred_protocol(
1977 self,
1978 child_player: Player,
1979 parent_player: Player,
1980 ) -> tuple[str | None, str | None]:
1981 """
1982 Try to use child's preferred output protocol for grouping.
1983
1984 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
1985 """
1986 child_preferred = self.mass.config.get_raw_player_config_value(
1987 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
1988 )
1989 if not child_preferred or child_preferred in {"auto", "native"}:
1990 return None, None
1991
1992 # Find child's preferred protocol, with its current availability
1993 child_protocol = None
1994 for output_protocol in child_player.output_protocols:
1995 if output_protocol.output_protocol_id == child_preferred:
1996 child_protocol = output_protocol
1997 break
1998
1999 if not child_protocol or not child_protocol.available:
2000 return None, None
2001
2002 # Check if parent supports this protocol (including native protocol)
2003 parent_protocol = parent_player.get_output_protocol_by_domain(
2004 child_protocol.protocol_domain
2005 )
2006 if not parent_protocol or not parent_protocol.available:
2007 return None, None
2008
2009 # Check if this protocol supports set_members
2010 protocol_player = parent_player.get_protocol_player(parent_protocol.output_protocol_id)
2011 if (
2012 not protocol_player
2013 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
2014 ):
2015 return None, None
2016
2017 return child_protocol.output_protocol_id, child_protocol.protocol_domain
2018
2019 def _can_use_native_grouping(
2020 self,
2021 child_player: Player,
2022 parent_player: Player,
2023 parent_supports_native: bool,
2024 ) -> bool:
2025 """Check if child can be grouped with parent using native grouping."""
2026 if not parent_supports_native:
2027 return False
2028 return (
2029 child_player.provider.instance_id == parent_player.provider.instance_id
2030 or child_player.player_id in parent_player._attr_can_group_with
2031 or child_player.provider.instance_id in parent_player._attr_can_group_with
2032 )
2033
2034 def _try_find_common_protocol(
2035 self, child_player: Player, parent_player: Player
2036 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
2037 """
2038 Find common protocol that supports set_members.
2039
2040 Returns tuple of (parent_protocol, child_protocol) or (None, None).
2041 """
2042 for parent_output_protocol in parent_player.output_protocols:
2043 if not parent_output_protocol.available:
2044 continue
2045 child_protocol = child_player.get_output_protocol_by_domain(
2046 parent_output_protocol.protocol_domain
2047 )
2048 if not child_protocol or not child_protocol.available:
2049 continue
2050 protocol_player = parent_player.get_protocol_player(
2051 parent_output_protocol.output_protocol_id
2052 )
2053 if protocol_player and PlayerFeature.SET_MEMBERS in protocol_player.supported_features:
2054 return parent_output_protocol, child_protocol
2055 return None, None
2056
2057 def _parent_has_live_native_session(self, parent_player: Player) -> bool:
2058 """
2059 Return True when the parent currently holds a live native playback session.
2060
2061 The active output protocol lingers for a few seconds after stop, so a non-idle
2062 playback state is required to distinguish a real session from a just-stopped one.
2063 """
2064 return parent_player.active_output_protocol == "native" and (
2065 parent_player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2066 )
2067
2068 def _order_members_for_native_join(
2069 self,
2070 player_ids: list[str],
2071 parent_player: Player,
2072 parent_supports_native_grouping: bool,
2073 ) -> list[str]:
2074 """
2075 Order members so a live native session can be joined without splitting the group.
2076
2077 When the parent already holds a live native session, children that cannot group
2078 natively are evaluated first: they may force a shared protocol for the whole group,
2079 and processing them before the native-capable children lets those join that same
2080 protocol instead of being stranded in a separate native sub-group. The order is left
2081 untouched when the parent is not playing natively, so fresh-group selection is unchanged.
2082
2083 :param player_ids: The member IDs to be added, in their original order.
2084 :param parent_player: The parent player being joined.
2085 :param parent_supports_native_grouping: Whether the parent can group natively.
2086 """
2087 if not self._parent_has_live_native_session(parent_player):
2088 return player_ids
2089 return sorted(
2090 player_ids,
2091 key=lambda pid: bool(
2092 (child := self.get_player(pid))
2093 and self._can_use_native_grouping(
2094 child, parent_player, parent_supports_native_grouping
2095 )
2096 ),
2097 )
2098
2099 def _try_join_active_native_session(
2100 self,
2101 child_player: Player,
2102 parent_player: Player,
2103 parent_protocol_domain: str | None,
2104 parent_supports_native_grouping: bool,
2105 native_members: list[str],
2106 ) -> bool:
2107 """
2108 Add the child to native_members if it can join the parent's active native session.
2109
2110 A child's preferred output protocol must only steer protocol selection when the child
2111 initiates its own playback; when it joins a parent that is already playing natively it
2112 should adopt native grouping if compatible, rather than forcing the whole group onto
2113 the child's preferred protocol. Skipped once a protocol has been selected for the group,
2114 so mixed batches stay cohesive on a single protocol.
2115
2116 :param child_player: The player being added to the group.
2117 :param parent_player: The parent player being joined.
2118 :param parent_protocol_domain: The protocol domain already selected for the group, if any.
2119 :param parent_supports_native_grouping: Whether the parent can group natively.
2120 :param native_members: The native members list to append to when the child joins.
2121 """
2122 if not (
2123 self._parent_has_live_native_session(parent_player)
2124 and not parent_protocol_domain
2125 and self._can_use_native_grouping(
2126 child_player, parent_player, parent_supports_native_grouping
2127 )
2128 ):
2129 return False
2130 native_members.append(child_player.player_id)
2131 self.logger.log(
2132 VERBOSE_LOG_LEVEL,
2133 "Joining parent's active native session for %s",
2134 child_player.state.name,
2135 )
2136 return True
2137
2138 def _translate_native_members_to_protocol(
2139 self, parent_player: Player, protocol_domain: str, member_ids: list[str]
2140 ) -> list[str]:
2141 """
2142 Translate natively grouped members onto the protocol domain the parent plays through.
2143
2144 Members that do not have that protocol cannot follow the parent at all and are
2145 dropped with a warning instead.
2146
2147 :param parent_player: The parent player the members are grouped with.
2148 :param protocol_domain: The protocol domain selected for the group.
2149 :param member_ids: The member IDs that were selected for native grouping.
2150 """
2151 translated: list[str] = []
2152 for member_id in member_ids:
2153 member_player = self.get_player(member_id)
2154 if not member_player:
2155 continue
2156 # a native group's members may be listed by their protocol player id
2157 if member_player.protocol_parent_id:
2158 member_player = self.get_player(member_player.protocol_parent_id) or member_player
2159 member_protocol = member_player.get_output_protocol_by_domain(protocol_domain)
2160 if not member_protocol or not member_protocol.available:
2161 self.logger.warning(
2162 "Cannot group %s with %s: the group plays through the %s protocol, "
2163 "which %s does not support",
2164 member_player.state.name,
2165 parent_player.state.name,
2166 protocol_domain,
2167 member_player.state.name,
2168 )
2169 continue
2170 # For native protocol players, use the member's player_id directly
2171 translated.append(
2172 member_player.player_id
2173 if member_protocol.is_native
2174 else member_protocol.output_protocol_id
2175 )
2176 self.logger.log(
2177 VERBOSE_LOG_LEVEL,
2178 "Moving %s from native grouping to the %s protocol",
2179 member_player.state.name,
2180 protocol_domain,
2181 )
2182 return translated
2183
2184 def _move_native_members_to_group_protocol(
2185 self,
2186 parent_player: Player,
2187 parent_protocol_player: Player | None,
2188 parent_protocol_domain: str | None,
2189 protocol_members: list[str],
2190 native_members: list[str],
2191 ) -> None:
2192 """
2193 Move the members selected for native grouping onto the protocol the group ended up on.
2194
2195 Does nothing unless the group ends up on one of the parent's protocols while the
2196 parent's native grouping needs the parent's own stream: only then do those members
2197 have no session left to attach to.
2198
2199 :param parent_player: The parent player being joined.
2200 :param parent_protocol_player: The protocol player selected for the group, if any.
2201 :param parent_protocol_domain: The protocol domain selected for the group, if any.
2202 :param protocol_members: The protocol member list the translated IDs are added to.
2203 :param native_members: The native member IDs, emptied when they are moved over.
2204 """
2205 if not (
2206 native_members
2207 and parent_protocol_domain
2208 and parent_protocol_player
2209 and parent_protocol_player.player_id != parent_player.player_id
2210 and parent_player.native_grouping_requires_own_stream
2211 ):
2212 return
2213 protocol_members.extend(
2214 self._translate_native_members_to_protocol(
2215 parent_player, parent_protocol_domain, native_members
2216 )
2217 )
2218 native_members.clear()
2219
2220 def _migrate_stranded_native_members(
2221 self,
2222 parent_player: Player,
2223 parent_protocol_player: Player,
2224 protocol_members: list[str],
2225 ) -> list[str]:
2226 """
2227 Move the native members that a switch to the given protocol strands onto that protocol.
2228
2229 Returns the member IDs that are still attached to the parent's own stream, so the
2230 caller can release them from it. Empty unless the parent renders that stream itself
2231 while its native grouping attaches the members to exactly that stream: only then are
2232 they left without anything to play.
2233
2234 :param parent_player: The parent player that is about to switch protocol.
2235 :param parent_protocol_player: The protocol player the parent will render through.
2236 :param protocol_members: The protocol member list the translated IDs are added to.
2237 """
2238 if parent_protocol_player.player_id == parent_player.player_id:
2239 return []
2240 if not parent_player.native_grouping_requires_own_stream:
2241 return []
2242 if parent_player.active_output_protocol not in (None, "native"):
2243 return []
2244 if parent_player.state.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
2245 return []
2246 stranded = [
2247 member_id
2248 for member_id in parent_player.group_members
2249 if member_id != parent_player.player_id
2250 ]
2251 for protocol_id in self._translate_native_members_to_protocol(
2252 parent_player, parent_protocol_player.provider.domain, stranded
2253 ):
2254 if protocol_id not in protocol_members:
2255 protocol_members.append(protocol_id)
2256 return stranded
2257
2258 async def _stop_native_session(
2259 self,
2260 parent_player: Player,
2261 parent_protocol_player: Player,
2262 stranded_native_members: list[str],
2263 ) -> None:
2264 """
2265 Release the given members from the parent's own stream and stop it.
2266
2267 :param parent_player: The parent player whose native session is handed over.
2268 :param parent_protocol_player: The protocol player taking the output over.
2269 :param stranded_native_members: The members to release, already migrated.
2270 """
2271 self.logger.debug(
2272 "Stopping the native session of %s before switching to %s, migrated members: %s",
2273 parent_player.state.name,
2274 parent_protocol_player.state.name,
2275 stranded_native_members,
2276 )
2277 # The members already joined the protocol group, so the native session only has to
2278 # release them and stop. Releasing them first also clears the native group, which
2279 # keeps a later native playback command from resurrecting it. Both calls take the
2280 # provider's own lock, so they must run one after the other.
2281 await parent_player.set_members(player_ids_to_remove=stranded_native_members)
2282 await parent_player.stop()
2283
2284 def _translate_members_for_protocols(
2285 self,
2286 parent_player: Player,
2287 player_ids: list[str],
2288 parent_protocol_player: Player | None,
2289 parent_protocol_domain: str | None,
2290 ) -> tuple[list[str], list[str], Player | None, str | None]:
2291 """
2292 Translate member IDs to protocol or native IDs.
2293
2294 Selection priority when grouping:
2295 0. If the parent is already playing natively and the child can be grouped
2296 natively, join that native session (a child joining an existing group must
2297 not force the whole group onto its own preferred output protocol)
2298 1. Try child's preferred output protocol (from player settings)
2299 2. Try parent's active output protocol (if any and child supports it)
2300 3. Try native grouping (if parent and child are compatible)
2301 4. Search for common protocol that supports set_members
2302 5. Log warning if no option works
2303
2304 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
2305 """
2306 protocol_members: list[str] = []
2307 native_members: list[str] = []
2308 parent_supports_native_grouping = (
2309 PlayerFeature.SET_MEMBERS in parent_player.supported_features
2310 )
2311 player_ids = self._order_members_for_native_join(
2312 player_ids, parent_player, parent_supports_native_grouping
2313 )
2314
2315 self.logger.log(
2316 VERBOSE_LOG_LEVEL,
2317 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
2318 parent_player.state.name,
2319 parent_supports_native_grouping,
2320 parent_protocol_player.state.name if parent_protocol_player else "none",
2321 parent_protocol_domain or "none",
2322 )
2323
2324 for child_player_id in player_ids:
2325 child_player = self.get_player(child_player_id)
2326 if not child_player:
2327 continue
2328
2329 self.logger.log(
2330 VERBOSE_LOG_LEVEL,
2331 "Processing child %s (type=%s, protocols=%s)",
2332 child_player.state.name,
2333 child_player.state.type,
2334 [p.protocol_domain for p in child_player.output_protocols],
2335 )
2336
2337 # Priority 0: The parent is already playing natively and the child can join
2338 # that native session directly - adopt it before considering the child's own
2339 # preferred output protocol.
2340 if self._try_join_active_native_session(
2341 child_player,
2342 parent_player,
2343 parent_protocol_domain,
2344 parent_supports_native_grouping,
2345 native_members,
2346 ):
2347 continue
2348
2349 # Priority 1: Try child's preferred output protocol
2350 # (only if no active protocol or if it matches the active protocol)
2351 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
2352 child_player, parent_player
2353 )
2354 if (
2355 child_protocol_id
2356 and protocol_domain
2357 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
2358 ):
2359 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
2360 parent_protocol = parent_player.get_output_protocol_by_domain(protocol_domain)
2361 if parent_protocol:
2362 parent_protocol_player = parent_player.get_protocol_player(
2363 parent_protocol.output_protocol_id
2364 )
2365 parent_protocol_domain = protocol_domain
2366 protocol_members.append(child_protocol_id)
2367 self.logger.log(
2368 VERBOSE_LOG_LEVEL,
2369 "Using child's preferred protocol %s for %s",
2370 protocol_domain,
2371 child_player.state.name,
2372 )
2373 continue
2374
2375 # Priority 2: Try parent's active output protocol (if it supports SET_MEMBERS)
2376 if parent_protocol_domain and parent_protocol_player:
2377 # Verify the active protocol supports SET_MEMBERS
2378 if PlayerFeature.SET_MEMBERS in parent_protocol_player.state.supported_features:
2379 child_protocol = child_player.get_output_protocol_by_domain(
2380 parent_protocol_domain
2381 )
2382 if child_protocol and child_protocol.available:
2383 # For native protocol players, use the child's player_id directly
2384 # (e.g., a native sendspin web player IS the protocol player)
2385 child_protocol_id = (
2386 child_player.player_id
2387 if child_protocol.is_native
2388 else child_protocol.output_protocol_id
2389 )
2390 protocol_members.append(child_protocol_id)
2391 self.logger.log(
2392 VERBOSE_LOG_LEVEL,
2393 "Using parent's active protocol %s for %s",
2394 parent_protocol_domain,
2395 child_player.state.name,
2396 )
2397 continue
2398 else:
2399 self.logger.log(
2400 VERBOSE_LOG_LEVEL,
2401 "Parent's active protocol %s does not support SET_MEMBERS, "
2402 "will search for alternative",
2403 parent_protocol_domain,
2404 )
2405 # Clear the parent protocol so Priority 4 can select a new one
2406 parent_protocol_player = None
2407 parent_protocol_domain = None
2408
2409 # Priority 3: Try native grouping
2410 if self._can_use_native_grouping(
2411 child_player, parent_player, parent_supports_native_grouping
2412 ):
2413 native_members.append(child_player_id)
2414 self.logger.log(
2415 VERBOSE_LOG_LEVEL,
2416 "Using native grouping for %s",
2417 child_player.state.name,
2418 )
2419 continue
2420
2421 # Priority 4: Search for common protocol that supports set_members
2422 parent_protocol, child_protocol = self._try_find_common_protocol(
2423 child_player, parent_player
2424 )
2425 if parent_protocol and child_protocol:
2426 if (
2427 not parent_protocol_player
2428 or parent_protocol_domain != parent_protocol.protocol_domain
2429 ):
2430 parent_protocol_player = parent_player.get_protocol_player(
2431 parent_protocol.output_protocol_id
2432 )
2433 if parent_protocol_player:
2434 parent_protocol_domain = parent_protocol_player.provider.domain
2435 # For native protocol players, use the child's player_id directly
2436 child_protocol_id = (
2437 child_player.player_id
2438 if child_protocol.is_native
2439 else child_protocol.output_protocol_id
2440 )
2441 protocol_members.append(child_protocol_id)
2442 self.logger.log(
2443 VERBOSE_LOG_LEVEL,
2444 "Selected common protocol %s for grouping %s with %s",
2445 parent_protocol.protocol_domain,
2446 child_player.state.name,
2447 parent_player.state.name,
2448 )
2449 continue
2450
2451 # Priority 5: No option worked - log warning
2452 self.logger.warning(
2453 "Cannot group %s with %s: no compatible grouping method found "
2454 "(tried: child preferred protocol, native grouping, "
2455 "parent active protocol, common protocols)",
2456 child_player.state.name,
2457 parent_player.state.name,
2458 )
2459
2460 # Post-pass: the protocol selected for the group is only known once every child has
2461 # been processed, so the members picked for native grouping are corrected here.
2462 self._move_native_members_to_group_protocol(
2463 parent_player,
2464 parent_protocol_player,
2465 parent_protocol_domain,
2466 protocol_members,
2467 native_members,
2468 )
2469
2470 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
2471
2472 async def _forward_protocol_set_members(
2473 self,
2474 parent_player: Player,
2475 parent_protocol_player: Player,
2476 protocol_members_to_add: list[str],
2477 protocol_members_to_remove: list[str],
2478 ) -> None:
2479 """
2480 Forward protocol members to protocol player's set_members and manage active output protocol.
2481
2482 :param parent_player: The parent player (native/universal).
2483 :param parent_protocol_player: The protocol player to forward commands to.
2484 :param protocol_members_to_add: Protocol player IDs to add.
2485 :param protocol_members_to_remove: Protocol player IDs to remove.
2486 """
2487 filtered_protocol_add = self._filter_protocol_members(
2488 protocol_members_to_add, parent_protocol_player
2489 )
2490 filtered_protocol_remove = self._filter_protocol_members(
2491 protocol_members_to_remove, parent_protocol_player
2492 )
2493 self.logger.debug(
2494 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
2495 parent_protocol_player.state.name,
2496 filtered_protocol_add,
2497 filtered_protocol_remove,
2498 )
2499
2500 if not filtered_protocol_add and not filtered_protocol_remove:
2501 return
2502
2503 # Safety check: verify protocol player supports SET_MEMBERS
2504 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2505 self.logger.error(
2506 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
2507 "This should have been caught earlier in the flow.",
2508 parent_protocol_player.state.name,
2509 )
2510 return
2511
2512 # Members that ride the parent's own stream are stranded by the protocol switch below,
2513 # so they join the protocol group in this very same call and their native session is
2514 # torn down afterwards.
2515 stranded_native_members = (
2516 self._migrate_stranded_native_members(
2517 parent_player, parent_protocol_player, filtered_protocol_add
2518 )
2519 if filtered_protocol_add
2520 else []
2521 )
2522
2523 self.logger.debug(
2524 "Calling set_members on protocol player %s with add=%s, remove=%s",
2525 parent_protocol_player.state.name,
2526 filtered_protocol_add,
2527 filtered_protocol_remove,
2528 )
2529 await parent_protocol_player.set_members(
2530 player_ids_to_add=filtered_protocol_add or None,
2531 player_ids_to_remove=filtered_protocol_remove or None,
2532 )
2533
2534 # Set active output protocol on added child players
2535 if filtered_protocol_add:
2536 for child_protocol_id in filtered_protocol_add:
2537 if child_protocol := self.get_player(child_protocol_id):
2538 if child_protocol.protocol_parent_id:
2539 if child_player := self.get_player(child_protocol.protocol_parent_id):
2540 if child_player.active_output_protocol != child_protocol_id:
2541 self.logger.debug(
2542 "Setting active output protocol on child %s to %s",
2543 child_player.state.name,
2544 child_protocol_id,
2545 )
2546 child_player.set_active_output_protocol(child_protocol_id)
2547
2548 # If we added members via this protocol, set it as the active output protocol
2549 # and restart playback if currently playing AND we're switching protocols
2550 if filtered_protocol_add:
2551 previous_protocol = parent_player.active_output_protocol
2552 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
2553 # A paused player still holds its output, so the handover has to run for it
2554 # too - only the resume at the end is reserved for a player that was playing,
2555 # so adding a member does not start playback on its own.
2556 was_rendering = was_playing or parent_player.state.playback_state == (
2557 PlaybackState.PAUSED
2558 )
2559
2560 # Determine if we're switching protocols (which requires restart)
2561 # Native protocol: parent_protocol_player is the same as parent_player
2562 is_native_protocol = parent_protocol_player.player_id == parent_player.player_id
2563 already_using_native = previous_protocol in (None, "native")
2564 already_using_this_protocol = previous_protocol == parent_protocol_player.player_id
2565
2566 # Only restart if we're actually switching to a different protocol
2567 switching_protocols = not (
2568 (is_native_protocol and already_using_native) or already_using_this_protocol
2569 )
2570
2571 self.logger.debug(
2572 "Protocol grouping: is_native=%s, already_native=%s, already_this=%s, "
2573 "switching=%s, was_rendering=%s",
2574 is_native_protocol,
2575 already_using_native,
2576 already_using_this_protocol,
2577 switching_protocols,
2578 was_rendering,
2579 )
2580
2581 # Update active output protocol if not already using native
2582 if not (is_native_protocol and already_using_native):
2583 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
2584
2585 # Hand the output over only if we're switching protocols
2586 if was_rendering and switching_protocols:
2587 self.logger.info(
2588 "Handing the output of %s over to the %s protocol%s",
2589 parent_player.state.name,
2590 parent_protocol_player.provider.domain,
2591 " and resuming playback" if was_playing else "",
2592 )
2593 if stranded_native_members:
2594 await self._stop_native_session(
2595 parent_player, parent_protocol_player, stranded_native_members
2596 )
2597 # Collect existing members from old protocol before stopping it,
2598 # so we can re-add them to the new protocol afterwards.
2599 old_protocol_members: list[str] = []
2600 if (
2601 previous_protocol
2602 and previous_protocol not in (None, "native")
2603 and (old_protocol_player := self.get_player(previous_protocol))
2604 and old_protocol_player.player_id != parent_protocol_player.player_id
2605 ):
2606 # Collect child members (exclude the leader itself)
2607 old_protocol_members = [
2608 m
2609 for m in old_protocol_player.group_members
2610 if m != old_protocol_player.player_id
2611 ]
2612 # Translate protocol IDs back to parent player IDs
2613 old_parent_members: list[str] = []
2614 for member_id in old_protocol_members:
2615 if member_player := self.get_player(member_id):
2616 parent_id = member_player.protocol_parent_id or member_id
2617 if parent_id != parent_player.player_id:
2618 old_parent_members.append(parent_id)
2619 self.logger.debug(
2620 "Stopping old protocol player %s before switching to %s, "
2621 "migrating members: %s",
2622 old_protocol_player.state.name,
2623 parent_protocol_player.state.name,
2624 old_parent_members,
2625 )
2626 # Use internal handler to stop the specific protocol player,
2627 # bypassing group/sync redirect and queue redirect logic.
2628 await self.mass.players._handle_cmd_stop(old_protocol_player.player_id)
2629 else:
2630 old_parent_members = []
2631 # Resume playback on the new protocol and re-add migrated members.
2632 if was_playing:
2633 await self.mass.players.cmd_resume(parent_player.player_id)
2634 if old_parent_members:
2635 self.logger.debug(
2636 "Re-adding migrated members %s to %s on new protocol",
2637 old_parent_members,
2638 parent_player.state.name,
2639 )
2640 # Use internal handler because we are already inside a
2641 # _handle_set_members call chain that holds the play lock.
2642 await self.mass.players._handle_set_members(
2643 parent_player,
2644 player_ids_to_add=old_parent_members,
2645 )
2646
2647 self.logger.debug(
2648 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
2649 parent_protocol_player.state.name,
2650 parent_protocol_player.group_members,
2651 parent_protocol_player.synced_to,
2652 )
2653