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