/
/
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 _cleanup_player_type_transition(self, existing: Player) -> None:
1598 """
1599 Release the protocol topology a player owned before its type changed.
1600
1601 :param existing: The registered player instance, still reporting its previous type.
1602 """
1603 if existing.state.type == PlayerType.PROTOCOL:
1604 # a provider may announce the new type with the live parent link already
1605 # dropped, so fall back to the persisted one to still reach the parent
1606 parent_id = existing.protocol_parent_id or self._get_cached_protocol_parent_id(
1607 existing.player_id
1608 )
1609 if not parent_id:
1610 return
1611 parent = self.get_player(parent_id)
1612 if parent is not None and parent.provider.domain == "universal_player":
1613 # drop only the active edge and leave the rest to the link evaluation,
1614 # which replaces the wrapper with this player: a leftover edge makes it
1615 # hand the player over to itself, which it refuses, abandoning the swap
1616 self._remove_protocol_link(parent, existing.player_id)
1617 return
1618 existing.set_protocol_parent_id(parent_id)
1619 # unlink at the parent and drop the persisted parent id, which would
1620 # otherwise heal the player's type back to protocol
1621 self._cleanup_protocol_links(existing)
1622 # a player leaving the protocol role has no parent, also when that parent
1623 # is not registered (anymore) and only the cached link could be cleaned up
1624 existing.set_protocol_parent_id(None)
1625 return
1626 # the player becomes a child itself: detach the protocol players it owned so they
1627 # can find a new parent, then give up their ownership in its (kept) config - the
1628 # reverse of the removal path, which drops the ownership before the detach
1629 protocol_ids = set(self._get_known_protocol_ids(existing))
1630 self._cleanup_protocol_links(existing)
1631 self._remove_protocol_ids_from_parent(existing, protocol_ids)
1632
1633 def _detach_protocol_children(self, parent_id: str) -> None:
1634 """
1635 Detach the registered protocol players of a parent player that is going away.
1636
1637 Covers the removal paths that don't unregister the parent first (e.g. its
1638 provider is unloaded), where the parent is not around anymore to enumerate
1639 its protocol players.
1640
1641 :param parent_id: Player id of the parent that is being removed.
1642 """
1643 for protocol_player in list(self._players.values()):
1644 if protocol_player.state.type != PlayerType.PROTOCOL:
1645 continue
1646 # a protocol player waiting for a parent that never registered only has
1647 # the link in its config, so fall back to the cached parent
1648 linked_parent_id = protocol_player.protocol_parent_id or (
1649 self._get_cached_protocol_parent_id(protocol_player.player_id)
1650 )
1651 if linked_parent_id != parent_id:
1652 continue
1653 self.logger.debug(
1654 "Player %s removed - scheduling evaluation for protocol %s",
1655 parent_id,
1656 protocol_player.player_id,
1657 )
1658 self._detach_protocol_child(protocol_player)
1659
1660 def _detach_protocol_child(self, protocol_player: Player) -> None:
1661 """Clear a protocol player's parent link and schedule a fresh evaluation."""
1662 self._clear_protocol_parent_id(protocol_player.player_id)
1663 protocol_player.set_protocol_parent_id(None)
1664 protocol_player.refresh_state()
1665 self._schedule_protocol_evaluation(protocol_player)
1666
1667 def _identifiers_match(
1668 self, player_a: Player, player_b: Player, protocol_domain: str = ""
1669 ) -> bool:
1670 """
1671 Check if identifiers match between two players.
1672
1673 Matching is done by comparing connection identifiers (MAC, serial, UUID).
1674 As a last resort, IP address is used when at least one player has a
1675 locally-administered MAC, indicating the device uses MAC randomization
1676 and ARP could not resolve the real hardware address.
1677
1678 Invalid identifiers (e.g., 00:00:00:00:00:00 MAC addresses) are filtered out
1679 to prevent false matches between unrelated devices.
1680 """
1681 identifiers_a = player_a.device_info.identifiers
1682 identifiers_b = player_b.device_info.identifiers
1683
1684 # Check identifiers in order of reliability
1685 # MAC_ADDRESS > SERIAL_NUMBER > UUID > CAST_UUID > AIRPLAY_ID
1686 for conn_type in (
1687 IdentifierType.MAC_ADDRESS,
1688 IdentifierType.SERIAL_NUMBER,
1689 IdentifierType.UUID,
1690 IdentifierType.CAST_UUID,
1691 IdentifierType.AIRPLAY_ID,
1692 ):
1693 val_a = identifiers_a.get(conn_type)
1694 val_b = identifiers_b.get(conn_type)
1695
1696 if not val_a or not val_b:
1697 continue
1698
1699 # Filter out invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
1700 if conn_type == IdentifierType.MAC_ADDRESS:
1701 if not is_valid_mac_address(val_a) or not is_valid_mac_address(val_b):
1702 self.logger.log(
1703 VERBOSE_LOG_LEVEL,
1704 "Skipping invalid MAC address for matching: %s=%s, %s=%s",
1705 player_a.display_name,
1706 val_a,
1707 player_b.display_name,
1708 val_b,
1709 )
1710 continue
1711
1712 # Normalize values for comparison
1713 if conn_type == IdentifierType.MAC_ADDRESS:
1714 # Use MAC normalization that handles locally-administered bit differences
1715 # Some protocols (like AirPlay) report a locally-administered MAC variant
1716 # where bit 1 of the first octet is set (e.g., 54:78:... vs 56:78:...)
1717 val_a_norm = normalize_mac_for_matching(val_a)
1718 val_b_norm = normalize_mac_for_matching(val_b)
1719
1720 # Direct match on current MAC
1721 if val_a_norm == val_b_norm:
1722 return True
1723
1724 # Multi-MAC matching: also check original reported MACs.
1725 # Devices with multiple interfaces (WiFi + Ethernet) may have ARP
1726 # resolve one MAC while the protocol reports a different one.
1727 macs_a = {val_a_norm}
1728 macs_b = {val_b_norm}
1729 reported_a = player_a.extra_data.get("reported_mac")
1730 reported_b = player_b.extra_data.get("reported_mac")
1731 if reported_a and is_valid_mac_address(reported_a):
1732 macs_a.add(normalize_mac_for_matching(reported_a))
1733 if reported_b and is_valid_mac_address(reported_b):
1734 macs_b.add(normalize_mac_for_matching(reported_b))
1735 if macs_a & macs_b:
1736 return True
1737
1738 # No MAC match - continue to next identifier type
1739 continue
1740
1741 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
1742 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
1743
1744 # Direct match
1745 if val_a_norm == val_b_norm:
1746 return True
1747
1748 # Special case: Sonos UUID matching with DLNA _MR suffix
1749 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
1750 if conn_type == IdentifierType.UUID:
1751 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
1752 return True
1753 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
1754 return True
1755
1756 # Last resort: IP-based matching.
1757 # Two players on the same IP are very likely the same physical device.
1758 # This handles two cases:
1759 # 1. MAC randomization: at least one player has no real MAC (LA or missing),
1760 # so ARP couldn't resolve a usable address.
1761 # 2. Different MACs per protocol: some devices (e.g., Yamaha MusicCast) report
1762 # different valid globally-unique MACs per protocol (DLNA vs AirPlay differ
1763 # by 1 in the last octet). IP matching is safe here because two different
1764 # devices on a LAN cannot share the same IP simultaneously.
1765 # To avoid false positives between unrelated native players, this path
1766 # requires at least one player to be a protocol or universal player.
1767 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
1768 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
1769 if ip_a and ip_b and ip_a == ip_b:
1770 mac_a = identifiers_a.get(IdentifierType.MAC_ADDRESS)
1771 mac_b = identifiers_b.get(IdentifierType.MAC_ADDRESS)
1772 a_is_real = (
1773 mac_a is not None
1774 and is_valid_mac_address(mac_a)
1775 and not is_locally_administered_mac(mac_a)
1776 )
1777 b_is_real = (
1778 mac_b is not None
1779 and is_valid_mac_address(mac_b)
1780 and not is_locally_administered_mac(mac_b)
1781 )
1782 # Case 1: at least one player has no real hardware MAC
1783 if not (a_is_real and b_is_real):
1784 return True
1785 # Case 2: both have real MACs but at least one is a protocol/universal player
1786 a_is_protocol = (
1787 player_a.type == PlayerType.PROTOCOL
1788 or player_a.provider.domain == "universal_player"
1789 )
1790 b_is_protocol = (
1791 player_b.type == PlayerType.PROTOCOL
1792 or player_b.provider.domain == "universal_player"
1793 )
1794 if a_is_protocol or b_is_protocol:
1795 return True
1796
1797 return False
1798
1799 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
1800 """
1801 Select the best available output protocol for a player.
1802
1803 Selection priority:
1804 1. Output protocol that is currently grouped/synced with other players.
1805 2. User's preferred output protocol (from player settings).
1806 3. Native playback (if player supports PLAY_MEDIA).
1807 4. The player's declared default output protocol domain, if available.
1808 5. Best available protocol by priority.
1809
1810 Returns tuple of (target_player, output_protocol).
1811 output_protocol is None when using native playback.
1812 """
1813 self.logger.log(
1814 VERBOSE_LOG_LEVEL,
1815 "Selecting output protocol for %s",
1816 player.state.name,
1817 )
1818
1819 # 1. Check if any output protocol is currently grouped
1820 for linked in player.linked_output_protocols:
1821 if protocol_player := self.get_player(linked.output_protocol_id):
1822 if protocol_player.available_for_playback and self._is_protocol_grouped(
1823 protocol_player
1824 ):
1825 self.logger.log(
1826 VERBOSE_LOG_LEVEL,
1827 "Selected protocol for %s: %s (grouped)",
1828 player.state.name,
1829 protocol_player.state.name,
1830 )
1831 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
1832
1833 # 2. Check for user's preferred output protocol.
1834 # The value is only stored while it differs from the entry's default: "native" when a
1835 # native output is available, otherwise "auto". A player without a native output (e.g. a
1836 # LinkPlay shell) therefore has no stored preference by default and gets its default
1837 # output domain applied in step 4.
1838 preferred = self.mass.config.get_raw_player_config_value(
1839 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
1840 )
1841 if preferred and preferred != "auto":
1842 if preferred == "native":
1843 if PlayerFeature.PLAY_MEDIA in player.supported_features:
1844 self.logger.log(
1845 VERBOSE_LOG_LEVEL,
1846 "Selected protocol for %s: native (user preference)",
1847 player.state.name,
1848 )
1849 return player, None
1850 else:
1851 for linked in player.linked_output_protocols:
1852 if linked.output_protocol_id == preferred:
1853 if protocol_player := self.get_player(linked.output_protocol_id):
1854 if protocol_player.available_for_playback:
1855 self.logger.log(
1856 VERBOSE_LOG_LEVEL,
1857 "Selected protocol for %s: %s (user preference)",
1858 player.state.name,
1859 protocol_player.state.name,
1860 )
1861 return protocol_player, player.get_linked_protocol(
1862 linked.output_protocol_id
1863 )
1864 break
1865
1866 # 3. Use native playback if available
1867 if PlayerFeature.PLAY_MEDIA in player.supported_features:
1868 self.logger.log(
1869 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
1870 )
1871 return player, None
1872
1873 # 4. Use the player's preferred default protocol domain, if it declares one and a
1874 # matching linked protocol is available (e.g. a LinkPlay shell prefers DLNA). This
1875 # never influences grouping; it only steers the default output for playback. "Auto"
1876 # is the entry default here, so it consistently resolves to this domain default.
1877 if default_domain := player.default_output_protocol_domain:
1878 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
1879 if linked.protocol_domain != default_domain:
1880 continue
1881 if (protocol_player := self.get_player(linked.output_protocol_id)) and (
1882 protocol_player.available_for_playback
1883 ):
1884 self.logger.log(
1885 VERBOSE_LOG_LEVEL,
1886 "Selected protocol for %s: %s (default domain %s)",
1887 player.state.name,
1888 protocol_player.state.name,
1889 default_domain,
1890 )
1891 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
1892
1893 # 5. Fall back to best protocol by priority
1894 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
1895 if protocol_player := self.get_player(linked.output_protocol_id):
1896 if protocol_player.available_for_playback:
1897 self.logger.log(
1898 VERBOSE_LOG_LEVEL,
1899 "Selected protocol for %s: %s (priority-based)",
1900 player.state.name,
1901 protocol_player.state.name,
1902 )
1903 return protocol_player, player.get_linked_protocol(linked.output_protocol_id)
1904
1905 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
1906
1907 def _get_control_target(
1908 self,
1909 player: Player,
1910 required_feature: PlayerFeature,
1911 require_active: bool = False,
1912 ) -> Player | None:
1913 """
1914 Get the best player(protocol) to send audio-path commands to.
1915
1916 Resolves commands that travel with the audio (enqueue, pause, announcements),
1917 so the output that renders the audio outranks the native player. Volume and
1918 mute are control-plane instead and resolve through
1919 :meth:`Player._get_protocol_player_for_feature`, which orders differently.
1920
1921 :param player: The player the command was issued on.
1922 :param required_feature: The feature the resolved target has to support.
1923 :param require_active: Only accept the output that is already rendering,
1924 instead of falling back to an idle one.
1925 """
1926 # If we have an active protocol, use that
1927 if (
1928 player.active_output_protocol
1929 and player.active_output_protocol != "native"
1930 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
1931 and required_feature in protocol_player.supported_features
1932 ):
1933 return protocol_player
1934
1935 # if the player natively supports the required feature, use that
1936 if (
1937 player.active_output_protocol == "native"
1938 and required_feature in player.supported_features
1939 ):
1940 return player
1941
1942 # If require_active is set, and no active protocol found, return None
1943 if require_active:
1944 return None
1945
1946 # if the player natively supports the required feature, use that
1947 if required_feature in player.supported_features:
1948 return player
1949
1950 # An output the user explicitly picked owns the audio, so a command that has to
1951 # start playback on an idle player follows it rather than the priority below.
1952 # The stored value survives a relink, so it only counts while it still names one
1953 # of this player's own outputs.
1954 preferred = self.mass.config.get_raw_player_config_value(
1955 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
1956 )
1957 if preferred and preferred not in ("auto", "native"):
1958 for linked in player.linked_output_protocols:
1959 if linked.output_protocol_id != preferred:
1960 continue
1961 if (
1962 (preferred_player := self.mass.players.get_player(str(preferred)))
1963 and preferred_player.available_for_playback
1964 and required_feature in preferred_player.supported_features
1965 ):
1966 return preferred_player
1967 break
1968
1969 # Otherwise, use the best available linked protocol, ordered by the same
1970 # priority that regular playback selection applies.
1971 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
1972 if (
1973 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
1974 and protocol_player.available_for_playback
1975 and required_feature in protocol_player.supported_features
1976 ):
1977 return protocol_player
1978
1979 return None
1980
1981 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
1982 """
1983 Check if a protocol player is currently grouped/synced with other players.
1984
1985 Used to prefer protocols that are actively participating in a group,
1986 ensuring consistent playback across grouped players.
1987 """
1988 is_grouped = bool(
1989 protocol_player.state.synced_to
1990 or (
1991 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
1992 )
1993 or protocol_player.state.active_group
1994 )
1995 if is_grouped:
1996 self.logger.log(
1997 VERBOSE_LOG_LEVEL,
1998 "Protocol player %s is grouped",
1999 protocol_player.state.name,
2000 )
2001 return is_grouped
2002
2003 def _translate_members_to_remove_for_protocols(
2004 self,
2005 parent_player: Player,
2006 player_ids: list[str],
2007 parent_protocol_player: Player | None,
2008 parent_protocol_domain: str | None,
2009 ) -> tuple[list[str], list[str]]:
2010 """
2011 Translate member IDs to remove into protocol and native lists.
2012
2013 :param parent_player: The parent player to remove members from.
2014 :param player_ids: List of visible player IDs to remove.
2015 :param parent_protocol_player: The parent's protocol player if available.
2016 :param parent_protocol_domain: The parent's protocol domain if available.
2017 """
2018 self.logger.debug(
2019 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
2020 parent_player.state.name,
2021 player_ids,
2022 parent_protocol_domain,
2023 )
2024 protocol_members: list[str] = []
2025 native_members: list[str] = []
2026
2027 for child_player_id in player_ids:
2028 child_player = self.get_player(child_player_id)
2029 if not child_player:
2030 continue
2031
2032 # Check if this member is in the parent's group via protocol
2033 if parent_protocol_domain and parent_protocol_player:
2034 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2035 if child_protocol and child_protocol.available:
2036 # For native protocol players, use the child's player_id directly
2037 child_protocol_id = (
2038 child_player.player_id
2039 if child_protocol.is_native
2040 else child_protocol.output_protocol_id
2041 )
2042 if child_protocol_id in parent_protocol_player.group_members:
2043 self.logger.debug(
2044 "Translating removal: %s -> protocol %s",
2045 child_player_id,
2046 child_protocol_id,
2047 )
2048 protocol_members.append(child_protocol_id)
2049 continue
2050
2051 # Check if child's protocol player is in parent's native group_members
2052 # This handles native protocol players (e.g., native AirPlay player like Apple TV)
2053 # where the parent itself contains protocol player IDs in its group_members
2054 translated = False
2055 for linked in child_player.linked_output_protocols:
2056 if linked.output_protocol_id in parent_player.group_members:
2057 self.logger.debug(
2058 "Translating removal (native parent): %s -> protocol %s",
2059 child_player_id,
2060 linked.output_protocol_id,
2061 )
2062 native_members.append(linked.output_protocol_id)
2063 translated = True
2064 break
2065
2066 if not translated:
2067 native_members.append(child_player_id)
2068
2069 return protocol_members, native_members
2070
2071 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
2072 """Filter member IDs to only include players from the same protocol domain."""
2073 return [
2074 pid
2075 for pid in member_ids
2076 if (p := self.get_player(pid)) and p.provider.domain == protocol_player.provider.domain
2077 ]
2078
2079 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
2080 """Filter member IDs to only include players compatible with the parent."""
2081 return [
2082 pid
2083 for pid in member_ids
2084 if (p := self.get_player(pid))
2085 and (
2086 p.provider.instance_id == parent_player.provider.instance_id
2087 or pid in parent_player._attr_can_group_with
2088 or p.provider.instance_id in parent_player._attr_can_group_with
2089 )
2090 ]
2091
2092 def _try_child_preferred_protocol(
2093 self,
2094 child_player: Player,
2095 parent_player: Player,
2096 ) -> tuple[str | None, str | None]:
2097 """
2098 Try to use child's preferred output protocol for grouping.
2099
2100 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
2101 """
2102 child_preferred = self.mass.config.get_raw_player_config_value(
2103 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2104 )
2105 if not child_preferred or child_preferred in {"auto", "native"}:
2106 return None, None
2107
2108 # Find child's preferred protocol, with its current availability
2109 child_protocol = None
2110 for output_protocol in child_player.output_protocols:
2111 if output_protocol.output_protocol_id == child_preferred:
2112 child_protocol = output_protocol
2113 break
2114
2115 if not child_protocol or not child_protocol.available:
2116 return None, None
2117
2118 # Check if parent supports this protocol (including native protocol)
2119 parent_protocol = parent_player.get_output_protocol_by_domain(
2120 child_protocol.protocol_domain
2121 )
2122 if not parent_protocol or not parent_protocol.available:
2123 return None, None
2124
2125 # Check if this protocol supports set_members
2126 protocol_player = parent_player.get_protocol_player(parent_protocol.output_protocol_id)
2127 if (
2128 not protocol_player
2129 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
2130 ):
2131 return None, None
2132
2133 return child_protocol.output_protocol_id, child_protocol.protocol_domain
2134
2135 def _can_use_native_grouping(
2136 self,
2137 child_player: Player,
2138 parent_player: Player,
2139 parent_supports_native: bool,
2140 ) -> bool:
2141 """Check if child can be grouped with parent using native grouping."""
2142 if not parent_supports_native:
2143 return False
2144 return (
2145 parent_player.is_native_group_compatible(child_player)
2146 or child_player.player_id in parent_player._attr_can_group_with
2147 or child_player.provider.instance_id in parent_player._attr_can_group_with
2148 )
2149
2150 def _try_find_common_protocol(
2151 self, child_player: Player, parent_player: Player
2152 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
2153 """
2154 Find common protocol that supports set_members.
2155
2156 Returns tuple of (parent_protocol, child_protocol) or (None, None).
2157 """
2158 for parent_output_protocol in parent_player.output_protocols:
2159 if not parent_output_protocol.available:
2160 continue
2161 child_protocol = child_player.get_output_protocol_by_domain(
2162 parent_output_protocol.protocol_domain
2163 )
2164 if not child_protocol or not child_protocol.available:
2165 continue
2166 protocol_player = parent_player.get_protocol_player(
2167 parent_output_protocol.output_protocol_id
2168 )
2169 if protocol_player and PlayerFeature.SET_MEMBERS in protocol_player.supported_features:
2170 return parent_output_protocol, child_protocol
2171 return None, None
2172
2173 def _parent_has_live_native_session(self, parent_player: Player) -> bool:
2174 """
2175 Return True when the parent currently holds a live native playback session.
2176
2177 The active output protocol lingers for a few seconds after stop, so a non-idle
2178 playback state is required to distinguish a real session from a just-stopped one.
2179 """
2180 return parent_player.active_output_protocol == "native" and (
2181 parent_player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2182 )
2183
2184 def _order_members_for_native_join(
2185 self,
2186 player_ids: list[str],
2187 parent_player: Player,
2188 parent_supports_native_grouping: bool,
2189 ) -> list[str]:
2190 """
2191 Order members so a live native session can be joined without splitting the group.
2192
2193 When the parent already holds a live native session, children that cannot group
2194 natively are evaluated first: they may force a shared protocol for the whole group,
2195 and processing them before the native-capable children lets those join that same
2196 protocol instead of being stranded in a separate native sub-group. The order is left
2197 untouched when the parent is not playing natively, so fresh-group selection is unchanged.
2198
2199 :param player_ids: The member IDs to be added, in their original order.
2200 :param parent_player: The parent player being joined.
2201 :param parent_supports_native_grouping: Whether the parent can group natively.
2202 """
2203 if not self._parent_has_live_native_session(parent_player):
2204 return player_ids
2205 return sorted(
2206 player_ids,
2207 key=lambda pid: bool(
2208 (child := self.get_player(pid))
2209 and self._can_use_native_grouping(
2210 child, parent_player, parent_supports_native_grouping
2211 )
2212 ),
2213 )
2214
2215 def _try_join_active_native_session(
2216 self,
2217 child_player: Player,
2218 parent_player: Player,
2219 parent_protocol_domain: str | None,
2220 parent_supports_native_grouping: bool,
2221 native_members: list[str],
2222 ) -> bool:
2223 """
2224 Add the child to native_members if it can join the parent's active native session.
2225
2226 A child's preferred output protocol must only steer protocol selection when the child
2227 initiates its own playback; when it joins a parent that is already playing natively it
2228 should adopt native grouping if compatible, rather than forcing the whole group onto
2229 the child's preferred protocol. Skipped once a protocol has been selected for the group,
2230 so mixed batches stay cohesive on a single protocol.
2231
2232 :param child_player: The player being added to the group.
2233 :param parent_player: The parent player being joined.
2234 :param parent_protocol_domain: The protocol domain already selected for the group, if any.
2235 :param parent_supports_native_grouping: Whether the parent can group natively.
2236 :param native_members: The native members list to append to when the child joins.
2237 """
2238 if not (
2239 self._parent_has_live_native_session(parent_player)
2240 and not parent_protocol_domain
2241 and self._can_use_native_grouping(
2242 child_player, parent_player, parent_supports_native_grouping
2243 )
2244 ):
2245 return False
2246 native_members.append(child_player.player_id)
2247 self.logger.log(
2248 VERBOSE_LOG_LEVEL,
2249 "Joining parent's active native session for %s",
2250 child_player.state.name,
2251 )
2252 return True
2253
2254 def _translate_native_members_to_protocol(
2255 self, parent_player: Player, protocol_domain: str, member_ids: list[str]
2256 ) -> list[str]:
2257 """
2258 Translate natively grouped members onto the protocol domain the parent plays through.
2259
2260 Members that do not have that protocol cannot follow the parent at all and are
2261 dropped with a warning instead.
2262
2263 :param parent_player: The parent player the members are grouped with.
2264 :param protocol_domain: The protocol domain selected for the group.
2265 :param member_ids: The member IDs that were selected for native grouping.
2266 """
2267 translated: list[str] = []
2268 for member_id in member_ids:
2269 member_player = self.get_player(member_id)
2270 if not member_player:
2271 continue
2272 # a native group's members may be listed by their protocol player id
2273 if member_player.protocol_parent_id:
2274 member_player = self.get_player(member_player.protocol_parent_id) or member_player
2275 member_protocol = member_player.get_output_protocol_by_domain(protocol_domain)
2276 if not member_protocol or not member_protocol.available:
2277 self.logger.warning(
2278 "Cannot group %s with %s: the group plays through the %s protocol, "
2279 "which %s does not support",
2280 member_player.state.name,
2281 parent_player.state.name,
2282 protocol_domain,
2283 member_player.state.name,
2284 )
2285 continue
2286 # For native protocol players, use the member's player_id directly
2287 translated.append(
2288 member_player.player_id
2289 if member_protocol.is_native
2290 else member_protocol.output_protocol_id
2291 )
2292 self.logger.log(
2293 VERBOSE_LOG_LEVEL,
2294 "Moving %s from native grouping to the %s protocol",
2295 member_player.state.name,
2296 protocol_domain,
2297 )
2298 return translated
2299
2300 def _move_native_members_to_group_protocol(
2301 self,
2302 parent_player: Player,
2303 parent_protocol_player: Player | None,
2304 parent_protocol_domain: str | None,
2305 protocol_members: list[str],
2306 native_members: list[str],
2307 ) -> None:
2308 """
2309 Move the members selected for native grouping onto the protocol the group ended up on.
2310
2311 Does nothing unless the group ends up on one of the parent's protocols while the
2312 parent's native grouping needs the parent's own stream: only then do those members
2313 have no session left to attach to.
2314
2315 :param parent_player: The parent player being joined.
2316 :param parent_protocol_player: The protocol player selected for the group, if any.
2317 :param parent_protocol_domain: The protocol domain selected for the group, if any.
2318 :param protocol_members: The protocol member list the translated IDs are added to.
2319 :param native_members: The native member IDs, emptied when they are moved over.
2320 """
2321 if not (
2322 native_members
2323 and parent_protocol_domain
2324 and parent_protocol_player
2325 and parent_protocol_player.player_id != parent_player.player_id
2326 and parent_player.native_grouping_requires_own_stream
2327 ):
2328 return
2329 protocol_members.extend(
2330 self._translate_native_members_to_protocol(
2331 parent_player, parent_protocol_domain, native_members
2332 )
2333 )
2334 native_members.clear()
2335
2336 def _migrate_stranded_native_members(
2337 self,
2338 parent_player: Player,
2339 parent_protocol_player: Player,
2340 protocol_members: list[str],
2341 ) -> list[str]:
2342 """
2343 Move the native members that a switch to the given protocol strands onto that protocol.
2344
2345 Returns the member IDs that are still attached to the parent's own stream, so the
2346 caller can release them from it. Empty unless the parent renders that stream itself
2347 while its native grouping attaches the members to exactly that stream: only then are
2348 they left without anything to play.
2349
2350 :param parent_player: The parent player that is about to switch protocol.
2351 :param parent_protocol_player: The protocol player the parent will render through.
2352 :param protocol_members: The protocol member list the translated IDs are added to.
2353 """
2354 if parent_protocol_player.player_id == parent_player.player_id:
2355 return []
2356 if not parent_player.native_grouping_requires_own_stream:
2357 return []
2358 if parent_player.active_output_protocol not in (None, "native"):
2359 return []
2360 if parent_player.state.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
2361 return []
2362 stranded = [
2363 member_id
2364 for member_id in parent_player.group_members
2365 if member_id != parent_player.player_id
2366 ]
2367 for protocol_id in self._translate_native_members_to_protocol(
2368 parent_player, parent_protocol_player.provider.domain, stranded
2369 ):
2370 if protocol_id not in protocol_members:
2371 protocol_members.append(protocol_id)
2372 return stranded
2373
2374 async def _stop_native_session(
2375 self,
2376 parent_player: Player,
2377 parent_protocol_player: Player,
2378 stranded_native_members: list[str],
2379 ) -> None:
2380 """
2381 Release the given members from the parent's own stream and stop it.
2382
2383 :param parent_player: The parent player whose native session is handed over.
2384 :param parent_protocol_player: The protocol player taking the output over.
2385 :param stranded_native_members: The members to release, already migrated.
2386 """
2387 self.logger.debug(
2388 "Stopping the native session of %s before switching to %s, migrated members: %s",
2389 parent_player.state.name,
2390 parent_protocol_player.state.name,
2391 stranded_native_members,
2392 )
2393 # The members already joined the protocol group, so the native session only has to
2394 # release them and stop. Releasing them first also clears the native group, which
2395 # keeps a later native playback command from resurrecting it. Both calls take the
2396 # provider's own lock, so they must run one after the other.
2397 await parent_player.set_members(player_ids_to_remove=stranded_native_members)
2398 await parent_player.stop()
2399
2400 def _translate_members_for_protocols(
2401 self,
2402 parent_player: Player,
2403 player_ids: list[str],
2404 parent_protocol_player: Player | None,
2405 parent_protocol_domain: str | None,
2406 ) -> tuple[list[str], list[str], Player | None, str | None]:
2407 """
2408 Translate member IDs to protocol or native IDs.
2409
2410 The grouping method is picked per member, see _select_grouping_for_member.
2411
2412 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
2413 """
2414 protocol_members: list[str] = []
2415 native_members: list[str] = []
2416 parent_supports_native_grouping = (
2417 PlayerFeature.SET_MEMBERS in parent_player.supported_features
2418 )
2419 player_ids = self._order_members_for_native_join(
2420 player_ids, parent_player, parent_supports_native_grouping
2421 )
2422
2423 self.logger.log(
2424 VERBOSE_LOG_LEVEL,
2425 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
2426 parent_player.state.name,
2427 parent_supports_native_grouping,
2428 parent_protocol_player.state.name if parent_protocol_player else "none",
2429 parent_protocol_domain or "none",
2430 )
2431
2432 for child_player_id in player_ids:
2433 child_player = self.get_player(child_player_id)
2434 if not child_player:
2435 continue
2436
2437 self.logger.log(
2438 VERBOSE_LOG_LEVEL,
2439 "Processing child %s (type=%s, protocols=%s)",
2440 child_player.state.name,
2441 child_player.state.type,
2442 [p.protocol_domain for p in child_player.output_protocols],
2443 )
2444
2445 parent_protocol_player, parent_protocol_domain = self._select_grouping_for_member(
2446 child_player,
2447 parent_player,
2448 parent_protocol_player,
2449 parent_protocol_domain,
2450 parent_supports_native_grouping,
2451 protocol_members,
2452 native_members,
2453 )
2454
2455 # Post-pass: the protocol selected for the group is only known once every child has
2456 # been processed, so the members picked for native grouping are corrected here.
2457 self._move_native_members_to_group_protocol(
2458 parent_player,
2459 parent_protocol_player,
2460 parent_protocol_domain,
2461 protocol_members,
2462 native_members,
2463 )
2464
2465 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
2466
2467 def _select_grouping_for_member(
2468 self,
2469 child_player: Player,
2470 parent_player: Player,
2471 parent_protocol_player: Player | None,
2472 parent_protocol_domain: str | None,
2473 parent_supports_native_grouping: bool,
2474 protocol_members: list[str],
2475 native_members: list[str],
2476 ) -> tuple[Player | None, str | None]:
2477 """
2478 Pick the grouping method for a single member and add it to the matching member list.
2479
2480 Selection priority when grouping:
2481 0. If the parent is already playing natively and the child can be grouped
2482 natively, join that native session (a child joining an existing group must
2483 not force the whole group onto its own preferred output protocol)
2484 1. Try child's preferred output protocol (from player settings)
2485 2. Try parent's active output protocol (if any and child supports it)
2486 3. Try native grouping (if parent and child are compatible)
2487 4. Search for common protocol that supports set_members
2488 5. Log warning if no option works
2489
2490 Returns the protocol player/domain the group is on, which the picked method may have
2491 changed.
2492
2493 :param child_player: The player being added to the group.
2494 :param parent_player: The parent player being joined.
2495 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2496 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2497 :param parent_supports_native_grouping: Whether the parent can group natively.
2498 :param protocol_members: The protocol member list to append to.
2499 :param native_members: The native member list to append to.
2500 """
2501 # Priority 0: The parent is already playing natively and the child can join
2502 # that native session directly - adopt it before considering the child's own
2503 # preferred output protocol.
2504 if self._try_join_active_native_session(
2505 child_player,
2506 parent_player,
2507 parent_protocol_domain,
2508 parent_supports_native_grouping,
2509 native_members,
2510 ):
2511 return parent_protocol_player, parent_protocol_domain
2512
2513 # Priority 0.5: a player that runs its own multiroom (e.g. a LinkPlay control shell)
2514 # keeps grouping on its native path rather than routing it through a linked protocol
2515 # that is merely its preferred playback output. Native compatibility still decides
2516 # whether this is possible, so an incompatible/cross-backend pair falls through.
2517 if child_player.prefer_native_grouping and self._can_use_native_grouping(
2518 child_player, parent_player, parent_supports_native_grouping
2519 ):
2520 native_members.append(child_player.player_id)
2521 self.logger.log(
2522 VERBOSE_LOG_LEVEL,
2523 "Using native grouping (preferred) for %s",
2524 child_player.state.name,
2525 )
2526 return parent_protocol_player, parent_protocol_domain
2527
2528 # Priority 1: the child's preferred output protocol
2529 grouped, parent_protocol_player, parent_protocol_domain = (
2530 self._try_group_via_preferred_protocol(
2531 child_player,
2532 parent_player,
2533 parent_protocol_player,
2534 parent_protocol_domain,
2535 protocol_members,
2536 )
2537 )
2538 if grouped:
2539 return parent_protocol_player, parent_protocol_domain
2540
2541 # Priority 2: the protocol the group is already on
2542 grouped, parent_protocol_player, parent_protocol_domain = (
2543 self._try_group_via_active_protocol(
2544 child_player,
2545 parent_protocol_player,
2546 parent_protocol_domain,
2547 protocol_members,
2548 )
2549 )
2550 if grouped:
2551 return parent_protocol_player, parent_protocol_domain
2552
2553 # Priority 3: native grouping
2554 if self._can_use_native_grouping(
2555 child_player, parent_player, parent_supports_native_grouping
2556 ):
2557 native_members.append(child_player.player_id)
2558 self.logger.log(
2559 VERBOSE_LOG_LEVEL,
2560 "Using native grouping for %s",
2561 child_player.state.name,
2562 )
2563 return parent_protocol_player, parent_protocol_domain
2564
2565 # Priority 4: a protocol both players share that supports set_members
2566 grouped, parent_protocol_player, parent_protocol_domain = (
2567 self._try_group_via_common_protocol(
2568 child_player,
2569 parent_player,
2570 parent_protocol_player,
2571 parent_protocol_domain,
2572 protocol_members,
2573 )
2574 )
2575 if grouped:
2576 return parent_protocol_player, parent_protocol_domain
2577
2578 # Priority 5: no option worked
2579 self.logger.warning(
2580 "Cannot group %s with %s: no compatible grouping method found "
2581 "(tried: child preferred protocol, parent active protocol, "
2582 "native grouping, common protocols)",
2583 child_player.state.name,
2584 parent_player.state.name,
2585 )
2586 return parent_protocol_player, parent_protocol_domain
2587
2588 def _try_group_via_preferred_protocol(
2589 self,
2590 child_player: Player,
2591 parent_player: Player,
2592 parent_protocol_player: Player | None,
2593 parent_protocol_domain: str | None,
2594 protocol_members: list[str],
2595 ) -> tuple[bool, Player | None, str | None]:
2596 """
2597 Try to group the child through the output protocol it prefers in its player settings.
2598
2599 Only used when the group is not on a protocol yet or is already on that same protocol.
2600 Returns whether the child was grouped, together with the protocol player/domain the
2601 group is on: the child's preferred protocol may become the group's protocol.
2602
2603 :param child_player: The player being added to the group.
2604 :param parent_player: The parent player being joined.
2605 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2606 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2607 :param protocol_members: The protocol member list to append to.
2608 """
2609 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
2610 child_player, parent_player
2611 )
2612 if not (
2613 child_protocol_id
2614 and protocol_domain
2615 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
2616 ):
2617 return False, parent_protocol_player, parent_protocol_domain
2618
2619 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
2620 parent_protocol = parent_player.get_output_protocol_by_domain(protocol_domain)
2621 if parent_protocol:
2622 parent_protocol_player = parent_player.get_protocol_player(
2623 parent_protocol.output_protocol_id
2624 )
2625 parent_protocol_domain = protocol_domain
2626 protocol_members.append(child_protocol_id)
2627 self.logger.log(
2628 VERBOSE_LOG_LEVEL,
2629 "Using child's preferred protocol %s for %s",
2630 protocol_domain,
2631 child_player.state.name,
2632 )
2633 return True, parent_protocol_player, parent_protocol_domain
2634
2635 def _try_group_via_active_protocol(
2636 self,
2637 child_player: Player,
2638 parent_protocol_player: Player | None,
2639 parent_protocol_domain: str | None,
2640 protocol_members: list[str],
2641 ) -> tuple[bool, Player | None, str | None]:
2642 """
2643 Try to group the child through the protocol the group is already on.
2644
2645 Returns whether the child was grouped, together with the protocol player/domain the
2646 group is on: the selection is dropped when that protocol cannot group members itself,
2647 so a later grouping method can select another one.
2648
2649 :param child_player: The player being added to the group.
2650 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2651 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2652 :param protocol_members: The protocol member list to append to.
2653 """
2654 if not parent_protocol_domain or not parent_protocol_player:
2655 return False, parent_protocol_player, parent_protocol_domain
2656
2657 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2658 self.logger.log(
2659 VERBOSE_LOG_LEVEL,
2660 "Parent's active protocol %s does not support SET_MEMBERS, "
2661 "will search for alternative",
2662 parent_protocol_domain,
2663 )
2664 # Drop the selection so a later grouping method can select a new protocol
2665 return False, None, None
2666
2667 child_protocol = child_player.get_output_protocol_by_domain(parent_protocol_domain)
2668 if not child_protocol or not child_protocol.available:
2669 return False, parent_protocol_player, parent_protocol_domain
2670
2671 # For native protocol players, use the child's player_id directly
2672 # (e.g., a native sendspin web player IS the protocol player)
2673 child_protocol_id = (
2674 child_player.player_id
2675 if child_protocol.is_native
2676 else child_protocol.output_protocol_id
2677 )
2678 protocol_members.append(child_protocol_id)
2679 self.logger.log(
2680 VERBOSE_LOG_LEVEL,
2681 "Using parent's active protocol %s for %s",
2682 parent_protocol_domain,
2683 child_player.state.name,
2684 )
2685 return True, parent_protocol_player, parent_protocol_domain
2686
2687 def _try_group_via_common_protocol(
2688 self,
2689 child_player: Player,
2690 parent_player: Player,
2691 parent_protocol_player: Player | None,
2692 parent_protocol_domain: str | None,
2693 protocol_members: list[str],
2694 ) -> tuple[bool, Player | None, str | None]:
2695 """
2696 Try to group the child through a protocol both players share.
2697
2698 Returns whether the child was grouped, together with the protocol player/domain the
2699 group is on: the shared protocol may become the group's protocol.
2700
2701 :param child_player: The player being added to the group.
2702 :param parent_player: The parent player being joined.
2703 :param parent_protocol_player: The protocol player selected for the group so far, if any.
2704 :param parent_protocol_domain: The protocol domain selected for the group so far, if any.
2705 :param protocol_members: The protocol member list to append to.
2706 """
2707 parent_protocol, child_protocol = self._try_find_common_protocol(
2708 child_player, parent_player
2709 )
2710 if not parent_protocol or not child_protocol:
2711 return False, parent_protocol_player, parent_protocol_domain
2712
2713 if not parent_protocol_player or parent_protocol_domain != parent_protocol.protocol_domain:
2714 parent_protocol_player = parent_player.get_protocol_player(
2715 parent_protocol.output_protocol_id
2716 )
2717 if parent_protocol_player:
2718 parent_protocol_domain = parent_protocol_player.provider.domain
2719 # For native protocol players, use the child's player_id directly
2720 child_protocol_id = (
2721 child_player.player_id
2722 if child_protocol.is_native
2723 else child_protocol.output_protocol_id
2724 )
2725 protocol_members.append(child_protocol_id)
2726 self.logger.log(
2727 VERBOSE_LOG_LEVEL,
2728 "Selected common protocol %s for grouping %s with %s",
2729 parent_protocol.protocol_domain,
2730 child_player.state.name,
2731 parent_player.state.name,
2732 )
2733 return True, parent_protocol_player, parent_protocol_domain
2734
2735 async def _forward_protocol_set_members(
2736 self,
2737 parent_player: Player,
2738 parent_protocol_player: Player,
2739 protocol_members_to_add: list[str],
2740 protocol_members_to_remove: list[str],
2741 ) -> None:
2742 """
2743 Forward protocol members to protocol player's set_members and manage active output protocol.
2744
2745 :param parent_player: The parent player (native/universal).
2746 :param parent_protocol_player: The protocol player to forward commands to.
2747 :param protocol_members_to_add: Protocol player IDs to add.
2748 :param protocol_members_to_remove: Protocol player IDs to remove.
2749 """
2750 filtered_protocol_add = self._filter_protocol_members(
2751 protocol_members_to_add, parent_protocol_player
2752 )
2753 filtered_protocol_remove = self._filter_protocol_members(
2754 protocol_members_to_remove, parent_protocol_player
2755 )
2756 self.logger.debug(
2757 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
2758 parent_protocol_player.state.name,
2759 filtered_protocol_add,
2760 filtered_protocol_remove,
2761 )
2762
2763 if not filtered_protocol_add and not filtered_protocol_remove:
2764 return
2765
2766 # Safety check: verify protocol player supports SET_MEMBERS
2767 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
2768 self.logger.error(
2769 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
2770 "This should have been caught earlier in the flow.",
2771 parent_protocol_player.state.name,
2772 )
2773 return
2774
2775 # Members that ride the parent's own stream are stranded by the protocol switch below,
2776 # so they join the protocol group in this very same call and their native session is
2777 # torn down afterwards.
2778 stranded_native_members = (
2779 self._migrate_stranded_native_members(
2780 parent_player, parent_protocol_player, filtered_protocol_add
2781 )
2782 if filtered_protocol_add
2783 else []
2784 )
2785
2786 # This runs before set_members because a member's own stream starts inside that call
2787 # and the provider resolves the member's volume control as it starts: unless its parent
2788 # already points at this protocol, that resolution picks a sibling interface of the same
2789 # device (e.g. its cast side) over the one carrying the audio.
2790 self._activate_protocol_on_added_children(filtered_protocol_add)
2791
2792 self.logger.debug(
2793 "Calling set_members on protocol player %s with add=%s, remove=%s",
2794 parent_protocol_player.state.name,
2795 filtered_protocol_add,
2796 filtered_protocol_remove,
2797 )
2798 await parent_protocol_player.set_members(
2799 player_ids_to_add=filtered_protocol_add or None,
2800 player_ids_to_remove=filtered_protocol_remove or None,
2801 )
2802
2803 if filtered_protocol_add:
2804 await self._activate_group_output_protocol(
2805 parent_player, parent_protocol_player, stranded_native_members
2806 )
2807
2808 self.logger.debug(
2809 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
2810 parent_protocol_player.state.name,
2811 parent_protocol_player.group_members,
2812 parent_protocol_player.synced_to,
2813 )
2814
2815 def _activate_protocol_on_added_children(self, protocol_member_ids: list[str]) -> None:
2816 """
2817 Point the parent of each given protocol member at the protocol carrying the group audio.
2818
2819 :param protocol_member_ids: The protocol player IDs joining the group.
2820 """
2821 for child_protocol_id in protocol_member_ids:
2822 if not (child_protocol := self.get_player(child_protocol_id)):
2823 continue
2824 if not child_protocol.protocol_parent_id:
2825 continue
2826 if not (child_player := self.get_player(child_protocol.protocol_parent_id)):
2827 continue
2828 if child_player.active_output_protocol == child_protocol_id:
2829 continue
2830 self.logger.debug(
2831 "Setting active output protocol on child %s to %s",
2832 child_player.state.name,
2833 child_protocol_id,
2834 )
2835 child_player.set_active_output_protocol(child_protocol_id)
2836
2837 async def _activate_group_output_protocol(
2838 self,
2839 parent_player: Player,
2840 parent_protocol_player: Player,
2841 stranded_native_members: list[str],
2842 ) -> None:
2843 """
2844 Mark the given protocol as the parent's output and hand the playback over to it.
2845
2846 The handover only runs when the parent is actually switching protocol while it is
2847 rendering; playback is resumed only for a parent that was playing, so adding a member
2848 never starts playback on its own.
2849
2850 :param parent_player: The parent player that just gained protocol members.
2851 :param parent_protocol_player: The protocol player the members joined.
2852 :param stranded_native_members: The members left without a stream by the switch.
2853 """
2854 previous_protocol = parent_player.active_output_protocol
2855 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
2856 # A paused player still holds its output, so the handover has to run for it too.
2857 was_rendering = was_playing or parent_player.state.playback_state == PlaybackState.PAUSED
2858
2859 # Native protocol: parent_protocol_player is the same as parent_player
2860 is_native_protocol = parent_protocol_player.player_id == parent_player.player_id
2861 already_using_native = previous_protocol in (None, "native")
2862 already_using_this_protocol = previous_protocol == parent_protocol_player.player_id
2863 switching_protocols = not (
2864 (is_native_protocol and already_using_native) or already_using_this_protocol
2865 )
2866
2867 self.logger.debug(
2868 "Protocol grouping: is_native=%s, already_native=%s, already_this=%s, "
2869 "switching=%s, was_rendering=%s",
2870 is_native_protocol,
2871 already_using_native,
2872 already_using_this_protocol,
2873 switching_protocols,
2874 was_rendering,
2875 )
2876
2877 if not (is_native_protocol and already_using_native):
2878 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
2879
2880 if not (was_rendering and switching_protocols):
2881 return
2882
2883 self.logger.info(
2884 "Handing the output of %s over to the %s protocol%s",
2885 parent_player.state.name,
2886 parent_protocol_player.provider.domain,
2887 " and resuming playback" if was_playing else "",
2888 )
2889 if stranded_native_members:
2890 await self._stop_native_session(
2891 parent_player, parent_protocol_player, stranded_native_members
2892 )
2893 old_parent_members = await self._stop_previous_protocol(
2894 parent_player, parent_protocol_player, previous_protocol
2895 )
2896 if was_playing:
2897 await self.mass.players.cmd_resume(parent_player.player_id)
2898 if old_parent_members:
2899 self.logger.debug(
2900 "Re-adding migrated members %s to %s on new protocol",
2901 old_parent_members,
2902 parent_player.state.name,
2903 )
2904 # Use internal handler because we are already inside a
2905 # _handle_set_members call chain that holds the play lock.
2906 await self.mass.players._handle_set_members(
2907 parent_player,
2908 player_ids_to_add=old_parent_members,
2909 )
2910
2911 async def _stop_previous_protocol(
2912 self,
2913 parent_player: Player,
2914 parent_protocol_player: Player,
2915 previous_protocol: str | None,
2916 ) -> list[str]:
2917 """
2918 Stop the protocol player the parent was rendering through and return its members.
2919
2920 The returned IDs are parent player IDs, so the caller can re-add them to the group
2921 once the new protocol carries the audio. Empty if there is nothing to hand over.
2922
2923 :param parent_player: The parent player that is switching protocol.
2924 :param parent_protocol_player: The protocol player taking the output over.
2925 :param previous_protocol: The parent's previous active output protocol, if any.
2926 """
2927 if previous_protocol in (None, "native"):
2928 return []
2929 if not (old_protocol_player := self.get_player(previous_protocol)):
2930 return []
2931 if old_protocol_player.player_id == parent_protocol_player.player_id:
2932 return []
2933 # Translate the old protocol's child members back to parent player IDs
2934 old_parent_members: list[str] = []
2935 for member_id in old_protocol_player.group_members:
2936 if member_id == old_protocol_player.player_id:
2937 continue
2938 if not (member_player := self.get_player(member_id)):
2939 continue
2940 parent_id = member_player.protocol_parent_id or member_id
2941 if parent_id != parent_player.player_id:
2942 old_parent_members.append(parent_id)
2943 self.logger.debug(
2944 "Stopping old protocol player %s before switching to %s, migrating members: %s",
2945 old_protocol_player.state.name,
2946 parent_protocol_player.state.name,
2947 old_parent_members,
2948 )
2949 # Use internal handler to stop the specific protocol player,
2950 # bypassing group/sync redirect and queue redirect logic.
2951 await self.mass.players._handle_cmd_stop(old_protocol_player.player_id)
2952 return old_parent_members
2953