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