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