/
/
/
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 # Extract domain from provider instance_id (e.g., "airplay--uuid" -> "airplay")
655 protocol_domain = protocol_provider.split("--")[0]
656
657 # Get provider name for display
658 provider_name = "Protocol" # Default fallback
659 for provider in self.mass.get_providers(ProviderType.PLAYER):
660 if provider.domain == protocol_domain:
661 provider_name = provider.name
662 break
663
664 # Get priority for this protocol
665 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
666
667 # Check if protocol player is available (registered)
668 protocol_player = self.get_player(protocol_id)
669 is_available = protocol_player is not None and protocol_player.available
670
671 # Add the OutputProtocol entry
672 native_player.linked_output_protocols.append(
673 OutputProtocol(
674 output_protocol_id=protocol_id,
675 name=provider_name,
676 protocol_domain=protocol_domain,
677 priority=priority,
678 is_native=False,
679 available=is_available,
680 )
681 )
682 self.logger.debug(
683 "Recovered cached protocol link %s -> %s (available: %s)",
684 native_player.player_id,
685 protocol_id,
686 is_available,
687 )
688
689 def _cleanup_protocol_links(self, player: Player) -> None:
690 """Clean up protocol links when a player is permanently removed."""
691 if player.state.type == PlayerType.PROTOCOL:
692 # Protocol player being removed: remove link from parent
693 if parent_id := player.protocol_parent_id:
694 if parent_player := self.get_player(parent_id):
695 # Use permanent=True to also remove from cached protocol IDs
696 self._remove_protocol_link(parent_player, player.player_id, permanent=True)
697 if (
698 parent_player.provider.domain == "universal_player"
699 and len(parent_player.linked_output_protocols) == 0
700 ):
701 # No protocols left - remove universal player
702 self.logger.info(
703 "Universal player %s has no protocols left, removing",
704 parent_id,
705 )
706 self.mass.create_task(
707 self.mass.players.unregister(parent_id, permanent=True)
708 )
709 else:
710 parent_player.update_state()
711 else:
712 # Native player being removed: schedule protocol evaluation for linked protocols
713 # so they can be assigned to a universal player
714 for linked in player.linked_output_protocols:
715 if protocol_player := self.get_player(linked.output_protocol_id):
716 protocol_player.set_protocol_parent_id(None)
717 protocol_player.update_state()
718 self.logger.debug(
719 "Native player %s removed - scheduling evaluation for %s",
720 player.player_id,
721 protocol_player.player_id,
722 )
723 self._schedule_protocol_evaluation(protocol_player)
724
725 def _identifiers_match(
726 self, player_a: Player, player_b: Player, protocol_domain: str = ""
727 ) -> bool:
728 """
729 Check if identifiers match between two players.
730
731 Matching is done by comparing connection identifiers (MAC, serial, UUID).
732 IP address is used as a fallback for protocol players only, because some
733 devices report different virtual MAC addresses per protocol (e.g., DLNA vs
734 AirPlay vs Chromecast may all have different MACs for the same device).
735 """
736 identifiers_a = player_a.device_info.identifiers
737 identifiers_b = player_b.device_info.identifiers
738
739 # Check identifiers in order of reliability
740 # MAC_ADDRESS > SERIAL_NUMBER > UUID
741 for conn_type in (
742 IdentifierType.MAC_ADDRESS,
743 IdentifierType.SERIAL_NUMBER,
744 IdentifierType.UUID,
745 ):
746 val_a = identifiers_a.get(conn_type)
747 val_b = identifiers_b.get(conn_type)
748
749 if not val_a or not val_b:
750 continue
751
752 # Normalize values for comparison
753 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
754 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
755
756 # Direct match
757 if val_a_norm == val_b_norm:
758 return True
759
760 # Special case: Sonos UUID matching with DLNA _MR suffix
761 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
762 if conn_type == IdentifierType.UUID:
763 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
764 return True
765 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
766 return True
767
768 # Fallback: IP address matching for protocol players only
769 # Some devices report different virtual MAC addresses per protocol,
770 # but the IP address remains the same. Only use this for protocol-to-protocol
771 # or protocol-to-universal matching to avoid false positives.
772 if self._can_use_ip_matching(player_a, player_b):
773 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
774 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
775 if ip_a and ip_b and ip_a == ip_b:
776 return True
777
778 return False
779
780 def _can_use_ip_matching(self, player_a: Player, player_b: Player) -> bool:
781 """
782 Check if IP address matching can be used between two players.
783
784 IP matching is only allowed when at least one player is a protocol player
785 or universal player, to avoid false positives between unrelated devices.
786 """
787 # Check if at least one is a protocol player or universal player
788 a_is_protocol = (
789 player_a.type == PlayerType.PROTOCOL or player_a.provider.domain == "universal_player"
790 )
791 b_is_protocol = (
792 player_b.type == PlayerType.PROTOCOL or player_b.provider.domain == "universal_player"
793 )
794 return a_is_protocol or b_is_protocol
795
796 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
797 """
798 Select the best available output protocol for a player.
799
800 Selection priority:
801 1. Output protocol that is currently grouped/synced with other players.
802 2. User's preferred output protocol (from player settings).
803 3. Native playback (if player supports PLAY_MEDIA).
804 4. Best available protocol by priority.
805
806 Returns tuple of (target_player, output_protocol).
807 output_protocol is None when using native playback.
808 """
809 self.logger.log(
810 VERBOSE_LOG_LEVEL,
811 "Selecting output protocol for %s",
812 player.state.name,
813 )
814
815 # 1. Check if any output protocol is currently grouped
816 for linked in player.linked_output_protocols:
817 if protocol_player := self.get_player(linked.output_protocol_id):
818 if protocol_player.available and self._is_protocol_grouped(protocol_player):
819 self.logger.log(
820 VERBOSE_LOG_LEVEL,
821 "Selected protocol for %s: %s (grouped)",
822 player.state.name,
823 protocol_player.state.name,
824 )
825 return protocol_player, linked
826
827 # 2. Check for user's preferred output protocol
828 preferred = self.mass.config.get_raw_player_config_value(
829 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
830 )
831 if preferred and preferred != "auto":
832 if preferred == "native":
833 if PlayerFeature.PLAY_MEDIA in player.supported_features:
834 self.logger.log(
835 VERBOSE_LOG_LEVEL,
836 "Selected protocol for %s: native (user preference)",
837 player.state.name,
838 )
839 return player, None
840 else:
841 for linked in player.linked_output_protocols:
842 if linked.output_protocol_id == preferred:
843 if protocol_player := self.get_player(linked.output_protocol_id):
844 if protocol_player.available:
845 self.logger.log(
846 VERBOSE_LOG_LEVEL,
847 "Selected protocol for %s: %s (user preference)",
848 player.state.name,
849 protocol_player.state.name,
850 )
851 return protocol_player, linked
852 break
853
854 # 3. Use native playback if available
855 if PlayerFeature.PLAY_MEDIA in player.supported_features:
856 self.logger.log(
857 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
858 )
859 return player, None
860
861 # 4. Fall back to best protocol by priority
862 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
863 if protocol_player := self.get_player(linked.output_protocol_id):
864 if protocol_player.available:
865 self.logger.log(
866 VERBOSE_LOG_LEVEL,
867 "Selected protocol for %s: %s (priority-based)",
868 player.state.name,
869 protocol_player.state.name,
870 )
871 return protocol_player, linked
872
873 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
874
875 def _get_control_target(
876 self,
877 player: Player,
878 required_feature: PlayerFeature,
879 require_active: bool = False,
880 allow_native: bool = True,
881 ) -> Player | None:
882 """
883 Get the best player(protocol) to send control commands to.
884
885 Prefers the active output protocol, otherwise uses the first available
886 protocol player that supports the needed feature.
887 """
888 # If we have an active protocol, use that
889 if (
890 player.active_output_protocol
891 and player.active_output_protocol != "native"
892 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
893 and required_feature in protocol_player.supported_features
894 ):
895 return protocol_player
896
897 # if the player natively supports the required feature, use that
898 if allow_native and required_feature in player.supported_features:
899 return player
900
901 # If require_active is set, and no active protocol found, return None
902 if require_active:
903 return None
904
905 # Otherwise, use the first available linked protocol
906 for linked in player.linked_output_protocols:
907 if (
908 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
909 and protocol_player.available
910 and required_feature in protocol_player.supported_features
911 ):
912 return protocol_player
913
914 return None
915
916 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
917 """
918 Check if a protocol player is currently grouped/synced with other players.
919
920 Used to prefer protocols that are actively participating in a group,
921 ensuring consistent playback across grouped players.
922 """
923 is_grouped = bool(
924 protocol_player.state.synced_to
925 or (
926 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
927 )
928 or protocol_player.state.active_group
929 )
930 if is_grouped:
931 self.logger.log(
932 VERBOSE_LOG_LEVEL,
933 "Protocol player %s is grouped",
934 protocol_player.state.name,
935 )
936 return is_grouped
937
938 def _translate_members_to_remove_for_protocols(
939 self,
940 parent_player: Player,
941 player_ids: list[str],
942 parent_protocol_player: Player | None,
943 parent_protocol_domain: str | None,
944 ) -> tuple[list[str], list[str]]:
945 """
946 Translate member IDs to remove into protocol and native lists.
947
948 :param parent_player: The parent player to remove members from.
949 :param player_ids: List of visible player IDs to remove.
950 :param parent_protocol_player: The parent's protocol player if available.
951 :param parent_protocol_domain: The parent's protocol domain if available.
952 """
953 self.logger.debug(
954 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
955 parent_player.state.name,
956 player_ids,
957 parent_protocol_domain,
958 )
959 protocol_members: list[str] = []
960 native_members: list[str] = []
961
962 for child_player_id in player_ids:
963 child_player = self.get_player(child_player_id)
964 if not child_player:
965 continue
966
967 # Check if this member is in the parent's group via protocol
968 if parent_protocol_domain and parent_protocol_player:
969 child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
970 if (
971 child_protocol
972 and child_protocol.output_protocol_id in parent_protocol_player.group_members
973 ):
974 self.logger.debug(
975 "Translating removal: %s -> protocol %s",
976 child_player_id,
977 child_protocol.output_protocol_id,
978 )
979 protocol_members.append(child_protocol.output_protocol_id)
980 continue
981
982 native_members.append(child_player_id)
983
984 return protocol_members, native_members
985
986 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
987 """Filter member IDs to only include protocol players from the same domain."""
988 return [
989 pid
990 for pid in member_ids
991 if (p := self.get_player(pid))
992 and p.type == PlayerType.PROTOCOL
993 and p.provider.domain == protocol_player.provider.domain
994 ]
995
996 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
997 """Filter member IDs to only include players compatible with the parent."""
998 return [
999 pid
1000 for pid in member_ids
1001 if (p := self.get_player(pid))
1002 and (
1003 p.provider.instance_id == parent_player.provider.instance_id
1004 or pid in parent_player._attr_can_group_with
1005 or p.provider.instance_id in parent_player._attr_can_group_with
1006 )
1007 ]
1008
1009 def _try_child_preferred_protocol(
1010 self,
1011 child_player: Player,
1012 parent_player: Player,
1013 ) -> tuple[str | None, str | None]:
1014 """
1015 Try to use child's preferred output protocol for grouping.
1016
1017 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
1018 """
1019 child_preferred = self.mass.config.get_raw_player_config_value(
1020 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
1021 )
1022 if not child_preferred or child_preferred in {"auto", "native"}:
1023 return None, None
1024
1025 # Find child's preferred protocol in linked protocols
1026 child_protocol = None
1027 for linked in child_player.linked_output_protocols:
1028 if linked.output_protocol_id == child_preferred:
1029 child_protocol = linked
1030 break
1031
1032 if not child_protocol or not child_protocol.available:
1033 return None, None
1034
1035 # Check if parent supports this protocol
1036 parent_protocol = parent_player.get_linked_protocol(child_protocol.protocol_domain)
1037 if not parent_protocol or not parent_protocol.available:
1038 return None, None
1039
1040 # Check if this protocol supports set_members
1041 protocol_player = self.get_player(parent_protocol.output_protocol_id)
1042 if (
1043 not protocol_player
1044 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
1045 ):
1046 return None, None
1047
1048 return child_protocol.output_protocol_id, child_protocol.protocol_domain
1049
1050 def _can_use_native_grouping(
1051 self,
1052 child_player: Player,
1053 parent_player: Player,
1054 parent_supports_native: bool,
1055 ) -> bool:
1056 """Check if child can be grouped with parent using native grouping."""
1057 if not parent_supports_native:
1058 return False
1059 return (
1060 child_player.provider.instance_id == parent_player.provider.instance_id
1061 or child_player.player_id in parent_player._attr_can_group_with
1062 or child_player.provider.instance_id in parent_player._attr_can_group_with
1063 )
1064
1065 def _try_find_common_protocol(
1066 self, child_player: Player, parent_player: Player
1067 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
1068 """
1069 Find common protocol that supports set_members.
1070
1071 Returns tuple of (parent_protocol, child_protocol) or (None, None).
1072 """
1073 for parent_output_protocol in parent_player.output_protocols:
1074 if not parent_output_protocol.available:
1075 continue
1076 child_protocol = child_player.get_linked_protocol(
1077 parent_output_protocol.protocol_domain
1078 )
1079 if not child_protocol or not child_protocol.available:
1080 continue
1081 protocol_player = self.get_player(parent_output_protocol.output_protocol_id)
1082 if (
1083 protocol_player
1084 and PlayerFeature.SET_MEMBERS in protocol_player.state.supported_features
1085 ):
1086 return parent_output_protocol, child_protocol
1087 return None, None
1088
1089 def _translate_members_for_protocols(
1090 self,
1091 parent_player: Player,
1092 player_ids: list[str],
1093 parent_protocol_player: Player | None,
1094 parent_protocol_domain: str | None,
1095 ) -> tuple[list[str], list[str], Player | None, str | None]:
1096 """
1097 Translate member IDs to protocol or native IDs.
1098
1099 Selection priority when grouping:
1100 1. Try child's preferred output protocol (from player settings)
1101 2. Try native grouping (if parent and child are compatible)
1102 3. Try parent's active output protocol (if any and child supports it)
1103 4. Search for common protocol that supports set_members
1104 5. Log warning if no option works
1105
1106 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
1107 """
1108 protocol_members: list[str] = []
1109 native_members: list[str] = []
1110 parent_supports_native_grouping = (
1111 PlayerFeature.SET_MEMBERS in parent_player.supported_features
1112 )
1113
1114 self.logger.log(
1115 VERBOSE_LOG_LEVEL,
1116 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
1117 parent_player.state.name,
1118 parent_supports_native_grouping,
1119 parent_protocol_player.state.name if parent_protocol_player else "none",
1120 parent_protocol_domain or "none",
1121 )
1122
1123 for child_player_id in player_ids:
1124 child_player = self.get_player(child_player_id)
1125 if not child_player:
1126 continue
1127
1128 self.logger.log(
1129 VERBOSE_LOG_LEVEL,
1130 "Processing child %s (type=%s, protocols=%s)",
1131 child_player.state.name,
1132 child_player.state.type,
1133 [p.protocol_domain for p in child_player.output_protocols],
1134 )
1135
1136 # Priority 1: Try child's preferred output protocol
1137 # (only if no active protocol or if it matches the active protocol)
1138 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
1139 child_player, parent_player
1140 )
1141 if (
1142 child_protocol_id
1143 and protocol_domain
1144 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
1145 ):
1146 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
1147 parent_protocol = parent_player.get_linked_protocol(protocol_domain)
1148 if parent_protocol:
1149 parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
1150 parent_protocol_domain = protocol_domain
1151 protocol_members.append(child_protocol_id)
1152 self.logger.log(
1153 VERBOSE_LOG_LEVEL,
1154 "Using child's preferred protocol %s for %s",
1155 protocol_domain,
1156 child_player.state.name,
1157 )
1158 continue
1159
1160 # Priority 2: Try native grouping
1161 if self._can_use_native_grouping(
1162 child_player, parent_player, parent_supports_native_grouping
1163 ):
1164 native_members.append(child_player_id)
1165 self.logger.log(
1166 VERBOSE_LOG_LEVEL,
1167 "Using native grouping for %s",
1168 child_player.state.name,
1169 )
1170 continue
1171
1172 # Priority 3: Try parent's active output protocol (if it supports SET_MEMBERS)
1173 if parent_protocol_domain and parent_protocol_player:
1174 # Verify the active protocol supports SET_MEMBERS
1175 if PlayerFeature.SET_MEMBERS in parent_protocol_player.state.supported_features:
1176 child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
1177 if child_protocol and child_protocol.available:
1178 protocol_members.append(child_protocol.output_protocol_id)
1179 self.logger.log(
1180 VERBOSE_LOG_LEVEL,
1181 "Using parent's active protocol %s for %s",
1182 parent_protocol_domain,
1183 child_player.state.name,
1184 )
1185 continue
1186 else:
1187 self.logger.log(
1188 VERBOSE_LOG_LEVEL,
1189 "Parent's active protocol %s does not support SET_MEMBERS, "
1190 "will search for alternative",
1191 parent_protocol_domain,
1192 )
1193 # Clear the parent protocol so Priority 4 can select a new one
1194 parent_protocol_player = None
1195 parent_protocol_domain = None
1196
1197 # Priority 4: Search for common protocol that supports set_members
1198 parent_protocol, child_protocol = self._try_find_common_protocol(
1199 child_player, parent_player
1200 )
1201 if parent_protocol and child_protocol:
1202 if (
1203 not parent_protocol_player
1204 or parent_protocol_domain != parent_protocol.protocol_domain
1205 ):
1206 parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
1207 if parent_protocol_player:
1208 parent_protocol_domain = parent_protocol_player.provider.domain
1209 protocol_members.append(child_protocol.output_protocol_id)
1210 self.logger.log(
1211 VERBOSE_LOG_LEVEL,
1212 "Selected common protocol %s for grouping %s with %s",
1213 parent_protocol.protocol_domain,
1214 child_player.state.name,
1215 parent_player.state.name,
1216 )
1217 continue
1218
1219 # Priority 5: No option worked - log warning
1220 self.logger.warning(
1221 "Cannot group %s with %s: no compatible grouping method found "
1222 "(tried: child preferred protocol, native grouping, "
1223 "parent active protocol, common protocols)",
1224 child_player.state.name,
1225 parent_player.state.name,
1226 )
1227
1228 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
1229
1230 async def _forward_protocol_set_members(
1231 self,
1232 parent_player: Player,
1233 parent_protocol_player: Player,
1234 protocol_members_to_add: list[str],
1235 protocol_members_to_remove: list[str],
1236 ) -> None:
1237 """
1238 Forward protocol members to protocol player's set_members and manage active output protocol.
1239
1240 :param parent_player: The parent player (native/universal).
1241 :param parent_protocol_player: The protocol player to forward commands to.
1242 :param protocol_members_to_add: Protocol player IDs to add.
1243 :param protocol_members_to_remove: Protocol player IDs to remove.
1244 """
1245 filtered_protocol_add = self._filter_protocol_members(
1246 protocol_members_to_add, parent_protocol_player
1247 )
1248 filtered_protocol_remove = self._filter_protocol_members(
1249 protocol_members_to_remove, parent_protocol_player
1250 )
1251 self.logger.debug(
1252 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
1253 parent_protocol_player.state.name,
1254 filtered_protocol_add,
1255 filtered_protocol_remove,
1256 )
1257
1258 if not filtered_protocol_add and not filtered_protocol_remove:
1259 return
1260
1261 # Safety check: verify protocol player supports SET_MEMBERS
1262 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
1263 self.logger.error(
1264 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
1265 "This should have been caught earlier in the flow.",
1266 parent_protocol_player.state.name,
1267 )
1268 return
1269
1270 self.logger.debug(
1271 "Calling set_members on protocol player %s with add=%s, remove=%s",
1272 parent_protocol_player.state.name,
1273 filtered_protocol_add,
1274 filtered_protocol_remove,
1275 )
1276 await parent_protocol_player.set_members(
1277 player_ids_to_add=filtered_protocol_add or None,
1278 player_ids_to_remove=filtered_protocol_remove or None,
1279 )
1280
1281 # If we added members via this protocol, set it as the active output protocol
1282 # and restart playback if currently playing
1283 if (
1284 filtered_protocol_add
1285 and parent_player.active_output_protocol != parent_protocol_player.player_id
1286 ):
1287 previous_protocol = parent_player.active_output_protocol
1288 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
1289
1290 self.logger.debug(
1291 "Setting active output protocol to %s after grouping members "
1292 "(previous: %s, was_playing: %s)",
1293 parent_protocol_player.player_id,
1294 previous_protocol,
1295 was_playing,
1296 )
1297 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
1298
1299 # Restart playback on the new protocol if we were playing
1300 if was_playing:
1301 self.logger.info(
1302 "Restarting playback on %s via %s protocol after grouping members",
1303 parent_player.state.name,
1304 parent_protocol_player.provider.domain,
1305 )
1306 # Use resume to restart from current position
1307 await self.mass.players.cmd_resume(parent_player.player_id)
1308
1309 self.logger.debug(
1310 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
1311 parent_protocol_player.state.name,
1312 parent_protocol_player.group_members,
1313 parent_protocol_player.synced_to,
1314 )
1315
1316 # Clear active protocol if all protocol members were removed
1317 if (
1318 filtered_protocol_remove
1319 and not filtered_protocol_add
1320 and parent_protocol_player.player_id == parent_player.active_output_protocol
1321 ):
1322 # Check group_members count to see if we should clear
1323 members_count = len(parent_protocol_player.group_members)
1324 self.logger.debug(
1325 "Checking if should clear active protocol on %s: "
1326 "protocol_members_count=%s, removing=%s",
1327 parent_player.state.name,
1328 members_count,
1329 filtered_protocol_remove,
1330 )
1331 if members_count <= 1 and parent_player.state.playback_state == PlaybackState.IDLE:
1332 parent_player.set_active_output_protocol(None)
1333
1334 # Clear active output protocol on removed child players
1335 if filtered_protocol_remove:
1336 for child_protocol_id in filtered_protocol_remove:
1337 if child_protocol := self.get_player(child_protocol_id):
1338 if child_protocol.protocol_parent_id:
1339 if child_player := self.get_player(child_protocol.protocol_parent_id):
1340 if child_player.active_output_protocol == child_protocol_id:
1341 child_player.set_active_output_protocol(None)
1342