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