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