music-assistant-server

59.1 KBPY
protocol_linking.py
59.1 KB1,342 lines • python
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