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