music-assistant-server

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