/
/
/
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 typing import TYPE_CHECKING, cast
16
17from music_assistant_models.enums import (
18 IdentifierType,
19 PlaybackState,
20 PlayerFeature,
21 PlayerType,
22 ProviderType,
23)
24from music_assistant_models.errors import PlayerCommandFailed
25from music_assistant_models.player import OutputProtocol
26
27from music_assistant.constants import (
28 CONF_LINKED_PROTOCOL_PLAYER_IDS,
29 CONF_PLAYERS,
30 CONF_PREFERRED_OUTPUT_PROTOCOL,
31 CONF_PROTOCOL_PARENT_ID,
32 PROTOCOL_PRIORITY,
33 VERBOSE_LOG_LEVEL,
34)
35from music_assistant.helpers.util import is_locally_administered_mac, resolve_real_mac_address
36from music_assistant.models.player import Player
37from music_assistant.providers.universal_player import UniversalPlayer, UniversalPlayerProvider
38
39if TYPE_CHECKING:
40 from collections.abc import Coroutine
41 from typing import Any
42
43 from music_assistant import MusicAssistant
44
45
46class ProtocolLinkingMixin:
47 """
48 Mixin class providing protocol linking functionality for PlayerController.
49
50 Handles the complex logic of:
51 - Matching protocol players to native players via device identifiers
52 - Creating Universal Players for devices without native support
53 - Managing protocol links and their lifecycle
54 - Selecting the best output protocol for playback
55
56 This mixin expects to be mixed with a class that provides:
57 - mass: MusicAssistant instance
58 - _players: dict of registered players
59 - _pending_protocol_evaluations: dict of pending protocol evaluations
60 - logger: logging.Logger instance
61 - all(): method to get all players
62 - get(): method to get a player by ID
63 - unregister(): method to unregister a player
64 """
65
66 # Type hints for attributes provided by the class this mixin is used with
67 if TYPE_CHECKING:
68 mass: MusicAssistant
69 _players: dict[str, Player]
70 _pending_protocol_evaluations: dict[str, asyncio.TimerHandle]
71 logger: logging.Logger
72
73 def all_players( # noqa: D102
74 self,
75 return_unavailable: bool = True,
76 return_disabled: bool = False,
77 provider_filter: str | None = None,
78 return_protocol_players: bool = False,
79 ) -> list[Player]: ...
80
81 def get_player(self, player_id: str) -> Player | None: ... # noqa: D102
82
83 def unregister( # noqa: D102
84 self, player_id: str, permanent: bool = False
85 ) -> Coroutine[Any, Any, None]: ...
86
87 def _is_protocol_player(self, player: Player) -> bool:
88 """
89 Check if a player is a generic protocol player without native support.
90
91 Protocol players have PlayerType.PROTOCOL set by their provider, indicating
92 they are generic streaming endpoints (e.g., AirPlay receiver, Chromecast device)
93 without vendor-specific native support in Music Assistant.
94 """
95 return player.state.type == PlayerType.PROTOCOL
96
97 async def _enrich_player_identifiers(self, player: Player) -> None:
98 """
99 Enrich player identifiers with real MAC address if needed.
100
101 Some devices report different virtual/locally administered MAC addresses per protocol
102 (AirPlay, DLNA, Chromecast may all have different MACs for the same device).
103 This also applies to native players that may report virtual MACs.
104 This method tries to resolve the actual hardware MAC via ARP and adds it as an
105 additional identifier to enable proper matching between protocols and native players.
106 """
107 identifiers = player.device_info.identifiers
108 reported_mac = identifiers.get(IdentifierType.MAC_ADDRESS)
109 ip_address = identifiers.get(IdentifierType.IP_ADDRESS)
110
111 # Skip if no IP available (can't do ARP lookup)
112 if not ip_address:
113 return
114
115 # Skip if MAC already looks like a real one (not locally administered)
116 if reported_mac and not is_locally_administered_mac(reported_mac):
117 return
118
119 # Try to resolve real MAC via ARP
120 real_mac = await resolve_real_mac_address(reported_mac, ip_address)
121 if real_mac and real_mac.upper() != (reported_mac or "").upper():
122 # Replace the virtual MAC with the real MAC address
123 # (add_identifier will store multiple values if the implementation supports it)
124 player.device_info.add_identifier(IdentifierType.MAC_ADDRESS, real_mac)
125 self.logger.debug(
126 "Resolved real MAC for %s: %s -> %s",
127 player.state.name,
128 reported_mac,
129 real_mac,
130 )
131
132 def _evaluate_protocol_links(self, player: Player) -> None:
133 """
134 Evaluate and establish protocol links for a player.
135
136 Called when a player is registered to:
137 1. If it's from a protocol provider - try to link to a native player.
138 2. If it's a native player - try to link any existing protocol players.
139 """
140 if player.state.type == PlayerType.PROTOCOL:
141 # Protocol player: try to find a native parent
142 self._try_link_protocol_to_native(player)
143 else:
144 # Native player: try to find protocol players to link
145 self._try_link_protocols_to_native(player)
146
147 def _try_link_protocol_to_native(self, protocol_player: Player) -> None:
148 """Try to link a protocol player to a native player."""
149 protocol_domain = protocol_player.provider.domain
150
151 # Check for cached parent_id from previous session and restore link immediately
152 cached_parent_id = self._get_cached_protocol_parent_id(protocol_player.player_id)
153 if cached_parent_id:
154 protocol_player.set_protocol_parent_id(cached_parent_id)
155 if parent_player := self.get_player(cached_parent_id):
156 if not any(
157 link.output_protocol_id == protocol_player.player_id
158 for link in parent_player.linked_output_protocols
159 ):
160 self._add_protocol_link(parent_player, protocol_player, protocol_domain)
161 protocol_player.update_state()
162 parent_player.update_state()
163 return
164 # Parent not registered yet - skip evaluation (no universal player created)
165 return
166
167 # Look for a matching native player
168 # Protocol players should only link to:
169 # 1. True native players (Sonos, etc.)
170 # 2. Universal players
171 # NOT to other protocol players (they get merged via universal_player)
172 for native_player in self.all_players(return_protocol_players=False):
173 if native_player.player_id == protocol_player.player_id:
174 continue
175 # Skip all protocol players - they should be handled via universal_player
176 if native_player.state.type == PlayerType.PROTOCOL:
177 continue
178
179 # For universal players, check if this protocol player is in its stored list
180 if native_player.provider.domain == "universal_player":
181 if isinstance(native_player, UniversalPlayer):
182 if protocol_player.player_id in native_player._protocol_player_ids:
183 self._add_protocol_link(native_player, protocol_player, protocol_domain)
184 # Copy identifiers from protocol player to universal player
185 # This is important for restored universal players which start
186 # with empty identifiers
187 for conn_type, value in protocol_player.device_info.identifiers.items():
188 native_player.device_info.add_identifier(conn_type, value)
189 # Update model/manufacturer if universal player has generic values
190 self._update_universal_device_info(native_player, protocol_player)
191 # Update availability from protocol players
192 native_player.update_from_protocol_players()
193 # Persist updated data to config (async via task)
194 self._save_universal_player_data(native_player)
195 protocol_player.update_state()
196 native_player.update_state()
197 return
198 continue
199
200 # Check cached protocol IDs first for fast matching on restart
201 cached_ids = self._get_cached_protocol_ids(native_player.player_id)
202 if protocol_player.player_id in cached_ids:
203 self._add_protocol_link(native_player, protocol_player, protocol_domain)
204 protocol_player.update_state()
205 native_player.update_state()
206 return
207
208 # Fallback to identifier matching
209 if self._identifiers_match(native_player, protocol_player, protocol_domain):
210 self._add_protocol_link(native_player, protocol_player, protocol_domain)
211 protocol_player.update_state()
212 native_player.update_state()
213 return
214
215 # No native player found - schedule delayed evaluation to allow other protocols to register
216 if not protocol_player.protocol_parent_id:
217 self._schedule_protocol_evaluation(protocol_player)
218
219 def _schedule_protocol_evaluation(self, protocol_player: Player) -> None:
220 """
221 Schedule a delayed protocol evaluation.
222
223 Delays evaluation to allow other protocol players and native players to register.
224 Uses a longer delay (30s) if this protocol player was previously linked to a native
225 player that hasn't registered yet, giving native providers time to start up.
226 """
227 player_id = protocol_player.player_id
228
229 # Cancel any existing pending evaluation for this player
230 if player_id in self._pending_protocol_evaluations:
231 self._pending_protocol_evaluations[player_id].cancel()
232
233 # Check if this protocol player has a cached parent (was previously linked)
234 cached_parent_id = self._get_cached_protocol_parent_id(player_id)
235 if cached_parent_id and not self.get_player(cached_parent_id):
236 # Previously linked to a native player that hasn't registered yet
237 # Use longer delay to give native providers time to start up
238 delay = 30.0
239 self.logger.debug(
240 "Protocol player %s waiting for cached parent %s (30s delay)",
241 player_id,
242 cached_parent_id,
243 )
244 else:
245 # Standard delay for protocol player discovery
246 # Allows time for other protocols and native players to register
247 delay = 10.0
248
249 # Schedule evaluation after the delay
250 handle = self.mass.loop.call_later(
251 delay,
252 lambda: self.mass.create_task(self._delayed_protocol_evaluation(player_id)),
253 )
254 self._pending_protocol_evaluations[player_id] = handle
255
256 async def _delayed_protocol_evaluation(self, player_id: str) -> None:
257 """
258 Perform delayed protocol evaluation.
259
260 Called after a delay to allow all protocol players for a device to register.
261 Decides whether to create a universal player, join an existing one, or
262 promote a single protocol player directly.
263 """
264 self._pending_protocol_evaluations.pop(player_id, None)
265
266 protocol_player = self.get_player(player_id)
267 if not protocol_player or protocol_player.protocol_parent_id:
268 return
269
270 protocol_domain = protocol_player.provider.domain
271
272 # Check if there's an existing universal player we should join
273 if existing_universal := self._find_matching_universal_player(protocol_player):
274 await self._add_protocol_to_existing_universal(
275 existing_universal, protocol_player, protocol_domain
276 )
277 return
278
279 # Find all protocol players that match this device's identifiers
280 matching_protocols = self._find_matching_protocol_players(protocol_player)
281
282 # Create or update UniversalPlayer for all protocol players
283 await self._create_or_update_universal_player(matching_protocols)
284
285 def _find_matching_protocol_players(self, protocol_player: Player) -> list[Player]:
286 """
287 Find all protocol players that match the same device as the given player.
288
289 Searches through all registered protocol players to find ones that share
290 identifiers (MAC, IP, UUID) with the given player, indicating they represent
291 the same physical device.
292 """
293 matching = [protocol_player]
294
295 for other_player in self.all_players(return_protocol_players=True):
296 if other_player.player_id == protocol_player.player_id:
297 continue
298 if other_player.state.type != PlayerType.PROTOCOL:
299 continue
300 if other_player.protocol_parent_id:
301 continue
302 if self._identifiers_match(protocol_player, other_player):
303 matching.append(other_player)
304
305 return matching
306
307 def _find_matching_universal_player(self, protocol_player: Player) -> Player | None:
308 """Find an existing universal player that matches this protocol player."""
309 for player in self._players.values():
310 if player.provider.domain != "universal_player":
311 continue
312 if self._identifiers_match(protocol_player, player, ""):
313 return player
314 return None
315
316 async def _add_protocol_to_existing_universal(
317 self, universal_player: Player, protocol_player: Player, protocol_domain: str
318 ) -> None:
319 """Add a protocol player to an existing universal player."""
320 self._add_protocol_link(universal_player, protocol_player, protocol_domain)
321
322 if isinstance(universal_player, UniversalPlayer):
323 universal_player.add_protocol_player(protocol_player.player_id)
324 for conn_type, value in protocol_player.device_info.identifiers.items():
325 universal_player.device_info.add_identifier(conn_type, value)
326 # Update model/manufacturer if universal player has generic values
327 self._update_universal_device_info(universal_player, protocol_player)
328 # Update availability from protocol players
329 universal_player.update_from_protocol_players()
330
331 # Persist all player data (protocol IDs, identifiers, device info) to config
332 for provider in self.mass.get_providers(ProviderType.PLAYER):
333 if provider.domain == "universal_player":
334 await cast("UniversalPlayerProvider", provider)._save_player_data(
335 universal_player.player_id, universal_player
336 )
337 break
338
339 protocol_player.update_state()
340 universal_player.update_state()
341
342 def _update_universal_device_info(
343 self, universal_player: UniversalPlayer, protocol_player: Player
344 ) -> None:
345 """
346 Update universal player's device info from protocol player if needed.
347
348 When a universal player is restored from config, it has generic device info
349 (model="Universal Player", manufacturer="Music Assistant"). This method
350 updates those values from a protocol player that has real device info.
351 """
352 # Check if universal player has generic device info (from restore)
353 device_info = universal_player.device_info
354 protocol_info = protocol_player.device_info
355
356 # Update model if universal player has generic value
357 if device_info.model in (None, "Universal Player") and protocol_info.model:
358 device_info.model = protocol_info.model
359
360 # Update manufacturer if universal player has generic value
361 if device_info.manufacturer in (None, "Music Assistant") and protocol_info.manufacturer:
362 device_info.manufacturer = protocol_info.manufacturer
363
364 def _save_universal_player_data(self, universal_player: UniversalPlayer) -> None:
365 """
366 Save universal player data to config via background task.
367
368 This is a helper to persist player data from synchronous code.
369 """
370
371 async def _do_save() -> None:
372 for provider in self.mass.get_providers(ProviderType.PLAYER):
373 if provider.domain == "universal_player":
374 await cast("UniversalPlayerProvider", provider)._save_player_data(
375 universal_player.player_id, universal_player
376 )
377 break
378
379 self.mass.create_task(_do_save())
380
381 def _link_protocols_to_universal(
382 self, universal_player: Player, protocol_players: list[Player]
383 ) -> None:
384 """Link protocol players to a universal player, cleaning up existing links."""
385 for player in protocol_players:
386 # Clean up if linked to another player
387 if player.protocol_parent_id:
388 if parent := self.get_player(player.protocol_parent_id):
389 self._remove_protocol_link(parent, player.player_id)
390 player.set_protocol_parent_id(None)
391 # Link to universal player
392 self._add_protocol_link(universal_player, player, player.provider.domain)
393 player.update_state()
394
395 # Update availability from protocol players
396 if isinstance(universal_player, UniversalPlayer):
397 universal_player.update_from_protocol_players()
398
399 async def _create_or_update_universal_player(self, protocol_players: list[Player]) -> None:
400 """
401 Create or update a UniversalPlayer for a set of protocol players.
402
403 Delegates to the universal player provider which handles orchestration,
404 locking, and player creation. The controller then links the protocols
405 to the universal player.
406 """
407 # Get the universal_player provider
408 universal_provider: UniversalPlayerProvider | None = None
409 for provider in self.mass.get_providers(ProviderType.PLAYER):
410 if provider.domain == "universal_player":
411 universal_provider = cast("UniversalPlayerProvider", provider)
412 break
413
414 if not universal_provider:
415 return
416
417 # Delegate to provider - it handles locking, create/update decision, etc.
418 universal_player = await universal_provider.ensure_universal_player_for_protocols(
419 protocol_players
420 )
421
422 if not universal_player:
423 return
424
425 # Link the protocols to the universal player (controller manages cross-provider state)
426 self._link_protocols_to_universal(universal_player, protocol_players)
427 universal_player.update_state()
428
429 def _try_link_protocols_to_native(self, native_player: Player) -> None:
430 """Try to link protocol players to a native player."""
431 # First, check if there's a universal player for this device that should be replaced
432 self._check_replace_universal_player(native_player)
433
434 # Look for protocol players that should be linked
435 for protocol_player in self.all_players(return_protocol_players=True):
436 if protocol_player.state.type != PlayerType.PROTOCOL:
437 continue
438 if protocol_player.protocol_parent_id:
439 # Already linked to a parent (could be this native player after replacement)
440 continue
441
442 protocol_domain = protocol_player.provider.domain
443 if self._identifiers_match(native_player, protocol_player, protocol_domain):
444 self._add_protocol_link(native_player, protocol_player, protocol_domain)
445 protocol_player.update_state()
446 native_player.update_state()
447
448 # Proactively recover disabled/missing protocols from config
449 # This ensures disabled protocols show up in the UI so they can be re-enabled
450 self._recover_cached_protocol_links(native_player)
451
452 def _check_replace_universal_player(self, native_player: Player) -> None:
453 """Check if a universal player should be replaced by this native player."""
454 # Skip if native_player is itself a universal player (prevent self-replacement)
455 if native_player.provider.domain == "universal_player":
456 return
457
458 # Look for universal players that match this native player
459 for player in list(self._players.values()):
460 if player.provider.domain != "universal_player":
461 continue
462 if not self._identifiers_match(native_player, player, ""):
463 continue
464
465 # Transfer all protocol links from universal player to native player
466 for linked in list(player.linked_output_protocols):
467 if protocol_player := self.get_player(linked.output_protocol_id):
468 protocol_player.set_protocol_parent_id(None)
469 domain = linked.protocol_domain or protocol_player.provider.domain
470 self._add_protocol_link(native_player, protocol_player, domain)
471 protocol_player.update_state()
472
473 player.set_linked_output_protocols([])
474 native_player.update_state()
475
476 # Remove the now-obsolete universal player
477 self.mass.create_task(self.unregister(player.player_id, permanent=True))
478
479 def _add_protocol_link(
480 self, native_player: Player, protocol_player: Player, protocol_domain: str
481 ) -> None:
482 """Add a protocol link from native player to protocol player."""
483 # Remove any existing link for the same protocol domain
484 updated_protocols = [
485 link
486 for link in native_player.linked_output_protocols
487 if link.protocol_domain != protocol_domain
488 ]
489
490 # Get priority for this protocol
491 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
492
493 # Add the new link
494 updated_protocols.append(
495 OutputProtocol(
496 output_protocol_id=protocol_player.player_id,
497 name=protocol_player.provider.name,
498 protocol_domain=protocol_domain,
499 priority=priority,
500 )
501 )
502 native_player.set_linked_output_protocols(updated_protocols)
503
504 # Set protocol player's parent
505 protocol_player.set_protocol_parent_id(native_player.player_id)
506
507 # Persist linked protocol IDs to config for fast restart
508 # (only for non-universal players, as universal players handle this themselves)
509 if native_player.provider.domain != "universal_player":
510 self._save_linked_protocol_ids(native_player)
511 # Also save the parent ID on the protocol player for reverse lookup on restart
512 self._save_protocol_parent_id(protocol_player.player_id, native_player.player_id)
513
514 def _remove_protocol_link(
515 self, native_player: Player, protocol_player_id: str, permanent: bool = False
516 ) -> None:
517 """
518 Remove a protocol link.
519
520 :param native_player: The parent player to remove the link from.
521 :param protocol_player_id: The protocol player ID to unlink.
522 :param permanent: If True, also removes the protocol ID from the cached list.
523 Use this when the protocol player config is being deleted. If False,
524 the protocol ID remains in the cache so it can be shown as disabled
525 and re-enabled later.
526 """
527 updated_protocols = [
528 link
529 for link in native_player.linked_output_protocols
530 if link.output_protocol_id != protocol_player_id
531 ]
532 native_player.set_linked_output_protocols(updated_protocols)
533
534 # Clear parent reference on protocol player if it still exists
535 if protocol_player := self.get_player(protocol_player_id):
536 if protocol_player.protocol_parent_id == native_player.player_id:
537 protocol_player.set_protocol_parent_id(None)
538
539 # Update persisted linked protocol IDs and clear cached parent
540 if native_player.provider.domain != "universal_player":
541 if permanent:
542 # Permanently remove from cache (player config is being deleted)
543 self._remove_protocol_id_from_cache(native_player.player_id, protocol_player_id)
544 # Note: we don't call _save_linked_protocol_ids here anymore for non-permanent
545 # removals because the merge approach will preserve the ID in the cache
546 self._clear_protocol_parent_id(protocol_player_id)
547
548 def _save_linked_protocol_ids(self, native_player: Player) -> None:
549 """
550 Save linked protocol IDs to config for persistence across restarts.
551
552 This method merges active protocol IDs with existing cached IDs to preserve
553 disabled protocol players in the cache. This allows disabled protocols to be
554 shown in the UI so they can be re-enabled.
555 """
556 conf_key = (
557 f"{CONF_PLAYERS}/{native_player.player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
558 )
559 # Get existing cached IDs to preserve disabled protocols
560 existing_ids: list[str] = self.mass.config.get(conf_key, [])
561 # Get currently active protocol IDs
562 active_ids = {link.output_protocol_id for link in native_player.linked_output_protocols}
563 # Merge: keep existing IDs and add any new active ones
564 merged_ids = list(existing_ids)
565 for protocol_id in active_ids:
566 if protocol_id not in merged_ids:
567 merged_ids.append(protocol_id)
568 self.mass.config.set(conf_key, merged_ids)
569
570 def _get_cached_protocol_ids(self, player_id: str) -> list[str]:
571 """Get cached linked protocol IDs from config."""
572 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
573 result = self.mass.config.get(conf_key, [])
574 return list(result) if result else []
575
576 def _remove_protocol_id_from_cache(
577 self, parent_player_id: str, protocol_player_id: str
578 ) -> None:
579 """
580 Permanently remove a protocol player ID from the cached linked protocol IDs.
581
582 Use this when a protocol player config is being deleted, not just disabled.
583 """
584 conf_key = f"{CONF_PLAYERS}/{parent_player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
585 cached_ids: list[str] = self.mass.config.get(conf_key, [])
586 if protocol_player_id in cached_ids:
587 cached_ids.remove(protocol_player_id)
588 self.mass.config.set(conf_key, cached_ids)
589
590 def _save_protocol_parent_id(self, protocol_player_id: str, parent_id: str) -> None:
591 """Save the parent ID for a protocol player for persistence across restarts."""
592 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
593 self.mass.config.set(conf_key, parent_id)
594
595 def _get_cached_protocol_parent_id(self, protocol_player_id: str) -> str | None:
596 """Get cached parent ID for a protocol player from config."""
597 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
598 result = self.mass.config.get(conf_key, None)
599 return str(result) if result else None
600
601 def _clear_protocol_parent_id(self, protocol_player_id: str) -> None:
602 """Clear the cached parent ID for a protocol player."""
603 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
604 self.mass.config.set(conf_key, None)
605
606 def _recover_cached_protocol_links(self, native_player: Player) -> None:
607 """
608 Recover protocol links from config for disabled/missing protocols.
609
610 This ensures that disabled protocols show up in the output_protocols list
611 so they can be re-enabled by the user. It also handles the case where
612 protocol players haven't registered yet during startup.
613 """
614 # Get currently linked protocol IDs
615 linked_protocol_ids = {
616 link.output_protocol_id for link in native_player.linked_output_protocols
617 }
618
619 # Get cached protocol IDs from config (includes protocols that were explicitly linked)
620 cached_protocol_ids = self._get_cached_protocol_ids(native_player.player_id)
621
622 # Also check all protocol players that have protocol_parent_id pointing to this player
623 # (this handles disabled protocols that may not be in linked_protocol_player_ids)
624 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
625 for protocol_id, protocol_config in all_player_configs.items():
626 # Skip if not a protocol player
627 if protocol_config.get("player_type") != "protocol":
628 continue
629 # Check if this protocol has a parent_id pointing to this native player
630 protocol_values = protocol_config.get("values", {})
631 protocol_parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
632 if protocol_parent_id == native_player.player_id:
633 if protocol_id not in cached_protocol_ids:
634 cached_protocol_ids.append(protocol_id)
635
636 if not cached_protocol_ids:
637 return
638
639 # Add OutputProtocol entries for any cached protocols that aren't currently linked
640 for protocol_id in cached_protocol_ids:
641 if protocol_id in linked_protocol_ids:
642 continue # Already linked
643
644 # Get protocol player config to determine the protocol domain and availability
645 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
646 if not protocol_config:
647 continue
648
649 # Determine protocol domain from provider
650 protocol_provider = protocol_config.get("provider")
651 if not protocol_provider:
652 continue
653
654 # Get provider name for display
655 provider_name = "Protocol" # Default fallback
656 for provider in self.mass.get_providers(ProviderType.PLAYER):
657 if provider.domain == protocol_provider:
658 provider_name = provider.name
659 break
660
661 # Get priority for this protocol
662 priority = PROTOCOL_PRIORITY.get(protocol_provider, 100)
663
664 # Check if protocol player is available (registered)
665 protocol_player = self.get_player(protocol_id)
666 is_available = protocol_player is not None and protocol_player.available
667
668 # Add the OutputProtocol entry
669 native_player.linked_output_protocols.append(
670 OutputProtocol(
671 output_protocol_id=protocol_id,
672 name=provider_name,
673 protocol_domain=protocol_provider,
674 priority=priority,
675 is_native=False,
676 available=is_available,
677 )
678 )
679 self.logger.debug(
680 "Recovered cached protocol link %s -> %s (available: %s)",
681 native_player.player_id,
682 protocol_id,
683 is_available,
684 )
685
686 def _cleanup_protocol_links(self, player: Player) -> None:
687 """Clean up protocol links when a player is permanently removed."""
688 if player.state.type == PlayerType.PROTOCOL:
689 # Protocol player being removed: remove link from parent
690 if parent_id := player.protocol_parent_id:
691 if parent_player := self.get_player(parent_id):
692 # Use permanent=True to also remove from cached protocol IDs
693 self._remove_protocol_link(parent_player, player.player_id, permanent=True)
694 if (
695 parent_player.provider.domain == "universal_player"
696 and len(parent_player.linked_output_protocols) == 0
697 ):
698 # No protocols left - remove universal player
699 self.logger.info(
700 "Universal player %s has no protocols left, removing",
701 parent_id,
702 )
703 self.mass.create_task(
704 self.mass.players.unregister(parent_id, permanent=True)
705 )
706 else:
707 parent_player.update_state()
708 else:
709 # Native player being removed: schedule protocol evaluation for linked protocols
710 # so they can be assigned to a universal player
711 for linked in player.linked_output_protocols:
712 if protocol_player := self.get_player(linked.output_protocol_id):
713 protocol_player.set_protocol_parent_id(None)
714 protocol_player.update_state()
715 self.logger.debug(
716 "Native player %s removed - scheduling evaluation for %s",
717 player.player_id,
718 protocol_player.player_id,
719 )
720 self._schedule_protocol_evaluation(protocol_player)
721
722 def _identifiers_match(
723 self, player_a: Player, player_b: Player, protocol_domain: str = ""
724 ) -> bool:
725 """
726 Check if identifiers match between two players.
727
728 Matching is done by comparing connection identifiers (MAC, serial, UUID).
729 IP address is used as a fallback for protocol players only, because some
730 devices report different virtual MAC addresses per protocol (e.g., DLNA vs
731 AirPlay vs Chromecast may all have different MACs for the same device).
732 """
733 identifiers_a = player_a.device_info.identifiers
734 identifiers_b = player_b.device_info.identifiers
735
736 # Check identifiers in order of reliability
737 # MAC_ADDRESS > SERIAL_NUMBER > UUID
738 for conn_type in (
739 IdentifierType.MAC_ADDRESS,
740 IdentifierType.SERIAL_NUMBER,
741 IdentifierType.UUID,
742 ):
743 val_a = identifiers_a.get(conn_type)
744 val_b = identifiers_b.get(conn_type)
745
746 if not val_a or not val_b:
747 continue
748
749 # Normalize values for comparison
750 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
751 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
752
753 # Direct match
754 if val_a_norm == val_b_norm:
755 return True
756
757 # Special case: Sonos UUID matching with DLNA _MR suffix
758 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
759 if conn_type == IdentifierType.UUID:
760 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
761 return True
762 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
763 return True
764
765 # Fallback: IP address matching for protocol players only
766 # Some devices report different virtual MAC addresses per protocol,
767 # but the IP address remains the same. Only use this for protocol-to-protocol
768 # or protocol-to-universal matching to avoid false positives.
769 if self._can_use_ip_matching(player_a, player_b):
770 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
771 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
772 if ip_a and ip_b and ip_a == ip_b:
773 return True
774
775 return False
776
777 def _can_use_ip_matching(self, player_a: Player, player_b: Player) -> bool:
778 """
779 Check if IP address matching can be used between two players.
780
781 IP matching is only allowed when at least one player is a protocol player
782 or universal player, to avoid false positives between unrelated devices.
783 """
784 # Check if at least one is a protocol player or universal player
785 a_is_protocol = (
786 player_a.type == PlayerType.PROTOCOL or player_a.provider.domain == "universal_player"
787 )
788 b_is_protocol = (
789 player_b.type == PlayerType.PROTOCOL or player_b.provider.domain == "universal_player"
790 )
791 return a_is_protocol or b_is_protocol
792
793 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
794 """
795 Select the best available output protocol for a player.
796
797 Selection priority:
798 1. Output protocol that is currently grouped/synced with other players.
799 2. User's preferred output protocol (from player settings).
800 3. Native playback (if player supports PLAY_MEDIA).
801 4. Best available protocol by priority.
802
803 Returns tuple of (target_player, output_protocol).
804 output_protocol is None when using native playback.
805 """
806 self.logger.log(
807 VERBOSE_LOG_LEVEL,
808 "Selecting output protocol for %s",
809 player.state.name,
810 )
811
812 # 1. Check if any output protocol is currently grouped
813 for linked in player.linked_output_protocols:
814 if protocol_player := self.get_player(linked.output_protocol_id):
815 if protocol_player.available and self._is_protocol_grouped(protocol_player):
816 self.logger.log(
817 VERBOSE_LOG_LEVEL,
818 "Selected protocol for %s: %s (grouped)",
819 player.state.name,
820 protocol_player.state.name,
821 )
822 return protocol_player, linked
823
824 # 2. Check for user's preferred output protocol
825 preferred = self.mass.config.get_raw_player_config_value(
826 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
827 )
828 if preferred and preferred != "auto":
829 if preferred == "native":
830 if PlayerFeature.PLAY_MEDIA in player.supported_features:
831 self.logger.log(
832 VERBOSE_LOG_LEVEL,
833 "Selected protocol for %s: native (user preference)",
834 player.state.name,
835 )
836 return player, None
837 else:
838 for linked in player.linked_output_protocols:
839 if linked.output_protocol_id == preferred:
840 if protocol_player := self.get_player(linked.output_protocol_id):
841 if protocol_player.available:
842 self.logger.log(
843 VERBOSE_LOG_LEVEL,
844 "Selected protocol for %s: %s (user preference)",
845 player.state.name,
846 protocol_player.state.name,
847 )
848 return protocol_player, linked
849 break
850
851 # 3. Use native playback if available
852 if PlayerFeature.PLAY_MEDIA in player.supported_features:
853 self.logger.log(
854 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
855 )
856 return player, None
857
858 # 4. Fall back to best protocol by priority
859 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
860 if protocol_player := self.get_player(linked.output_protocol_id):
861 if protocol_player.available:
862 self.logger.log(
863 VERBOSE_LOG_LEVEL,
864 "Selected protocol for %s: %s (priority-based)",
865 player.state.name,
866 protocol_player.state.name,
867 )
868 return protocol_player, linked
869
870 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
871
872 def _get_control_target(
873 self,
874 player: Player,
875 required_feature: PlayerFeature,
876 require_active: bool = False,
877 allow_native: bool = True,
878 ) -> Player | None:
879 """
880 Get the best player(protocol) to send control commands to.
881
882 Prefers the active output protocol, otherwise uses the first available
883 protocol player that supports the needed feature.
884 """
885 # If we have an active protocol, use that
886 if (
887 player.active_output_protocol
888 and player.active_output_protocol != "native"
889 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
890 and required_feature in protocol_player.supported_features
891 ):
892 return protocol_player
893
894 # if the player natively supports the required feature, use that
895 if allow_native and required_feature in player.supported_features:
896 return player
897
898 # If require_active is set, and no active protocol found, return None
899 if require_active:
900 return None
901
902 # Otherwise, use the first available linked protocol
903 for linked in player.linked_output_protocols:
904 if (
905 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
906 and protocol_player.available
907 and required_feature in protocol_player.supported_features
908 ):
909 return protocol_player
910
911 return None
912
913 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
914 """
915 Check if a protocol player is currently grouped/synced with other players.
916
917 Used to prefer protocols that are actively participating in a group,
918 ensuring consistent playback across grouped players.
919 """
920 is_grouped = bool(
921 protocol_player.state.synced_to
922 or (
923 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
924 )
925 or protocol_player.state.active_group
926 )
927 if is_grouped:
928 self.logger.log(
929 VERBOSE_LOG_LEVEL,
930 "Protocol player %s is grouped",
931 protocol_player.state.name,
932 )
933 return is_grouped
934
935 def _translate_members_to_remove_for_protocols(
936 self,
937 parent_player: Player,
938 player_ids: list[str],
939 parent_protocol_player: Player | None,
940 parent_protocol_domain: str | None,
941 ) -> tuple[list[str], list[str]]:
942 """
943 Translate member IDs to remove into protocol and native lists.
944
945 :param parent_player: The parent player to remove members from.
946 :param player_ids: List of visible player IDs to remove.
947 :param parent_protocol_player: The parent's protocol player if available.
948 :param parent_protocol_domain: The parent's protocol domain if available.
949 """
950 self.logger.debug(
951 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
952 parent_player.state.name,
953 player_ids,
954 parent_protocol_domain,
955 )
956 protocol_members: list[str] = []
957 native_members: list[str] = []
958
959 for child_player_id in player_ids:
960 child_player = self.get_player(child_player_id)
961 if not child_player:
962 continue
963
964 # Check if this member is in the parent's group via protocol
965 if parent_protocol_domain and parent_protocol_player:
966 child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
967 if (
968 child_protocol
969 and child_protocol.output_protocol_id in parent_protocol_player.group_members
970 ):
971 self.logger.debug(
972 "Translating removal: %s -> protocol %s",
973 child_player_id,
974 child_protocol.output_protocol_id,
975 )
976 protocol_members.append(child_protocol.output_protocol_id)
977 continue
978
979 native_members.append(child_player_id)
980
981 return protocol_members, native_members
982
983 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
984 """Filter member IDs to only include protocol players from the same domain."""
985 return [
986 pid
987 for pid in member_ids
988 if (p := self.get_player(pid))
989 and p.type == PlayerType.PROTOCOL
990 and p.provider.domain == protocol_player.provider.domain
991 ]
992
993 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
994 """Filter member IDs to only include players compatible with the parent."""
995 return [
996 pid
997 for pid in member_ids
998 if (p := self.get_player(pid))
999 and (
1000 p.provider.instance_id == parent_player.provider.instance_id
1001 or pid in parent_player._attr_can_group_with
1002 or p.provider.instance_id in parent_player._attr_can_group_with
1003 )
1004 ]
1005
1006 def _try_child_preferred_protocol(
1007 self,
1008 child_player: Player,
1009 parent_player: Player,
1010 ) -> tuple[str | None, str | None]:
1011 """
1012 Try to use child's preferred output protocol for grouping.
1013
1014 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
1015 """
1016 child_preferred = self.mass.config.get_raw_player_config_value(
1017 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
1018 )
1019 if not child_preferred or child_preferred in {"auto", "native"}:
1020 return None, None
1021
1022 # Find child's preferred protocol in linked protocols
1023 child_protocol = None
1024 for linked in child_player.linked_output_protocols:
1025 if linked.output_protocol_id == child_preferred:
1026 child_protocol = linked
1027 break
1028
1029 if not child_protocol or not child_protocol.available:
1030 return None, None
1031
1032 # Check if parent supports this protocol
1033 parent_protocol = parent_player.get_linked_protocol(child_protocol.protocol_domain)
1034 if not parent_protocol or not parent_protocol.available:
1035 return None, None
1036
1037 # Check if this protocol supports set_members
1038 protocol_player = self.get_player(parent_protocol.output_protocol_id)
1039 if (
1040 not protocol_player
1041 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
1042 ):
1043 return None, None
1044
1045 return child_protocol.output_protocol_id, child_protocol.protocol_domain
1046
1047 def _can_use_native_grouping(
1048 self,
1049 child_player: Player,
1050 parent_player: Player,
1051 parent_supports_native: bool,
1052 ) -> bool:
1053 """Check if child can be grouped with parent using native grouping."""
1054 if not parent_supports_native:
1055 return False
1056 return (
1057 child_player.provider.instance_id == parent_player.provider.instance_id
1058 or child_player.player_id in parent_player._attr_can_group_with
1059 or child_player.provider.instance_id in parent_player._attr_can_group_with
1060 )
1061
1062 def _try_find_common_protocol(
1063 self, child_player: Player, parent_player: Player
1064 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
1065 """
1066 Find common protocol that supports set_members.
1067
1068 Returns tuple of (parent_protocol, child_protocol) or (None, None).
1069 """
1070 for parent_output_protocol in parent_player.output_protocols:
1071 if not parent_output_protocol.available:
1072 continue
1073 child_protocol = child_player.get_linked_protocol(
1074 parent_output_protocol.protocol_domain
1075 )
1076 if not child_protocol or not child_protocol.available:
1077 continue
1078 protocol_player = self.get_player(parent_output_protocol.output_protocol_id)
1079 if (
1080 protocol_player
1081 and PlayerFeature.SET_MEMBERS in protocol_player.state.supported_features
1082 ):
1083 return parent_output_protocol, child_protocol
1084 return None, None
1085
1086 def _translate_members_for_protocols(
1087 self,
1088 parent_player: Player,
1089 player_ids: list[str],
1090 parent_protocol_player: Player | None,
1091 parent_protocol_domain: str | None,
1092 ) -> tuple[list[str], list[str], Player | None, str | None]:
1093 """
1094 Translate member IDs to protocol or native IDs.
1095
1096 Selection priority when grouping:
1097 1. Try child's preferred output protocol (from player settings)
1098 2. Try native grouping (if parent and child are compatible)
1099 3. Try parent's active output protocol (if any and child supports it)
1100 4. Search for common protocol that supports set_members
1101 5. Log warning if no option works
1102
1103 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
1104 """
1105 protocol_members: list[str] = []
1106 native_members: list[str] = []
1107 parent_supports_native_grouping = (
1108 PlayerFeature.SET_MEMBERS in parent_player.supported_features
1109 )
1110
1111 self.logger.log(
1112 VERBOSE_LOG_LEVEL,
1113 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
1114 parent_player.state.name,
1115 parent_supports_native_grouping,
1116 parent_protocol_player.state.name if parent_protocol_player else "none",
1117 parent_protocol_domain or "none",
1118 )
1119
1120 for child_player_id in player_ids:
1121 child_player = self.get_player(child_player_id)
1122 if not child_player:
1123 continue
1124
1125 self.logger.log(
1126 VERBOSE_LOG_LEVEL,
1127 "Processing child %s (type=%s, protocols=%s)",
1128 child_player.state.name,
1129 child_player.state.type,
1130 [p.protocol_domain for p in child_player.output_protocols],
1131 )
1132
1133 # Priority 1: Try child's preferred output protocol
1134 # (only if no active protocol or if it matches the active protocol)
1135 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
1136 child_player, parent_player
1137 )
1138 if (
1139 child_protocol_id
1140 and protocol_domain
1141 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
1142 ):
1143 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
1144 parent_protocol = parent_player.get_linked_protocol(protocol_domain)
1145 if parent_protocol:
1146 parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
1147 parent_protocol_domain = protocol_domain
1148 protocol_members.append(child_protocol_id)
1149 self.logger.log(
1150 VERBOSE_LOG_LEVEL,
1151 "Using child's preferred protocol %s for %s",
1152 protocol_domain,
1153 child_player.state.name,
1154 )
1155 continue
1156
1157 # Priority 2: Try native grouping
1158 if self._can_use_native_grouping(
1159 child_player, parent_player, parent_supports_native_grouping
1160 ):
1161 native_members.append(child_player_id)
1162 self.logger.log(
1163 VERBOSE_LOG_LEVEL,
1164 "Using native grouping for %s",
1165 child_player.state.name,
1166 )
1167 continue
1168
1169 # Priority 3: Try parent's active output protocol (if it supports SET_MEMBERS)
1170 if parent_protocol_domain and parent_protocol_player:
1171 # Verify the active protocol supports SET_MEMBERS
1172 if PlayerFeature.SET_MEMBERS in parent_protocol_player.state.supported_features:
1173 child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
1174 if child_protocol and child_protocol.available:
1175 protocol_members.append(child_protocol.output_protocol_id)
1176 self.logger.log(
1177 VERBOSE_LOG_LEVEL,
1178 "Using parent's active protocol %s for %s",
1179 parent_protocol_domain,
1180 child_player.state.name,
1181 )
1182 continue
1183 else:
1184 self.logger.log(
1185 VERBOSE_LOG_LEVEL,
1186 "Parent's active protocol %s does not support SET_MEMBERS, "
1187 "will search for alternative",
1188 parent_protocol_domain,
1189 )
1190 # Clear the parent protocol so Priority 4 can select a new one
1191 parent_protocol_player = None
1192 parent_protocol_domain = None
1193
1194 # Priority 4: Search for common protocol that supports set_members
1195 parent_protocol, child_protocol = self._try_find_common_protocol(
1196 child_player, parent_player
1197 )
1198 if parent_protocol and child_protocol:
1199 if (
1200 not parent_protocol_player
1201 or parent_protocol_domain != parent_protocol.protocol_domain
1202 ):
1203 parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
1204 if parent_protocol_player:
1205 parent_protocol_domain = parent_protocol_player.provider.domain
1206 protocol_members.append(child_protocol.output_protocol_id)
1207 self.logger.log(
1208 VERBOSE_LOG_LEVEL,
1209 "Selected common protocol %s for grouping %s with %s",
1210 parent_protocol.protocol_domain,
1211 child_player.state.name,
1212 parent_player.state.name,
1213 )
1214 continue
1215
1216 # Priority 5: No option worked - log warning
1217 self.logger.warning(
1218 "Cannot group %s with %s: no compatible grouping method found "
1219 "(tried: child preferred protocol, native grouping, "
1220 "parent active protocol, common protocols)",
1221 child_player.state.name,
1222 parent_player.state.name,
1223 )
1224
1225 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
1226
1227 async def _forward_protocol_set_members(
1228 self,
1229 parent_player: Player,
1230 parent_protocol_player: Player,
1231 protocol_members_to_add: list[str],
1232 protocol_members_to_remove: list[str],
1233 ) -> None:
1234 """
1235 Forward protocol members to protocol player's set_members and manage active output protocol.
1236
1237 :param parent_player: The parent player (native/universal).
1238 :param parent_protocol_player: The protocol player to forward commands to.
1239 :param protocol_members_to_add: Protocol player IDs to add.
1240 :param protocol_members_to_remove: Protocol player IDs to remove.
1241 """
1242 filtered_protocol_add = self._filter_protocol_members(
1243 protocol_members_to_add, parent_protocol_player
1244 )
1245 filtered_protocol_remove = self._filter_protocol_members(
1246 protocol_members_to_remove, parent_protocol_player
1247 )
1248 self.logger.debug(
1249 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
1250 parent_protocol_player.state.name,
1251 filtered_protocol_add,
1252 filtered_protocol_remove,
1253 )
1254
1255 if not filtered_protocol_add and not filtered_protocol_remove:
1256 return
1257
1258 # Safety check: verify protocol player supports SET_MEMBERS
1259 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
1260 self.logger.error(
1261 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
1262 "This should have been caught earlier in the flow.",
1263 parent_protocol_player.state.name,
1264 )
1265 return
1266
1267 self.logger.debug(
1268 "Calling set_members on protocol player %s with add=%s, remove=%s",
1269 parent_protocol_player.state.name,
1270 filtered_protocol_add,
1271 filtered_protocol_remove,
1272 )
1273 await parent_protocol_player.set_members(
1274 player_ids_to_add=filtered_protocol_add or None,
1275 player_ids_to_remove=filtered_protocol_remove or None,
1276 )
1277
1278 # If we added members via this protocol, set it as the active output protocol
1279 # This ensures playback will be restarted on the correct protocol if needed
1280 if (
1281 filtered_protocol_add
1282 and parent_player.active_output_protocol != parent_protocol_player.player_id
1283 ):
1284 self.logger.debug(
1285 "Setting active output protocol to %s after grouping members",
1286 parent_protocol_player.player_id,
1287 )
1288 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
1289 self.logger.debug(
1290 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
1291 parent_protocol_player.state.name,
1292 parent_protocol_player.group_members,
1293 parent_protocol_player.synced_to,
1294 )
1295
1296 # Clear active protocol if all protocol members were removed
1297 if (
1298 filtered_protocol_remove
1299 and not filtered_protocol_add
1300 and parent_protocol_player.player_id == parent_player.active_output_protocol
1301 ):
1302 # Check group_members count to see if we should clear
1303 members_count = len(parent_protocol_player.group_members)
1304 self.logger.debug(
1305 "Checking if should clear active protocol on %s: "
1306 "protocol_members_count=%s, removing=%s",
1307 parent_player.state.name,
1308 members_count,
1309 filtered_protocol_remove,
1310 )
1311 if members_count <= 1 and parent_player.state.playback_state == PlaybackState.IDLE:
1312 parent_player.set_active_output_protocol(None)
1313
1314 # Clear active output protocol on removed child players
1315 if filtered_protocol_remove:
1316 for child_protocol_id in filtered_protocol_remove:
1317 if child_protocol := self.get_player(child_protocol_id):
1318 if child_protocol.protocol_parent_id:
1319 if child_player := self.get_player(child_protocol.protocol_parent_id):
1320 if child_player.active_output_protocol == child_protocol_id:
1321 child_player.set_active_output_protocol(None)
1322