/
/
/
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 if not self._identifiers_match(native_player, player, ""):
505 continue
506
507 # Transfer all protocol links from universal player to native player
508 for linked in list(player.linked_output_protocols):
509 if protocol_player := self.get_player(linked.output_protocol_id):
510 protocol_player.set_protocol_parent_id(None)
511 domain = linked.protocol_domain or protocol_player.provider.domain
512 self._add_protocol_link(native_player, protocol_player, domain)
513 protocol_player.update_state()
514
515 player.set_linked_output_protocols([])
516 native_player.update_state()
517
518 # Remove the now-obsolete universal player
519 self.mass.create_task(self.unregister(player.player_id, permanent=True))
520
521 def _add_protocol_link(
522 self, native_player: Player, protocol_player: Player, protocol_domain: str
523 ) -> None:
524 """Add a protocol link from native player to protocol player."""
525 # Remove any existing link for the same protocol domain
526 updated_protocols = [
527 link
528 for link in native_player.linked_output_protocols
529 if link.protocol_domain != protocol_domain
530 ]
531
532 # Get priority for this protocol
533 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
534
535 # Add the new link
536 updated_protocols.append(
537 OutputProtocol(
538 output_protocol_id=protocol_player.player_id,
539 name=protocol_player.provider.name,
540 protocol_domain=protocol_domain,
541 priority=priority,
542 )
543 )
544 native_player.set_linked_output_protocols(updated_protocols)
545
546 # Set protocol player's parent
547 protocol_player.set_protocol_parent_id(native_player.player_id)
548
549 # Persist linked protocol IDs to config for fast restart
550 # (only for non-universal players, as universal players handle this themselves)
551 if native_player.provider.domain != "universal_player":
552 self._save_linked_protocol_ids(native_player)
553 # Also save the parent ID on the protocol player for reverse lookup on restart
554 self._save_protocol_parent_id(protocol_player.player_id, native_player.player_id)
555
556 def _remove_protocol_link(
557 self, native_player: Player, protocol_player_id: str, permanent: bool = False
558 ) -> None:
559 """
560 Remove a protocol link.
561
562 :param native_player: The parent player to remove the link from.
563 :param protocol_player_id: The protocol player ID to unlink.
564 :param permanent: If True, also removes the protocol ID from the cached list.
565 Use this when the protocol player config is being deleted. If False,
566 the protocol ID remains in the cache so it can be shown as disabled
567 and re-enabled later.
568 """
569 updated_protocols = [
570 link
571 for link in native_player.linked_output_protocols
572 if link.output_protocol_id != protocol_player_id
573 ]
574 native_player.set_linked_output_protocols(updated_protocols)
575
576 # Clear parent reference on protocol player if it still exists
577 if protocol_player := self.get_player(protocol_player_id):
578 if protocol_player.protocol_parent_id == native_player.player_id:
579 protocol_player.set_protocol_parent_id(None)
580
581 # Update persisted linked protocol IDs and clear cached parent
582 if native_player.provider.domain != "universal_player":
583 if permanent:
584 # Permanently remove from cache (player config is being deleted)
585 self._remove_protocol_id_from_cache(native_player.player_id, protocol_player_id)
586 # Note: we don't call _save_linked_protocol_ids here anymore for non-permanent
587 # removals because the merge approach will preserve the ID in the cache
588 self._clear_protocol_parent_id(protocol_player_id)
589
590 def _save_linked_protocol_ids(self, native_player: Player) -> None:
591 """
592 Save linked protocol IDs to config for persistence across restarts.
593
594 This method merges active protocol IDs with existing cached IDs to preserve
595 disabled protocol players in the cache. This allows disabled protocols to be
596 shown in the UI so they can be re-enabled.
597 """
598 conf_key = (
599 f"{CONF_PLAYERS}/{native_player.player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
600 )
601 # Get existing cached IDs to preserve disabled protocols
602 existing_ids: list[str] = self.mass.config.get(conf_key, [])
603 # Get currently active protocol IDs
604 active_ids = {link.output_protocol_id for link in native_player.linked_output_protocols}
605 # Merge: keep existing IDs and add any new active ones
606 merged_ids = list(existing_ids)
607 for protocol_id in active_ids:
608 if protocol_id not in merged_ids:
609 merged_ids.append(protocol_id)
610 self.mass.config.set(conf_key, merged_ids)
611
612 def _get_cached_protocol_ids(self, player_id: str) -> list[str]:
613 """Get cached linked protocol IDs from config."""
614 conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
615 result = self.mass.config.get(conf_key, [])
616 return list(result) if result else []
617
618 def _remove_protocol_id_from_cache(
619 self, parent_player_id: str, protocol_player_id: str
620 ) -> None:
621 """
622 Permanently remove a protocol player ID from the cached linked protocol IDs.
623
624 Use this when a protocol player config is being deleted, not just disabled.
625 """
626 conf_key = f"{CONF_PLAYERS}/{parent_player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
627 cached_ids: list[str] = self.mass.config.get(conf_key, [])
628 if protocol_player_id in cached_ids:
629 cached_ids.remove(protocol_player_id)
630 self.mass.config.set(conf_key, cached_ids)
631
632 def _save_protocol_parent_id(self, protocol_player_id: str, parent_id: str) -> None:
633 """Save the parent ID for a protocol player for persistence across restarts."""
634 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
635 self.mass.config.set(conf_key, parent_id)
636
637 def _get_cached_protocol_parent_id(self, protocol_player_id: str) -> str | None:
638 """Get cached parent ID for a protocol player from config."""
639 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
640 result = self.mass.config.get(conf_key, None)
641 return str(result) if result else None
642
643 def _clear_protocol_parent_id(self, protocol_player_id: str) -> None:
644 """Clear the cached parent ID for a protocol player."""
645 conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
646 self.mass.config.set(conf_key, None)
647
648 def _recover_cached_protocol_links(self, native_player: Player) -> None:
649 """
650 Recover protocol links from config for disabled/missing protocols.
651
652 This ensures that disabled protocols show up in the output_protocols list
653 so they can be re-enabled by the user. It also handles the case where
654 protocol players haven't registered yet during startup.
655 """
656 # Get currently linked protocol IDs
657 linked_protocol_ids = {
658 link.output_protocol_id for link in native_player.linked_output_protocols
659 }
660
661 # Get cached protocol IDs from config (includes protocols that were explicitly linked)
662 cached_protocol_ids = self._get_cached_protocol_ids(native_player.player_id)
663
664 # Also check all protocol players that have protocol_parent_id pointing to this player
665 # (this handles disabled protocols that may not be in linked_protocol_player_ids)
666 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
667 for protocol_id, protocol_config in all_player_configs.items():
668 # Skip if not a protocol player
669 if protocol_config.get("player_type") != "protocol":
670 continue
671 # Check if this protocol has a parent_id pointing to this native player
672 protocol_values = protocol_config.get("values", {})
673 protocol_parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
674 if protocol_parent_id == native_player.player_id:
675 if protocol_id not in cached_protocol_ids:
676 cached_protocol_ids.append(protocol_id)
677
678 if not cached_protocol_ids:
679 return
680
681 # Add OutputProtocol entries for any cached protocols that aren't currently linked
682 for protocol_id in cached_protocol_ids:
683 if protocol_id in linked_protocol_ids:
684 continue # Already linked
685
686 # Get protocol player config to determine the protocol domain and availability
687 protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
688 if not protocol_config:
689 continue
690
691 # Determine protocol domain from provider
692 protocol_provider = protocol_config.get("provider")
693 if not protocol_provider:
694 continue
695
696 # Extract domain from provider instance_id (e.g., "airplay--uuid" -> "airplay")
697 protocol_domain = protocol_provider.split("--")[0]
698
699 # Get provider name for display
700 provider_name = "Protocol" # Default fallback
701 for provider in self.mass.get_providers(ProviderType.PLAYER):
702 if provider.domain == protocol_domain:
703 provider_name = provider.name
704 break
705
706 # Get priority for this protocol
707 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
708
709 # Check if protocol player is available (registered)
710 protocol_player = self.get_player(protocol_id)
711 is_available = protocol_player is not None and protocol_player.available
712
713 # Add the OutputProtocol entry
714 native_player.linked_output_protocols.append(
715 OutputProtocol(
716 output_protocol_id=protocol_id,
717 name=provider_name,
718 protocol_domain=protocol_domain,
719 priority=priority,
720 is_native=False,
721 available=is_available,
722 )
723 )
724 self.logger.debug(
725 "Recovered cached protocol link %s -> %s (available: %s)",
726 native_player.player_id,
727 protocol_id,
728 is_available,
729 )
730
731 def _cleanup_protocol_links(self, player: Player) -> None:
732 """Clean up protocol links when a player is permanently removed."""
733 if player.state.type == PlayerType.PROTOCOL:
734 # Protocol player being removed: remove link from parent
735 if parent_id := player.protocol_parent_id:
736 if parent_player := self.get_player(parent_id):
737 # Use permanent=True to also remove from cached protocol IDs
738 self._remove_protocol_link(parent_player, player.player_id, permanent=True)
739 if (
740 parent_player.provider.domain == "universal_player"
741 and len(parent_player.linked_output_protocols) == 0
742 ):
743 # No protocols left - remove universal player
744 self.logger.info(
745 "Universal player %s has no protocols left, removing",
746 parent_id,
747 )
748 self.mass.create_task(
749 self.mass.players.unregister(parent_id, permanent=True)
750 )
751 else:
752 parent_player.update_state()
753 else:
754 # Native player being removed: schedule protocol evaluation for linked protocols
755 # so they can be assigned to a universal player
756 for linked in player.linked_output_protocols:
757 if protocol_player := self.get_player(linked.output_protocol_id):
758 protocol_player.set_protocol_parent_id(None)
759 protocol_player.update_state()
760 self.logger.debug(
761 "Native player %s removed - scheduling evaluation for %s",
762 player.player_id,
763 protocol_player.player_id,
764 )
765 self._schedule_protocol_evaluation(protocol_player)
766
767 def _identifiers_match(
768 self, player_a: Player, player_b: Player, protocol_domain: str = ""
769 ) -> bool:
770 """
771 Check if identifiers match between two players.
772
773 Matching is done by comparing connection identifiers (MAC, serial, UUID).
774 IP address is used as a fallback for protocol players only, because some
775 devices report different virtual MAC addresses per protocol (e.g., DLNA vs
776 AirPlay vs Chromecast may all have different MACs for the same device).
777
778 Invalid identifiers (e.g., 00:00:00:00:00:00 MAC addresses) are filtered out
779 to prevent false matches between unrelated devices.
780 """
781 identifiers_a = player_a.device_info.identifiers
782 identifiers_b = player_b.device_info.identifiers
783
784 # Check identifiers in order of reliability
785 # MAC_ADDRESS > SERIAL_NUMBER > UUID
786 for conn_type in (
787 IdentifierType.MAC_ADDRESS,
788 IdentifierType.SERIAL_NUMBER,
789 IdentifierType.UUID,
790 ):
791 val_a = identifiers_a.get(conn_type)
792 val_b = identifiers_b.get(conn_type)
793
794 if not val_a or not val_b:
795 continue
796
797 # Filter out invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
798 if conn_type == IdentifierType.MAC_ADDRESS:
799 if not is_valid_mac_address(val_a) or not is_valid_mac_address(val_b):
800 self.logger.log(
801 VERBOSE_LOG_LEVEL,
802 "Skipping invalid MAC address for matching: %s=%s, %s=%s",
803 player_a.display_name,
804 val_a,
805 player_b.display_name,
806 val_b,
807 )
808 continue
809
810 # Normalize values for comparison
811 if conn_type == IdentifierType.MAC_ADDRESS:
812 # Use MAC normalization that handles locally-administered bit differences
813 # Some protocols (like AirPlay) report a locally-administered MAC variant
814 # where bit 1 of the first octet is set (e.g., 54:78:... vs 56:78:...)
815 val_a_norm = normalize_mac_for_matching(val_a)
816 val_b_norm = normalize_mac_for_matching(val_b)
817 else:
818 val_a_norm = val_a.lower().replace(":", "").replace("-", "")
819 val_b_norm = val_b.lower().replace(":", "").replace("-", "")
820
821 # Direct match
822 if val_a_norm == val_b_norm:
823 return True
824
825 # Special case: Sonos UUID matching with DLNA _MR suffix
826 # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
827 if conn_type == IdentifierType.UUID:
828 if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
829 return True
830 if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
831 return True
832
833 # Fallback: IP address matching for protocol players only
834 # Some devices report different virtual MAC addresses per protocol,
835 # but the IP address remains the same. Only use this for protocol-to-protocol
836 # or protocol-to-universal matching to avoid false positives.
837 if self._can_use_ip_matching(player_a, player_b):
838 ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
839 ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
840
841 # Normalize IP addresses (handle IPv6-mapped IPv4 like ::ffff:192.168.1.64)
842 ip_a_normalized = normalize_ip_address(ip_a)
843 ip_b_normalized = normalize_ip_address(ip_b)
844
845 if ip_a_normalized and ip_b_normalized and ip_a_normalized == ip_b_normalized:
846 return True
847
848 return False
849
850 def _can_use_ip_matching(self, player_a: Player, player_b: Player) -> bool:
851 """
852 Check if IP address matching can be used between two players.
853
854 IP matching is only allowed when at least one player is a protocol player
855 or universal player, to avoid false positives between unrelated devices.
856 """
857 # Check if at least one is a protocol player or universal player
858 a_is_protocol = (
859 player_a.type == PlayerType.PROTOCOL or player_a.provider.domain == "universal_player"
860 )
861 b_is_protocol = (
862 player_b.type == PlayerType.PROTOCOL or player_b.provider.domain == "universal_player"
863 )
864 return a_is_protocol or b_is_protocol
865
866 def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
867 """
868 Select the best available output protocol for a player.
869
870 Selection priority:
871 1. Output protocol that is currently grouped/synced with other players.
872 2. User's preferred output protocol (from player settings).
873 3. Native playback (if player supports PLAY_MEDIA).
874 4. Best available protocol by priority.
875
876 Returns tuple of (target_player, output_protocol).
877 output_protocol is None when using native playback.
878 """
879 self.logger.log(
880 VERBOSE_LOG_LEVEL,
881 "Selecting output protocol for %s",
882 player.state.name,
883 )
884
885 # 1. Check if any output protocol is currently grouped
886 for linked in player.linked_output_protocols:
887 if protocol_player := self.get_player(linked.output_protocol_id):
888 if protocol_player.available and self._is_protocol_grouped(protocol_player):
889 self.logger.log(
890 VERBOSE_LOG_LEVEL,
891 "Selected protocol for %s: %s (grouped)",
892 player.state.name,
893 protocol_player.state.name,
894 )
895 return protocol_player, linked
896
897 # 2. Check for user's preferred output protocol
898 preferred = self.mass.config.get_raw_player_config_value(
899 player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
900 )
901 if preferred and preferred != "auto":
902 if preferred == "native":
903 if PlayerFeature.PLAY_MEDIA in player.supported_features:
904 self.logger.log(
905 VERBOSE_LOG_LEVEL,
906 "Selected protocol for %s: native (user preference)",
907 player.state.name,
908 )
909 return player, None
910 else:
911 for linked in player.linked_output_protocols:
912 if linked.output_protocol_id == preferred:
913 if protocol_player := self.get_player(linked.output_protocol_id):
914 if protocol_player.available:
915 self.logger.log(
916 VERBOSE_LOG_LEVEL,
917 "Selected protocol for %s: %s (user preference)",
918 player.state.name,
919 protocol_player.state.name,
920 )
921 return protocol_player, linked
922 break
923
924 # 3. Use native playback if available
925 if PlayerFeature.PLAY_MEDIA in player.supported_features:
926 self.logger.log(
927 VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
928 )
929 return player, None
930
931 # 4. Fall back to best protocol by priority
932 for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
933 if protocol_player := self.get_player(linked.output_protocol_id):
934 if protocol_player.available:
935 self.logger.log(
936 VERBOSE_LOG_LEVEL,
937 "Selected protocol for %s: %s (priority-based)",
938 player.state.name,
939 protocol_player.state.name,
940 )
941 return protocol_player, linked
942
943 raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
944
945 def _get_control_target(
946 self,
947 player: Player,
948 required_feature: PlayerFeature,
949 require_active: bool = False,
950 allow_native: bool = True,
951 ) -> Player | None:
952 """
953 Get the best player(protocol) to send control commands to.
954
955 Prefers the active output protocol, otherwise uses the first available
956 protocol player that supports the needed feature.
957 """
958 # If we have an active protocol, use that
959 if (
960 player.active_output_protocol
961 and player.active_output_protocol != "native"
962 and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
963 and required_feature in protocol_player.supported_features
964 ):
965 return protocol_player
966
967 # if the player natively supports the required feature, use that
968 if allow_native and required_feature in player.supported_features:
969 return player
970
971 # If require_active is set, and no active protocol found, return None
972 if require_active:
973 return None
974
975 # Otherwise, use the first available linked protocol
976 for linked in player.linked_output_protocols:
977 if (
978 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
979 and protocol_player.available
980 and required_feature in protocol_player.supported_features
981 ):
982 return protocol_player
983
984 return None
985
986 def _is_protocol_grouped(self, protocol_player: Player) -> bool:
987 """
988 Check if a protocol player is currently grouped/synced with other players.
989
990 Used to prefer protocols that are actively participating in a group,
991 ensuring consistent playback across grouped players.
992 """
993 is_grouped = bool(
994 protocol_player.state.synced_to
995 or (
996 protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
997 )
998 or protocol_player.state.active_group
999 )
1000 if is_grouped:
1001 self.logger.log(
1002 VERBOSE_LOG_LEVEL,
1003 "Protocol player %s is grouped",
1004 protocol_player.state.name,
1005 )
1006 return is_grouped
1007
1008 def _translate_members_to_remove_for_protocols(
1009 self,
1010 parent_player: Player,
1011 player_ids: list[str],
1012 parent_protocol_player: Player | None,
1013 parent_protocol_domain: str | None,
1014 ) -> tuple[list[str], list[str]]:
1015 """
1016 Translate member IDs to remove into protocol and native lists.
1017
1018 :param parent_player: The parent player to remove members from.
1019 :param player_ids: List of visible player IDs to remove.
1020 :param parent_protocol_player: The parent's protocol player if available.
1021 :param parent_protocol_domain: The parent's protocol domain if available.
1022 """
1023 self.logger.debug(
1024 "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
1025 parent_player.state.name,
1026 player_ids,
1027 parent_protocol_domain,
1028 )
1029 protocol_members: list[str] = []
1030 native_members: list[str] = []
1031
1032 for child_player_id in player_ids:
1033 child_player = self.get_player(child_player_id)
1034 if not child_player:
1035 continue
1036
1037 # Check if this member is in the parent's group via protocol
1038 if parent_protocol_domain and parent_protocol_player:
1039 child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
1040 if (
1041 child_protocol
1042 and child_protocol.output_protocol_id in parent_protocol_player.group_members
1043 ):
1044 self.logger.debug(
1045 "Translating removal: %s -> protocol %s",
1046 child_player_id,
1047 child_protocol.output_protocol_id,
1048 )
1049 protocol_members.append(child_protocol.output_protocol_id)
1050 continue
1051
1052 native_members.append(child_player_id)
1053
1054 return protocol_members, native_members
1055
1056 def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
1057 """Filter member IDs to only include protocol players from the same domain."""
1058 return [
1059 pid
1060 for pid in member_ids
1061 if (p := self.get_player(pid))
1062 and p.type == PlayerType.PROTOCOL
1063 and p.provider.domain == protocol_player.provider.domain
1064 ]
1065
1066 def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
1067 """Filter member IDs to only include players compatible with the parent."""
1068 return [
1069 pid
1070 for pid in member_ids
1071 if (p := self.get_player(pid))
1072 and (
1073 p.provider.instance_id == parent_player.provider.instance_id
1074 or pid in parent_player._attr_can_group_with
1075 or p.provider.instance_id in parent_player._attr_can_group_with
1076 )
1077 ]
1078
1079 def _try_child_preferred_protocol(
1080 self,
1081 child_player: Player,
1082 parent_player: Player,
1083 ) -> tuple[str | None, str | None]:
1084 """
1085 Try to use child's preferred output protocol for grouping.
1086
1087 Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
1088 """
1089 child_preferred = self.mass.config.get_raw_player_config_value(
1090 child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
1091 )
1092 if not child_preferred or child_preferred in {"auto", "native"}:
1093 return None, None
1094
1095 # Find child's preferred protocol in linked protocols
1096 child_protocol = None
1097 for linked in child_player.linked_output_protocols:
1098 if linked.output_protocol_id == child_preferred:
1099 child_protocol = linked
1100 break
1101
1102 if not child_protocol or not child_protocol.available:
1103 return None, None
1104
1105 # Check if parent supports this protocol
1106 parent_protocol = parent_player.get_linked_protocol(child_protocol.protocol_domain)
1107 if not parent_protocol or not parent_protocol.available:
1108 return None, None
1109
1110 # Check if this protocol supports set_members
1111 protocol_player = self.get_player(parent_protocol.output_protocol_id)
1112 if (
1113 not protocol_player
1114 or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
1115 ):
1116 return None, None
1117
1118 return child_protocol.output_protocol_id, child_protocol.protocol_domain
1119
1120 def _can_use_native_grouping(
1121 self,
1122 child_player: Player,
1123 parent_player: Player,
1124 parent_supports_native: bool,
1125 ) -> bool:
1126 """Check if child can be grouped with parent using native grouping."""
1127 if not parent_supports_native:
1128 return False
1129 return (
1130 child_player.provider.instance_id == parent_player.provider.instance_id
1131 or child_player.player_id in parent_player._attr_can_group_with
1132 or child_player.provider.instance_id in parent_player._attr_can_group_with
1133 )
1134
1135 def _try_find_common_protocol(
1136 self, child_player: Player, parent_player: Player
1137 ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
1138 """
1139 Find common protocol that supports set_members.
1140
1141 Returns tuple of (parent_protocol, child_protocol) or (None, None).
1142 """
1143 for parent_output_protocol in parent_player.output_protocols:
1144 if not parent_output_protocol.available:
1145 continue
1146 child_protocol = child_player.get_linked_protocol(
1147 parent_output_protocol.protocol_domain
1148 )
1149 if not child_protocol or not child_protocol.available:
1150 continue
1151 protocol_player = self.get_player(parent_output_protocol.output_protocol_id)
1152 if (
1153 protocol_player
1154 and PlayerFeature.SET_MEMBERS in protocol_player.state.supported_features
1155 ):
1156 return parent_output_protocol, child_protocol
1157 return None, None
1158
1159 def _translate_members_for_protocols(
1160 self,
1161 parent_player: Player,
1162 player_ids: list[str],
1163 parent_protocol_player: Player | None,
1164 parent_protocol_domain: str | None,
1165 ) -> tuple[list[str], list[str], Player | None, str | None]:
1166 """
1167 Translate member IDs to protocol or native IDs.
1168
1169 Selection priority when grouping:
1170 1. Try child's preferred output protocol (from player settings)
1171 2. Try native grouping (if parent and child are compatible)
1172 3. Try parent's active output protocol (if any and child supports it)
1173 4. Search for common protocol that supports set_members
1174 5. Log warning if no option works
1175
1176 Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
1177 """
1178 protocol_members: list[str] = []
1179 native_members: list[str] = []
1180 parent_supports_native_grouping = (
1181 PlayerFeature.SET_MEMBERS in parent_player.supported_features
1182 )
1183
1184 self.logger.log(
1185 VERBOSE_LOG_LEVEL,
1186 "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
1187 parent_player.state.name,
1188 parent_supports_native_grouping,
1189 parent_protocol_player.state.name if parent_protocol_player else "none",
1190 parent_protocol_domain or "none",
1191 )
1192
1193 for child_player_id in player_ids:
1194 child_player = self.get_player(child_player_id)
1195 if not child_player:
1196 continue
1197
1198 self.logger.log(
1199 VERBOSE_LOG_LEVEL,
1200 "Processing child %s (type=%s, protocols=%s)",
1201 child_player.state.name,
1202 child_player.state.type,
1203 [p.protocol_domain for p in child_player.output_protocols],
1204 )
1205
1206 # Priority 1: Try child's preferred output protocol
1207 # (only if no active protocol or if it matches the active protocol)
1208 child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
1209 child_player, parent_player
1210 )
1211 if (
1212 child_protocol_id
1213 and protocol_domain
1214 and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
1215 ):
1216 if not parent_protocol_player or parent_protocol_domain != protocol_domain:
1217 parent_protocol = parent_player.get_linked_protocol(protocol_domain)
1218 if parent_protocol:
1219 parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
1220 parent_protocol_domain = protocol_domain
1221 protocol_members.append(child_protocol_id)
1222 self.logger.log(
1223 VERBOSE_LOG_LEVEL,
1224 "Using child's preferred protocol %s for %s",
1225 protocol_domain,
1226 child_player.state.name,
1227 )
1228 continue
1229
1230 # Priority 2: Try native grouping
1231 if self._can_use_native_grouping(
1232 child_player, parent_player, parent_supports_native_grouping
1233 ):
1234 native_members.append(child_player_id)
1235 self.logger.log(
1236 VERBOSE_LOG_LEVEL,
1237 "Using native grouping for %s",
1238 child_player.state.name,
1239 )
1240 continue
1241
1242 # Priority 3: Try parent's active output protocol (if it supports SET_MEMBERS)
1243 if parent_protocol_domain and parent_protocol_player:
1244 # Verify the active protocol supports SET_MEMBERS
1245 if PlayerFeature.SET_MEMBERS in parent_protocol_player.state.supported_features:
1246 child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
1247 if child_protocol and child_protocol.available:
1248 protocol_members.append(child_protocol.output_protocol_id)
1249 self.logger.log(
1250 VERBOSE_LOG_LEVEL,
1251 "Using parent's active protocol %s for %s",
1252 parent_protocol_domain,
1253 child_player.state.name,
1254 )
1255 continue
1256 else:
1257 self.logger.log(
1258 VERBOSE_LOG_LEVEL,
1259 "Parent's active protocol %s does not support SET_MEMBERS, "
1260 "will search for alternative",
1261 parent_protocol_domain,
1262 )
1263 # Clear the parent protocol so Priority 4 can select a new one
1264 parent_protocol_player = None
1265 parent_protocol_domain = None
1266
1267 # Priority 4: Search for common protocol that supports set_members
1268 parent_protocol, child_protocol = self._try_find_common_protocol(
1269 child_player, parent_player
1270 )
1271 if parent_protocol and child_protocol:
1272 if (
1273 not parent_protocol_player
1274 or parent_protocol_domain != parent_protocol.protocol_domain
1275 ):
1276 parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
1277 if parent_protocol_player:
1278 parent_protocol_domain = parent_protocol_player.provider.domain
1279 protocol_members.append(child_protocol.output_protocol_id)
1280 self.logger.log(
1281 VERBOSE_LOG_LEVEL,
1282 "Selected common protocol %s for grouping %s with %s",
1283 parent_protocol.protocol_domain,
1284 child_player.state.name,
1285 parent_player.state.name,
1286 )
1287 continue
1288
1289 # Priority 5: No option worked - log warning
1290 self.logger.warning(
1291 "Cannot group %s with %s: no compatible grouping method found "
1292 "(tried: child preferred protocol, native grouping, "
1293 "parent active protocol, common protocols)",
1294 child_player.state.name,
1295 parent_player.state.name,
1296 )
1297
1298 return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
1299
1300 async def _forward_protocol_set_members(
1301 self,
1302 parent_player: Player,
1303 parent_protocol_player: Player,
1304 protocol_members_to_add: list[str],
1305 protocol_members_to_remove: list[str],
1306 ) -> None:
1307 """
1308 Forward protocol members to protocol player's set_members and manage active output protocol.
1309
1310 :param parent_player: The parent player (native/universal).
1311 :param parent_protocol_player: The protocol player to forward commands to.
1312 :param protocol_members_to_add: Protocol player IDs to add.
1313 :param protocol_members_to_remove: Protocol player IDs to remove.
1314 """
1315 filtered_protocol_add = self._filter_protocol_members(
1316 protocol_members_to_add, parent_protocol_player
1317 )
1318 filtered_protocol_remove = self._filter_protocol_members(
1319 protocol_members_to_remove, parent_protocol_player
1320 )
1321 self.logger.debug(
1322 "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
1323 parent_protocol_player.state.name,
1324 filtered_protocol_add,
1325 filtered_protocol_remove,
1326 )
1327
1328 if not filtered_protocol_add and not filtered_protocol_remove:
1329 return
1330
1331 # Safety check: verify protocol player supports SET_MEMBERS
1332 if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
1333 self.logger.error(
1334 "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
1335 "This should have been caught earlier in the flow.",
1336 parent_protocol_player.state.name,
1337 )
1338 return
1339
1340 self.logger.debug(
1341 "Calling set_members on protocol player %s with add=%s, remove=%s",
1342 parent_protocol_player.state.name,
1343 filtered_protocol_add,
1344 filtered_protocol_remove,
1345 )
1346 await parent_protocol_player.set_members(
1347 player_ids_to_add=filtered_protocol_add or None,
1348 player_ids_to_remove=filtered_protocol_remove or None,
1349 )
1350
1351 # If we added members via this protocol, set it as the active output protocol
1352 # and restart playback if currently playing
1353 if (
1354 filtered_protocol_add
1355 and parent_player.active_output_protocol != parent_protocol_player.player_id
1356 ):
1357 previous_protocol = parent_player.active_output_protocol
1358 was_playing = parent_player.state.playback_state == PlaybackState.PLAYING
1359
1360 self.logger.debug(
1361 "Setting active output protocol to %s after grouping members "
1362 "(previous: %s, was_playing: %s)",
1363 parent_protocol_player.player_id,
1364 previous_protocol,
1365 was_playing,
1366 )
1367 parent_player.set_active_output_protocol(parent_protocol_player.player_id)
1368
1369 # Restart playback on the new protocol if we were playing
1370 if was_playing:
1371 self.logger.info(
1372 "Restarting playback on %s via %s protocol after grouping members",
1373 parent_player.state.name,
1374 parent_protocol_player.provider.domain,
1375 )
1376 # Use resume to restart from current position
1377 await self.mass.players.cmd_resume(parent_player.player_id)
1378
1379 self.logger.debug(
1380 "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
1381 parent_protocol_player.state.name,
1382 parent_protocol_player.group_members,
1383 parent_protocol_player.synced_to,
1384 )
1385
1386 # Clear active protocol if all protocol members were removed
1387 if (
1388 filtered_protocol_remove
1389 and not filtered_protocol_add
1390 and parent_protocol_player.player_id == parent_player.active_output_protocol
1391 ):
1392 # Check group_members count to see if we should clear
1393 members_count = len(parent_protocol_player.group_members)
1394 self.logger.debug(
1395 "Checking if should clear active protocol on %s: "
1396 "protocol_members_count=%s, removing=%s",
1397 parent_player.state.name,
1398 members_count,
1399 filtered_protocol_remove,
1400 )
1401 if members_count <= 1 and parent_player.state.playback_state == PlaybackState.IDLE:
1402 parent_player.set_active_output_protocol(None)
1403
1404 # Clear active output protocol on removed child players
1405 if filtered_protocol_remove:
1406 for child_protocol_id in filtered_protocol_remove:
1407 if child_protocol := self.get_player(child_protocol_id):
1408 if child_protocol.protocol_parent_id:
1409 if child_player := self.get_player(child_protocol.protocol_parent_id):
1410 if child_player.active_output_protocol == child_protocol_id:
1411 child_player.set_active_output_protocol(None)
1412