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