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