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