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