/
/
1"""Tests for protocol player linking and universal player creation."""
2
3import asyncio
4import inspect
5import logging
6import re
7from collections.abc import Awaitable, Iterator
8from typing import Any
9from unittest.mock import AsyncMock, MagicMock, patch
10from uuid import UUID
11
12import pytest
13from music_assistant_models.config_entries import PlayerConfig
14from music_assistant_models.enums import (
15 EventType,
16 IdentifierType,
17 PlaybackState,
18 PlayerFeature,
19 PlayerType,
20)
21from music_assistant_models.player import PlayerMedia
22
23from music_assistant.constants import (
24 ATTR_ENABLED,
25 CONF_PLAYER_DSP,
26 CONF_PLAYER_QUEUES,
27 CONF_PLAYERS,
28 CONF_PREFERRED_OUTPUT_PROTOCOL,
29 CONF_PROTOCOL_PARENT_ID,
30 CONF_UNDERLYING_PLAYER_ID,
31)
32from music_assistant.controllers.config import ConfigController
33from music_assistant.controllers.config.migrations import migrate
34from music_assistant.controllers.players import PlayerController
35from music_assistant.helpers.util import enrich_device_mac_address
36from music_assistant.models.player import DeviceInfo, LinkedOutputProtocol, Player
37from music_assistant.models.player_provider import PlayerProvider
38from music_assistant.providers.universal_player.constants import CONF_CREATED_AT
39from music_assistant.providers.universal_player.player import UniversalPlayer
40from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
41
42
43def create_mock_config(name: str) -> MagicMock:
44 """Create a mock player config with the given name."""
45 config = MagicMock()
46 config.name = None # No custom name, use default
47 config.default_name = name
48 return config
49
50
51def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
52 """Create a mock UniversalPlayerProvider for testing."""
53 # Create a mock manifest
54 manifest = MagicMock()
55 manifest.domain = "universal_player"
56 manifest.name = "Universal Player"
57
58 # Create provider with the mock manifest
59 provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
60 provider.mass = mock_mass
61 provider.manifest = manifest
62 provider.logger = logging.getLogger("test.universal_player")
63 # Add config mock for instance_id property
64 config = MagicMock()
65 config.instance_id = "universal_player"
66 config.name = None
67 provider.config = config
68 # Initialize the lock that would normally be set in handle_async_init
69 provider._lock = asyncio.Lock()
70 return provider
71
72
73# Player id of the universal player a device was wrapped in earlier, as minted once
74# by UniversalPlayerProvider.mint_player_id.
75STORED_UNIVERSAL_ID = "up1a2b3c4d"
76
77
78def _wire_nested_config(mock_mass: MagicMock, store: dict[str, Any]) -> None:
79 """Wire mass.config get/set/remove to a nested, path-addressable store."""
80
81 def _get(key: str, default: object = None) -> object:
82 node: Any = store
83 for part in key.split("/"):
84 if not isinstance(node, dict) or part not in node:
85 return default
86 node = node[part]
87 return node
88
89 def _set(key: str, value: object) -> None:
90 parts = key.split("/")
91 node: dict[str, Any] = store
92 for part in parts[:-1]:
93 node = node.setdefault(part, {})
94 node[parts[-1]] = value
95
96 def _remove(key: str) -> None:
97 parts = key.split("/")
98 node: Any = store
99 for part in parts[:-1]:
100 node = node.get(part) if isinstance(node, dict) else None
101 if node is None:
102 return
103 if isinstance(node, dict):
104 node.pop(parts[-1], None)
105
106 mock_mass.config.get = MagicMock(side_effect=_get)
107 mock_mass.config.set = MagicMock(side_effect=_set)
108 mock_mass.config.remove = MagicMock(side_effect=_remove)
109
110
111def _fake_create_default_player_config(
112 store: dict[str, Any],
113 player_id: str,
114 provider: str,
115 player_type: PlayerType,
116 name: str | None = None,
117 enabled: bool = True,
118 values: dict[str, Any] | None = None,
119) -> None:
120 """
121 Mirror ConfigController.create_default_player_config against a plain dict store.
122
123 Only the parts the universal player flows depend on: a fresh config keeps the
124 creation-time name as the default name only, an existing config keeps its type.
125 """
126 players: dict[str, Any] = store.setdefault(CONF_PLAYERS, {})
127 if existing := players.get(player_id):
128 if name and name != existing.get("default_name"):
129 existing["default_name"] = name
130 return
131 players[player_id] = {
132 "player_id": player_id,
133 "provider": provider,
134 "player_type": player_type.value,
135 "enabled": enabled,
136 "name": None,
137 "default_name": name,
138 "values": dict(values or {}),
139 }
140
141
142class MockProvider:
143 """Mock player provider for testing."""
144
145 def __init__(
146 self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
147 ) -> None:
148 """Initialize the mock provider."""
149 self.domain = domain
150 self.instance_id = instance_id
151 self.name = f"Mock {domain.title()}"
152 self.manifest = MagicMock()
153 self.manifest.name = f"Mock {domain} Provider"
154 self.mass = mass or MagicMock()
155 self.logger = logging.getLogger(f"test.{domain}")
156 # the controller iterates these when a group related field changes
157 self.players: list[Player] = []
158
159
160class MockPlayer(Player):
161 """Mock player for testing."""
162
163 def __init__(
164 self,
165 provider: MockProvider,
166 player_id: str,
167 name: str,
168 player_type: PlayerType = PlayerType.PLAYER,
169 identifiers: dict[IdentifierType, str] | None = None,
170 ) -> None:
171 """Initialize the mock player."""
172 # Set up the mock config before calling super().__init__
173 # because the parent __init__ accesses config
174 provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
175
176 super().__init__(provider, player_id) # type: ignore[arg-type]
177 self._attr_name = name
178 # Set type as instance attribute (overrides class attribute)
179 self._attr_type = player_type
180 self._attr_available = True
181 self._attr_powered = True
182 self._attr_supported_features = {PlayerFeature.VOLUME_SET}
183 self._attr_can_group_with = set()
184
185 # Set up device info with identifiers
186 self._attr_device_info = DeviceInfo(
187 model="Test Model",
188 manufacturer="Test Manufacturer",
189 )
190 if identifiers:
191 for conn_type, value in identifiers.items():
192 self._attr_device_info.add_identifier(conn_type, value)
193
194 # Clear cached properties after modifying attributes
195 self._cache.clear()
196
197 # Update state to reflect the modified attributes
198 self.update_state(signal_event=False)
199
200 async def stop(self) -> None:
201 """Stop playback - required abstract method."""
202
203 async def set_members(
204 self,
205 player_ids_to_add: list[str] | None = None,
206 player_ids_to_remove: list[str] | None = None,
207 ) -> None:
208 """Mock implementation of set_members."""
209 current_members = set(getattr(self, "_attr_group_members", []))
210
211 if player_ids_to_add:
212 current_members.update(player_ids_to_add)
213
214 if player_ids_to_remove:
215 current_members.difference_update(player_ids_to_remove)
216
217 # Always include self as first member if there are members
218 if current_members:
219 self._attr_group_members = [self.player_id] + [
220 pid for pid in current_members if pid != self.player_id
221 ]
222 else:
223 self._attr_group_members = []
224
225 # Clear cache to reflect changes
226 self._cache.clear()
227
228
229class SessionBoundMockPlayer(MockPlayer):
230 """Mock player whose native grouping attaches members to its own stream (e.g. AirPlay)."""
231
232 @property
233 def native_grouping_requires_own_stream(self) -> bool:
234 """Return True: native members ride this player's own stream session."""
235 return True
236
237
238@pytest.fixture
239def mock_mass() -> Iterator[MagicMock]:
240 """Create a mock MusicAssistant instance."""
241 mass = MagicMock()
242 mass.closing = False
243 mass.config = MagicMock()
244 mass.config.get = MagicMock(return_value=[])
245
246 def _get_raw_player_config_value(
247 _player_id: str, key: str, default: str | int | None = None
248 ) -> str | int | None:
249 """Return appropriate defaults for player config values."""
250 if key == "min_volume":
251 return 0
252 if key == "max_volume":
253 return 100
254 return default
255
256 mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
257 # Return "GLOBAL" for log level config (standard default)
258 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
259 mass.config.set = MagicMock()
260 mass.signal_event = MagicMock()
261 mass.get_providers = MagicMock(return_value=[])
262 # awaited by the tests that replay the tasks scheduled during a config wipe
263 mass.webserver.auth.remove_from_user_filters = AsyncMock()
264 mass.webserver.auth.replace_player_in_user_filters = AsyncMock()
265 yield mass
266 for call in mass.create_task.call_args_list:
267 if call.args and inspect.iscoroutine(coro := call.args[0]):
268 coro.close()
269
270
271class TestIdentifiersMatch:
272 """Tests for identifier matching logic."""
273
274 def test_mac_address_match(self, mock_mass: MagicMock) -> None:
275 """Test that MAC addresses match correctly."""
276 controller = PlayerController(mock_mass)
277
278 provider = MockProvider("test")
279 player_a = MockPlayer(
280 provider,
281 "player_a",
282 "Player A",
283 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
284 )
285 player_b = MockPlayer(
286 provider,
287 "player_b",
288 "Player B",
289 identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, # lowercase
290 )
291
292 assert controller._identifiers_match(player_a, player_b) is True
293
294 def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
295 """Test that different MAC addresses don't match."""
296 controller = PlayerController(mock_mass)
297
298 provider = MockProvider("test")
299 player_a = MockPlayer(
300 provider,
301 "player_a",
302 "Player A",
303 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
304 )
305 player_b = MockPlayer(
306 provider,
307 "player_b",
308 "Player B",
309 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
310 )
311
312 assert controller._identifiers_match(player_a, player_b) is False
313
314 def test_invalid_mac_address_no_match(self, mock_mass: MagicMock) -> None:
315 """Test that an all-zero MAC is not identity and never matches across devices."""
316 controller = PlayerController(mock_mass)
317
318 provider = MockProvider("test")
319 player_a = MockPlayer(
320 provider,
321 "player_a",
322 "Player A",
323 identifiers={IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00"},
324 )
325 player_b = MockPlayer(
326 provider,
327 "player_b",
328 "Player B",
329 identifiers={IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00"},
330 )
331
332 assert controller._identifiers_match(player_a, player_b) is False
333
334 def test_mac_address_locally_administered_bit_match(self, mock_mass: MagicMock) -> None:
335 """
336 Test that MAC addresses differing only in locally-administered bit match.
337
338 Some protocols (like AirPlay) report a MAC with the locally-administered
339 bit set (bit 1 of first octet), while other protocols report the real
340 hardware MAC. These should match as the same device.
341
342 Example: 54:78:C9:E6:0D:A0 (hardware) vs 56:78:C9:E6:0D:A0 (AirPlay)
343 """
344 controller = PlayerController(mock_mass)
345
346 provider = MockProvider("test")
347 # Real hardware MAC (first byte 0x54 = 01010100, bit 1 = 0)
348 player_a = MockPlayer(
349 provider,
350 "player_a",
351 "WiiM Pro (DLNA)",
352 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
353 )
354 # AirPlay MAC with locally-administered bit set (first byte 0x56 = 01010110, bit 1 = 1)
355 player_b = MockPlayer(
356 provider,
357 "player_b",
358 "WiiM Pro (AirPlay)",
359 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0"},
360 )
361
362 # These should match because they differ only in the locally-administered bit
363 assert controller._identifiers_match(player_a, player_b) is True
364
365 def test_mac_address_locally_administered_bit_different_devices_no_match(
366 self, mock_mass: MagicMock
367 ) -> None:
368 """
369 Test that different devices with locally-administered MACs don't match.
370
371 Only the locally-administered bit should be ignored, not other differences.
372 """
373 controller = PlayerController(mock_mass)
374
375 provider = MockProvider("test")
376 player_a = MockPlayer(
377 provider,
378 "player_a",
379 "Device A",
380 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
381 )
382 player_b = MockPlayer(
383 provider,
384 "player_b",
385 "Device B",
386 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A1"}, # Different last byte
387 )
388
389 # These should NOT match - they differ in more than just the locally-administered bit
390 assert controller._identifiers_match(player_a, player_b) is False
391
392 def test_ip_address_fallback_with_no_mac(self, mock_mass: MagicMock) -> None:
393 """Test that same IP matches when no MAC is available (ARP couldn't help)."""
394 controller = PlayerController(mock_mass)
395
396 provider = MockProvider("test")
397 player_a = MockPlayer(
398 provider,
399 "player_a",
400 "Player A",
401 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
402 )
403 player_b = MockPlayer(
404 provider,
405 "player_b",
406 "Player B",
407 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
408 )
409
410 # Same IP with no MAC at all = match (likely same device, ARP unavailable)
411 assert controller._identifiers_match(player_a, player_b) is True
412
413 def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
414 """Test Sonos UUID matching with DLNA _MR suffix."""
415 controller = PlayerController(mock_mass)
416
417 provider = MockProvider("test")
418 # Sonos native player
419 player_a = MockPlayer(
420 provider,
421 "player_a",
422 "Sonos Player",
423 identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
424 )
425 # DLNA player with _MR suffix
426 player_b = MockPlayer(
427 provider,
428 "player_b",
429 "DLNA Player",
430 identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
431 )
432
433 assert controller._identifiers_match(player_a, player_b) is True
434
435 def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
436 """Test that players without identifiers don't match."""
437 controller = PlayerController(mock_mass)
438
439 provider = MockProvider("test")
440 player_a = MockPlayer(provider, "player_a", "Player A")
441 player_b = MockPlayer(provider, "player_b", "Player B")
442
443 assert controller._identifiers_match(player_a, player_b) is False
444
445 def test_cast_uuid_match(self, mock_mass: MagicMock) -> None:
446 """
447 Test that CAST_UUID identifiers match for cross-protocol linking.
448
449 When a Chromecast device has no valid MAC (non-Google devices like SEI Robotics),
450 the CAST_UUID identifier allows matching the Chromecast player with its
451 Sendspin bridge player.
452 """
453 controller = PlayerController(mock_mass)
454
455 provider = MockProvider("test")
456 # Chromecast player with UUID and CAST_UUID but no MAC
457 player_a = MockPlayer(
458 provider,
459 "player_a",
460 "Android TV (Cast)",
461 player_type=PlayerType.PROTOCOL,
462 identifiers={
463 IdentifierType.UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
464 IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
465 IdentifierType.IP_ADDRESS: "192.168.1.228",
466 },
467 )
468 # Sendspin bridge player with only MAC and CAST_UUID (no UUID, no IP)
469 player_b = MockPlayer(
470 provider,
471 "player_b",
472 "Android TV (Sendspin)",
473 player_type=PlayerType.PROTOCOL,
474 identifiers={
475 IdentifierType.MAC_ADDRESS: "D4:CF:F9:D9:DA:FD",
476 IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
477 },
478 )
479
480 # Should match via CAST_UUID even though they share no MAC, UUID, or IP
481 assert controller._identifiers_match(player_a, player_b) is True
482
483 def test_cast_uuid_no_match(self, mock_mass: MagicMock) -> None:
484 """Test that different CAST_UUIDs don't match."""
485 controller = PlayerController(mock_mass)
486
487 provider = MockProvider("test")
488 player_a = MockPlayer(
489 provider,
490 "player_a",
491 "Device A",
492 player_type=PlayerType.PROTOCOL,
493 identifiers={IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7"},
494 )
495 player_b = MockPlayer(
496 provider,
497 "player_b",
498 "Device B",
499 player_type=PlayerType.PROTOCOL,
500 identifiers={IdentifierType.CAST_UUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
501 )
502
503 assert controller._identifiers_match(player_a, player_b) is False
504
505 def test_airplay_id_match(self, mock_mass: MagicMock) -> None:
506 """
507 Test that AIRPLAY_ID identifiers match for cross-protocol linking.
508
509 When an AirPlay device's MAC has locally-administered bit differences,
510 AIRPLAY_ID provides a reliable secondary match path between the AirPlay
511 player and its Sendspin bridge player.
512 """
513 controller = PlayerController(mock_mass)
514
515 provider = MockProvider("test")
516 # AirPlay player with its player_id as AIRPLAY_ID
517 player_a = MockPlayer(
518 provider,
519 "player_a",
520 "Apple TV (AirPlay)",
521 player_type=PlayerType.PROTOCOL,
522 identifiers={
523 IdentifierType.MAC_ADDRESS: "54:78:1A:D4:FF:44",
524 IdentifierType.AIRPLAY_ID: "ap54781ad4ff44",
525 IdentifierType.IP_ADDRESS: "192.168.1.50",
526 },
527 )
528 # Sendspin bridge player with MAC and AIRPLAY_ID
529 player_b = MockPlayer(
530 provider,
531 "player_b",
532 "Apple TV (Sendspin)",
533 player_type=PlayerType.PROTOCOL,
534 identifiers={
535 IdentifierType.MAC_ADDRESS: "54:78:1A:D4:FF:44",
536 IdentifierType.AIRPLAY_ID: "ap54781ad4ff44",
537 },
538 )
539
540 # Should match via both MAC and AIRPLAY_ID
541 assert controller._identifiers_match(player_a, player_b) is True
542
543 def test_airplay_id_no_match(self, mock_mass: MagicMock) -> None:
544 """Test that different AIRPLAY_IDs don't match."""
545 controller = PlayerController(mock_mass)
546
547 provider = MockProvider("test")
548 player_a = MockPlayer(
549 provider,
550 "player_a",
551 "Device A",
552 player_type=PlayerType.PROTOCOL,
553 identifiers={IdentifierType.AIRPLAY_ID: "ap54781ad4ff44"},
554 )
555 player_b = MockPlayer(
556 provider,
557 "player_b",
558 "Device B",
559 player_type=PlayerType.PROTOCOL,
560 identifiers={IdentifierType.AIRPLAY_ID: "apaabbccddeeff"},
561 )
562
563 assert controller._identifiers_match(player_a, player_b) is False
564
565 def test_ip_fallback_with_la_mac(self, mock_mass: MagicMock) -> None:
566 """Test IP fallback matching when one player has a locally-administered MAC."""
567 controller = PlayerController(mock_mass)
568
569 provider = MockProvider("test")
570 player_a = MockPlayer(
571 provider,
572 "player_a",
573 "AirPlay Device",
574 player_type=PlayerType.PROTOCOL,
575 identifiers={
576 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
577 IdentifierType.IP_ADDRESS: "192.168.1.48",
578 },
579 )
580 player_b = MockPlayer(
581 provider,
582 "player_b",
583 "DLNA Device",
584 player_type=PlayerType.PROTOCOL,
585 identifiers={
586 # Locally-administered MAC from ARP (device uses MAC randomization)
587 IdentifierType.MAC_ADDRESS: "02:4E:52:A1:BC:6B",
588 IdentifierType.IP_ADDRESS: "192.168.1.48",
589 },
590 )
591
592 assert controller._identifiers_match(player_a, player_b) is True
593
594 def test_ip_fallback_with_missing_mac(self, mock_mass: MagicMock) -> None:
595 """Test IP fallback matching when one player has no MAC at all."""
596 controller = PlayerController(mock_mass)
597
598 provider = MockProvider("test")
599 player_a = MockPlayer(
600 provider,
601 "player_a",
602 "AirPlay Device",
603 player_type=PlayerType.PROTOCOL,
604 identifiers={
605 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
606 IdentifierType.IP_ADDRESS: "192.168.1.48",
607 },
608 )
609 player_b = MockPlayer(
610 provider,
611 "player_b",
612 "DLNA Device",
613 player_type=PlayerType.PROTOCOL,
614 identifiers={
615 IdentifierType.IP_ADDRESS: "192.168.1.48",
616 },
617 )
618
619 assert controller._identifiers_match(player_a, player_b) is True
620
621 def test_ip_fallback_protocol_players_with_real_macs(self, mock_mass: MagicMock) -> None:
622 """
623 Test that IP matches protocol players even when both have real MACs.
624
625 Some devices (e.g., Yamaha MusicCast) report different valid globally-unique
626 MACs per protocol (DLNA vs AirPlay differ by 1 in the last octet).
627 IP matching is safe here because two devices on a LAN can't share an IP.
628 """
629 controller = PlayerController(mock_mass)
630
631 provider = MockProvider("test")
632 player_a = MockPlayer(
633 provider,
634 "player_a",
635 "Device A",
636 player_type=PlayerType.PROTOCOL,
637 identifiers={
638 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
639 IdentifierType.IP_ADDRESS: "192.168.1.48",
640 },
641 )
642 player_b = MockPlayer(
643 provider,
644 "player_b",
645 "Device B",
646 player_type=PlayerType.PROTOCOL,
647 identifiers={
648 # A8 first octet = 10101000, LA bit (0x02) is clear -> real MAC
649 IdentifierType.MAC_ADDRESS: "A8:BB:CC:DD:EE:FF",
650 IdentifierType.IP_ADDRESS: "192.168.1.48",
651 },
652 )
653
654 # Protocol players with real MACs on same IP -> match (same physical device)
655 assert controller._identifiers_match(player_a, player_b) is True
656
657 def test_ip_no_fallback_native_players_with_real_macs(self, mock_mass: MagicMock) -> None:
658 """Test that IP alone does NOT match two native PLAYER-type players with real MACs."""
659 controller = PlayerController(mock_mass)
660
661 provider = MockProvider("test")
662 player_a = MockPlayer(
663 provider,
664 "player_a",
665 "Device A",
666 player_type=PlayerType.PLAYER,
667 identifiers={
668 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
669 IdentifierType.IP_ADDRESS: "192.168.1.48",
670 },
671 )
672 player_b = MockPlayer(
673 provider,
674 "player_b",
675 "Device B",
676 player_type=PlayerType.PLAYER,
677 identifiers={
678 IdentifierType.MAC_ADDRESS: "A8:BB:CC:DD:EE:FF",
679 IdentifierType.IP_ADDRESS: "192.168.1.48",
680 },
681 )
682
683 # Two native players with real MACs that don't match - IP should NOT be used
684 assert controller._identifiers_match(player_a, player_b) is False
685
686 def test_ip_no_fallback_different_ips(self, mock_mass: MagicMock) -> None:
687 """Test that different IPs don't match even with LA MAC."""
688 controller = PlayerController(mock_mass)
689
690 provider = MockProvider("test")
691 player_a = MockPlayer(
692 provider,
693 "player_a",
694 "Device A",
695 player_type=PlayerType.PROTOCOL,
696 identifiers={
697 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
698 IdentifierType.IP_ADDRESS: "192.168.1.48",
699 },
700 )
701 player_b = MockPlayer(
702 provider,
703 "player_b",
704 "Device B",
705 player_type=PlayerType.PROTOCOL,
706 identifiers={
707 IdentifierType.MAC_ADDRESS: "02:4E:52:A1:BC:6B",
708 IdentifierType.IP_ADDRESS: "192.168.1.99",
709 },
710 )
711
712 assert controller._identifiers_match(player_a, player_b) is False
713
714
715class TestProtocolPlayerDetection:
716 """Tests for protocol player type detection."""
717
718 def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
719 """Test that PlayerType.PROTOCOL is correctly detected."""
720 controller = PlayerController(mock_mass)
721
722 provider = MockProvider("airplay")
723 player = MockPlayer(
724 provider,
725 "ap_123456",
726 "Samsung TV (AirPlay)",
727 player_type=PlayerType.PROTOCOL,
728 )
729
730 assert controller._is_protocol_player(player) is True
731
732 def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
733 """Test that PlayerType.PLAYER is not detected as protocol."""
734 controller = PlayerController(mock_mass)
735
736 provider = MockProvider("airplay")
737 player = MockPlayer(
738 provider,
739 "ap_123456",
740 "HomePod",
741 player_type=PlayerType.PLAYER, # Apple device with native support
742 )
743
744 assert controller._is_protocol_player(player) is False
745
746
747class TestFindMatchingProtocolPlayers:
748 """Tests for finding matching protocol players."""
749
750 def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
751 """Test finding matching protocol players by MAC address."""
752 controller = PlayerController(mock_mass)
753
754 # Set up providers
755 airplay_provider = MockProvider("airplay")
756 chromecast_provider = MockProvider("chromecast")
757
758 # Create matching protocol players (same device, different protocols)
759 airplay_player = MockPlayer(
760 airplay_provider,
761 "ap_aabbccddee",
762 "Samsung TV (AirPlay)",
763 player_type=PlayerType.PROTOCOL,
764 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
765 )
766 chromecast_player = MockPlayer(
767 chromecast_provider,
768 "cc_aabbccddee",
769 "Samsung TV (Chromecast)",
770 player_type=PlayerType.PROTOCOL,
771 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
772 )
773
774 # Register players
775 controller._players = {
776 "ap_aabbccddee": airplay_player,
777 "cc_aabbccddee": chromecast_player,
778 }
779
780 # Mark players as initialized so they are returned by all_players()
781 airplay_player.set_initialized()
782 chromecast_player.set_initialized()
783
784 # Find matching players for AirPlay player
785 matches = controller._find_matching_protocol_players(airplay_player)
786
787 assert len(matches) == 2
788 assert airplay_player in matches
789 assert chromecast_player in matches
790
791 def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None:
792 """Test that multiple players of same protocol on same host are NOT matched together."""
793 controller = PlayerController(mock_mass)
794
795 # Set up provider
796 snapcast_provider = MockProvider("snapcast")
797
798 # Create multiple Snapcast players on same host (same MAC/IP)
799 # This simulates multiple Snapcast clients running on the same server
800 snapcast_player_1 = MockPlayer(
801 snapcast_provider,
802 "snapcast_client_1",
803 "Snapcast Client 1",
804 player_type=PlayerType.PROTOCOL,
805 identifiers={
806 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
807 IdentifierType.IP_ADDRESS: "192.168.1.100",
808 },
809 )
810 snapcast_player_2 = MockPlayer(
811 snapcast_provider,
812 "snapcast_client_2",
813 "Snapcast Client 2",
814 player_type=PlayerType.PROTOCOL,
815 identifiers={
816 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
817 IdentifierType.IP_ADDRESS: "192.168.1.100",
818 },
819 )
820
821 # Register players
822 controller._players = {
823 "snapcast_client_1": snapcast_player_1,
824 "snapcast_client_2": snapcast_player_2,
825 }
826
827 # Find matching players for first Snapcast player
828 matches = controller._find_matching_protocol_players(snapcast_player_1)
829
830 # Should only match itself, NOT the other Snapcast player (same protocol domain)
831 assert len(matches) == 1
832 assert snapcast_player_1 in matches
833 assert snapcast_player_2 not in matches
834
835
836class TestResolveStoredUniversalPlayer:
837 """
838 Tests for resolving the universal player a protocol player is persistently linked to.
839
840 Regression tests for music-assistant/support#5888: the universal player id is
841 minted once and a device is always routed back to the wrapper its protocol
842 players point at, so the id API consumers (such as the Home Assistant
843 integration) bind their entities to stays put.
844 """
845
846 @staticmethod
847 def _stored_universal_config(**overrides: Any) -> dict[str, Any]:
848 """Return the stored config of a universal player wrapping the ap_123456 device."""
849 return {
850 "player_id": STORED_UNIVERSAL_ID,
851 "provider": "universal_player",
852 "player_type": "player",
853 "enabled": True,
854 "default_name": "Living Room",
855 "values": {
856 "linked_protocol_ids": ["ap_123456"],
857 "device_identifiers": {"mac_address": "54:78:C9:E6:0D:A0"},
858 "device_info": {"model": "WiiM Pro", "manufacturer": "Linkplay"},
859 },
860 } | overrides
861
862 @staticmethod
863 def _wire_config(
864 mock_mass: MagicMock,
865 parent_id: str | None,
866 parent_config: dict[str, Any] | None = None,
867 ) -> None:
868 """
869 Wire a config store holding the ap_123456 protocol player and its parent.
870
871 :param mock_mass: The mocked MusicAssistant instance to wire.
872 :param parent_id: Parent link persisted on the protocol player, if any.
873 :param parent_config: Stored config of the parent player, when it (still) exists.
874 """
875 players: dict[str, Any] = {
876 "ap_123456": {
877 "player_id": "ap_123456",
878 "provider": "airplay",
879 "player_type": "protocol",
880 "enabled": True,
881 "values": {"protocol_parent_id": parent_id},
882 }
883 }
884 if parent_config is not None:
885 players[parent_config["player_id"]] = parent_config
886 _wire_nested_config(mock_mass, {CONF_PLAYERS: players})
887
888 @staticmethod
889 def _wire_players(mock_mass: MagicMock, registry: dict[str, Player]) -> None:
890 """Wire mass.players so registrations land in the given registry."""
891
892 async def _register(player: Player) -> None:
893 registry[player.player_id] = player
894 player.set_initialized()
895
896 mock_mass.players = MagicMock()
897 mock_mass.players.get_player = MagicMock(
898 side_effect=lambda pid, *_args, **_kwargs: registry.get(pid)
899 )
900 mock_mass.players.register_or_update = AsyncMock(side_effect=_register)
901
902 @staticmethod
903 def _make_protocol_player(mock_mass: MagicMock) -> MockPlayer:
904 """Return the unlinked AirPlay protocol player of the wrapped device."""
905 player = MockPlayer(
906 MockProvider("airplay", mass=mock_mass),
907 "ap_123456",
908 "Test Player",
909 player_type=PlayerType.PROTOCOL,
910 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
911 )
912 player.set_initialized()
913 return player
914
915 async def test_registered_parent_resolves_to_its_universal_player(
916 self, mock_mass: MagicMock
917 ) -> None:
918 """The stored parent link resolves to the universal player it names."""
919 universal_provider = create_mock_universal_provider(mock_mass)
920 self._wire_config(mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config())
921 registry: dict[str, Player] = {}
922 self._wire_players(mock_mass, registry)
923 universal = _create_universal_player(
924 mock_mass, STORED_UNIVERSAL_ID, "Living Room", protocol_player_ids=["ap_123456"]
925 )
926 registry[STORED_UNIVERSAL_ID] = universal
927 player = self._make_protocol_player(mock_mass)
928
929 assert await universal_provider._resolve_stored_universal_player(player) is universal
930 mock_mass.players.register_or_update.assert_not_awaited()
931
932 async def test_unregistered_parent_is_restored_from_its_config(
933 self, mock_mass: MagicMock
934 ) -> None:
935 """A stored parent that is not registered is restored under its own player id."""
936 universal_provider = create_mock_universal_provider(mock_mass)
937 self._wire_config(mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config())
938 registry: dict[str, Player] = {}
939 self._wire_players(mock_mass, registry)
940 player = self._make_protocol_player(mock_mass)
941
942 resolved = await universal_provider._resolve_stored_universal_player(player)
943
944 assert resolved is not None
945 assert resolved.player_id == STORED_UNIVERSAL_ID
946 assert resolved._protocol_player_ids == ["ap_123456"]
947 assert registry[STORED_UNIVERSAL_ID] is resolved
948
949 async def test_parent_config_of_another_provider_is_ignored(self, mock_mass: MagicMock) -> None:
950 """A parent config owned by another provider is never ours to resolve to."""
951 universal_provider = create_mock_universal_provider(mock_mass)
952 # a native player whose id merely happens to start with the universal player prefix
953 self._wire_config(
954 mock_mass,
955 "upnp_media_renderer",
956 {
957 "player_id": "upnp_media_renderer",
958 "provider": "some_native_provider",
959 "enabled": True,
960 "values": {"linked_protocol_ids": ["ap_123456"]},
961 },
962 )
963 registry: dict[str, Player] = {}
964 self._wire_players(mock_mass, registry)
965 player = self._make_protocol_player(mock_mass)
966
967 assert await universal_provider._resolve_stored_universal_player(player) is None
968 mock_mass.players.register_or_update.assert_not_awaited()
969
970 async def test_disabled_parent_config_is_ignored(self, mock_mass: MagicMock) -> None:
971 """A disabled universal player stays off: the user turned this device off."""
972 universal_provider = create_mock_universal_provider(mock_mass)
973 self._wire_config(
974 mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config(enabled=False)
975 )
976 registry: dict[str, Player] = {}
977 self._wire_players(mock_mass, registry)
978 player = self._make_protocol_player(mock_mass)
979
980 assert await universal_provider._resolve_stored_universal_player(player) is None
981 mock_mass.players.register_or_update.assert_not_awaited()
982
983 async def test_stored_parent_without_a_config_is_ignored(self, mock_mass: MagicMock) -> None:
984 """A parent link left dangling by a deleted config resolves to nothing."""
985 universal_provider = create_mock_universal_provider(mock_mass)
986 self._wire_config(mock_mass, STORED_UNIVERSAL_ID)
987 registry: dict[str, Player] = {}
988 self._wire_players(mock_mass, registry)
989 player = self._make_protocol_player(mock_mass)
990
991 assert await universal_provider._resolve_stored_universal_player(player) is None
992 mock_mass.players.register_or_update.assert_not_awaited()
993
994 async def test_without_a_stored_parent_link_nothing_resolves(
995 self, mock_mass: MagicMock
996 ) -> None:
997 """A protocol player that was never wrapped resolves to no universal player."""
998 universal_provider = create_mock_universal_provider(mock_mass)
999 self._wire_config(mock_mass, None)
1000 registry: dict[str, Player] = {}
1001 self._wire_players(mock_mass, registry)
1002 player = self._make_protocol_player(mock_mass)
1003
1004 assert await universal_provider._resolve_stored_universal_player(player) is None
1005
1006
1007class TestMintPlayerId:
1008 """Tests for minting the opaque player id of a new universal player."""
1009
1010 def test_minted_id_is_free_as_a_player_and_as_a_config(self, mock_mass: MagicMock) -> None:
1011 """A minted id is never one that is already taken."""
1012 universal_provider = create_mock_universal_provider(mock_mass)
1013 _wire_nested_config(
1014 mock_mass,
1015 {CONF_PLAYERS: {"upbbbbbbbb": {"player_id": "upbbbbbbbb", "enabled": False}}},
1016 )
1017 registered = {"upaaaaaaaa": MagicMock()}
1018 mock_mass.players = MagicMock()
1019 mock_mass.players.get_player = MagicMock(
1020 side_effect=lambda pid, *_args, **_kwargs: registered.get(pid)
1021 )
1022 candidates = [
1023 UUID("aaaaaaaa-0000-0000-0000-000000000000"), # taken by a registered player
1024 UUID("bbbbbbbb-0000-0000-0000-000000000000"), # taken by a (stale) config
1025 UUID("cccccccc-0000-0000-0000-000000000000"),
1026 ]
1027
1028 with patch(
1029 "music_assistant.providers.universal_player.provider.uuid4", side_effect=candidates
1030 ):
1031 assert universal_provider.mint_player_id() == "upcccccccc"
1032
1033
1034class TestGetCleanPlayerName:
1035 """Tests for player name selection."""
1036
1037 def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
1038 """Test that Chromecast names are preferred over other protocols."""
1039 universal_provider = create_mock_universal_provider(mock_mass)
1040
1041 airplay_provider = MockProvider("airplay")
1042 chromecast_provider = MockProvider("chromecast")
1043
1044 airplay_player = MockPlayer(
1045 airplay_provider,
1046 "ap_123456",
1047 "Samsung TV",
1048 player_type=PlayerType.PROTOCOL,
1049 )
1050 chromecast_player = MockPlayer(
1051 chromecast_provider,
1052 "cc_123456",
1053 "Living Room Speaker",
1054 player_type=PlayerType.PROTOCOL,
1055 )
1056
1057 # Chromecast should be preferred (priority 1)
1058 clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
1059 assert clean_name == "Living Room Speaker"
1060
1061 def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
1062 """Test that MAC address-like names are filtered out."""
1063 universal_provider = create_mock_universal_provider(mock_mass)
1064
1065 squeezelite_provider = MockProvider("squeezelite")
1066 airplay_provider = MockProvider("airplay")
1067
1068 # Squeezelite with MAC address as name
1069 sq_player = MockPlayer(
1070 squeezelite_provider,
1071 "sq_123456",
1072 "AA:BB:CC:DD:EE:FF",
1073 player_type=PlayerType.PROTOCOL,
1074 )
1075 # AirPlay with proper name
1076 ap_player = MockPlayer(
1077 airplay_provider,
1078 "ap_123456",
1079 "Kitchen Speaker",
1080 player_type=PlayerType.PROTOCOL,
1081 )
1082
1083 # Should prefer Kitchen Speaker over MAC address
1084 clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
1085 assert clean_name == "Kitchen Speaker"
1086
1087 def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
1088 """Test that player ID-like names are filtered out."""
1089 universal_provider = create_mock_universal_provider(mock_mass)
1090
1091 sendspin_provider = MockProvider("sendspin")
1092 dlna_provider = MockProvider("dlna")
1093
1094 # SendSpin with player ID as name
1095 ss_player = MockPlayer(
1096 sendspin_provider,
1097 "sendspin_123456",
1098 "sendspin_device_abc",
1099 player_type=PlayerType.PROTOCOL,
1100 )
1101 # DLNA with proper name
1102 dlna_player = MockPlayer(
1103 dlna_provider,
1104 "dlna_123456",
1105 "Bedroom TV",
1106 player_type=PlayerType.PROTOCOL,
1107 )
1108
1109 # Should prefer Bedroom TV over player ID
1110 clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
1111 assert clean_name == "Bedroom TV"
1112
1113 def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
1114 """Test that valid names are returned unchanged."""
1115 universal_provider = create_mock_universal_provider(mock_mass)
1116
1117 provider = MockProvider("airplay")
1118 player = MockPlayer(
1119 provider,
1120 "ap_123456",
1121 "HomePod Mini",
1122 player_type=PlayerType.PLAYER,
1123 )
1124
1125 clean_name = universal_provider._get_clean_player_name([player])
1126 assert clean_name == "HomePod Mini"
1127
1128
1129class TestCachedProtocolParentRestore:
1130 """Tests for restoring cached protocol parent links."""
1131
1132 def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
1133 """Test that cached protocol_parent_id is loaded and used for immediate linking."""
1134 controller = PlayerController(mock_mass)
1135
1136 # Mock config to return cached parent_id when queried
1137 def mock_config_get(key: str, default: str | None = None) -> str | None:
1138 if "protocol_parent_id" in str(key):
1139 return "native_player_id"
1140 return default
1141
1142 mock_mass.config.get.side_effect = mock_config_get
1143
1144 # Create native player
1145 native_provider = MockProvider("sonos", mass=mock_mass)
1146 native_player = MockPlayer(
1147 native_provider,
1148 "native_player_id",
1149 "Sonos Speaker",
1150 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1151 )
1152
1153 # Create protocol player
1154 dlna_provider = MockProvider("dlna", mass=mock_mass)
1155 protocol_player = MockPlayer(
1156 dlna_provider,
1157 "uuid:RINCON_AABBCCDDEEFF_MR",
1158 "Sonos DLNA",
1159 player_type=PlayerType.PROTOCOL,
1160 )
1161
1162 # Register native player
1163 controller._players = {"native_player_id": native_player}
1164
1165 # Try to link protocol to native - should load cached parent_id
1166 controller._try_link_protocol_to_native(protocol_player)
1167
1168 # Verify protocol_parent_id was set
1169 assert protocol_player.protocol_parent_id == "native_player_id"
1170
1171 # Verify protocol was linked to native player
1172 assert any(
1173 link.output_protocol_id == protocol_player.player_id
1174 for link in native_player.linked_output_protocols
1175 )
1176
1177 def test_missing_cached_parent_schedules_protocol_evaluation(
1178 self, mock_mass: MagicMock
1179 ) -> None:
1180 """Test that a missing cached parent does not block delayed protocol evaluation."""
1181 controller = PlayerController(mock_mass)
1182
1183 # Mock config to return cached parent_id (parent not yet registered)
1184 def mock_config_get(key: str, default: str | None = None) -> str | None:
1185 if "protocol_parent_id" in str(key):
1186 return "native_player_id"
1187 return default
1188
1189 mock_mass.config.get.side_effect = mock_config_get
1190
1191 # Create protocol player
1192 dlna_provider = MockProvider("dlna", mass=mock_mass)
1193 protocol_player = MockPlayer(
1194 dlna_provider,
1195 "uuid:RINCON_AABBCCDDEEFF_MR",
1196 "Sonos DLNA",
1197 player_type=PlayerType.PROTOCOL,
1198 )
1199
1200 # No native player registered yet
1201 controller._players = {}
1202
1203 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
1204 controller._try_link_protocol_to_native(protocol_player)
1205
1206 # A cached parent that is not registered yet should be handled by the
1207 # delayed evaluation path, not by assigning a dangling in-memory parent.
1208 assert protocol_player.protocol_parent_id is None
1209 mock_schedule.assert_called_once_with(protocol_player)
1210
1211 def test_stale_cache_does_not_claim_a_protocol_waiting_for_its_owner(
1212 self, mock_mass: MagicMock
1213 ) -> None:
1214 """A protocol whose persisted owner is still starting up is not claimed by another parent."""
1215 controller = PlayerController(mock_mass)
1216
1217 store: dict[str, Any] = {
1218 CONF_PLAYERS: {
1219 "ap_shared": {
1220 "provider": "airplay--x",
1221 "player_type": "protocol",
1222 "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1223 },
1224 "heos_player": {
1225 "provider": "heos--y",
1226 "player_type": "player",
1227 "values": {"linked_protocol_ids": ["ap_shared"]},
1228 },
1229 "sonos_owner": {
1230 "provider": "sonos--z",
1231 "player_type": "player",
1232 "values": {"linked_protocol_ids": ["ap_shared"]},
1233 },
1234 }
1235 }
1236 _wire_nested_config(mock_mass, store)
1237
1238 # a native player that kept a stale cached id for a protocol it no longer owns
1239 heos_player = MockPlayer(
1240 MockProvider("heos", mass=mock_mass),
1241 "heos_player",
1242 "Old Receiver",
1243 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
1244 )
1245 heos_player.set_initialized()
1246 airplay = MockPlayer(
1247 MockProvider("airplay", mass=mock_mass),
1248 "ap_shared",
1249 "Speaker (AirPlay)",
1250 player_type=PlayerType.PROTOCOL,
1251 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1252 )
1253 airplay.set_initialized()
1254 controller._players = {"heos_player": heos_player, "ap_shared": airplay}
1255
1256 # the persisted owner has not registered yet
1257 controller._try_link_protocol_to_native(airplay)
1258
1259 assert airplay.protocol_parent_id is None
1260 assert heos_player.linked_output_protocols == []
1261 assert mock_mass.loop.call_later.call_args.args[0] == 45.0
1262
1263 # once the owner registers it takes its own protocol
1264 sonos_owner = MockPlayer(
1265 MockProvider("sonos", mass=mock_mass),
1266 "sonos_owner",
1267 "Speaker",
1268 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1269 )
1270 sonos_owner.set_initialized()
1271 controller._players["sonos_owner"] = sonos_owner
1272 controller._try_link_protocols_to_native(sonos_owner)
1273
1274 assert airplay.protocol_parent_id == "sonos_owner"
1275 assert [ # type: ignore[unreachable]
1276 link.output_protocol_id for link in sonos_owner.linked_output_protocols
1277 ] == ["ap_shared"]
1278
1279 def test_identifier_match_does_not_claim_a_protocol_waiting_for_its_owner(
1280 self, mock_mass: MagicMock
1281 ) -> None:
1282 """Identifier matching does not override a protocol's own persisted owner."""
1283 controller = PlayerController(mock_mass)
1284
1285 store: dict[str, Any] = {
1286 CONF_PLAYERS: {
1287 "ap_shared": {
1288 "provider": "airplay--x",
1289 "player_type": "protocol",
1290 "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1291 },
1292 "sonos_owner": {"provider": "sonos--z", "player_type": "player", "values": {}},
1293 }
1294 }
1295 _wire_nested_config(mock_mass, store)
1296
1297 # shares identifiers with the protocol but is not its persisted owner
1298 other_native = MockPlayer(
1299 MockProvider("heos", mass=mock_mass),
1300 "heos_player",
1301 "Other Receiver",
1302 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1303 )
1304 other_native.set_initialized()
1305 airplay = MockPlayer(
1306 MockProvider("airplay", mass=mock_mass),
1307 "ap_shared",
1308 "Speaker (AirPlay)",
1309 player_type=PlayerType.PROTOCOL,
1310 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1311 )
1312 airplay.set_initialized()
1313 controller._players = {"heos_player": other_native, "ap_shared": airplay}
1314
1315 controller._try_link_protocol_to_native(airplay)
1316
1317 assert airplay.protocol_parent_id is None
1318 assert other_native.linked_output_protocols == []
1319
1320 def test_other_native_does_not_claim_a_protocol_reserved_for_its_owner(
1321 self, mock_mass: MagicMock
1322 ) -> None:
1323 """A native registering before the persisted owner leaves that owner's protocol alone."""
1324 controller = PlayerController(mock_mass)
1325
1326 store: dict[str, Any] = {
1327 CONF_PLAYERS: {
1328 "ap_shared": {
1329 "provider": "airplay--x",
1330 "player_type": "protocol",
1331 "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1332 },
1333 "sonos_owner": {"provider": "sonos--z", "player_type": "player", "values": {}},
1334 }
1335 }
1336 _wire_nested_config(mock_mass, store)
1337
1338 airplay = MockPlayer(
1339 MockProvider("airplay", mass=mock_mass),
1340 "ap_shared",
1341 "Speaker (AirPlay)",
1342 player_type=PlayerType.PROTOCOL,
1343 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1344 )
1345 airplay.set_initialized()
1346 controller._players = {"ap_shared": airplay}
1347
1348 controller._try_link_protocol_to_native(airplay)
1349 assert airplay.protocol_parent_id is None
1350
1351 # another native with a colliding identifier registers before the owner
1352 other_native = MockPlayer(
1353 MockProvider("heos", mass=mock_mass),
1354 "heos_player",
1355 "Other Receiver",
1356 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1357 )
1358 other_native.set_initialized()
1359 controller._players["heos_player"] = other_native
1360 controller._try_link_protocols_to_native(other_native)
1361
1362 assert airplay.protocol_parent_id is None
1363 assert other_native.linked_output_protocols == []
1364
1365 # the owner still gets its own protocol
1366 sonos_owner = MockPlayer(
1367 MockProvider("sonos", mass=mock_mass),
1368 "sonos_owner",
1369 "Speaker",
1370 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1371 )
1372 sonos_owner.set_initialized()
1373 controller._players["sonos_owner"] = sonos_owner
1374 controller._try_link_protocols_to_native(sonos_owner)
1375
1376 assert airplay.protocol_parent_id == "sonos_owner"
1377
1378 @pytest.mark.asyncio
1379 async def test_protocol_links_elsewhere_when_its_owner_never_registers(
1380 self, mock_mass: MagicMock
1381 ) -> None:
1382 """A protocol whose persisted owner never registers still links on delayed evaluation."""
1383 controller = PlayerController(mock_mass)
1384
1385 store: dict[str, Any] = {
1386 CONF_PLAYERS: {
1387 "ap_shared": {
1388 "provider": "airplay--x",
1389 "player_type": "protocol",
1390 "values": {CONF_PROTOCOL_PARENT_ID: "gone_owner"},
1391 },
1392 "heos_player": {
1393 "provider": "heos--y",
1394 "player_type": "player",
1395 "values": {"linked_protocol_ids": ["ap_shared"]},
1396 },
1397 }
1398 }
1399 _wire_nested_config(mock_mass, store)
1400
1401 heos_player = MockPlayer(
1402 MockProvider("heos", mass=mock_mass),
1403 "heos_player",
1404 "Receiver",
1405 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1406 )
1407 heos_player.set_initialized()
1408 airplay = MockPlayer(
1409 MockProvider("airplay", mass=mock_mass),
1410 "ap_shared",
1411 "Speaker (AirPlay)",
1412 player_type=PlayerType.PROTOCOL,
1413 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1414 )
1415 airplay.set_initialized()
1416 controller._players = {"heos_player": heos_player, "ap_shared": airplay}
1417
1418 controller._try_link_protocol_to_native(airplay)
1419 assert airplay.protocol_parent_id is None
1420
1421 await controller._delayed_protocol_evaluation("ap_shared")
1422
1423 assert airplay.protocol_parent_id == "heos_player"
1424
1425 @pytest.mark.asyncio
1426 async def test_cached_parent_registers_before_delayed_eval_links_without_universal(
1427 self, mock_mass: MagicMock
1428 ) -> None:
1429 """Test cached parent startup grace still links when native registers during delay."""
1430 controller = PlayerController(mock_mass)
1431
1432 def mock_config_get(
1433 key: str, default: str | list[str] | None = None
1434 ) -> str | list[str] | None:
1435 if "protocol_parent_id" in str(key):
1436 return "native_player_id"
1437 return default
1438
1439 mock_mass.config.get.side_effect = mock_config_get
1440
1441 dlna_provider = MockProvider("dlna", mass=mock_mass)
1442 protocol_player = MockPlayer(
1443 dlna_provider,
1444 "uuid:RINCON_AABBCCDDEEFF_MR",
1445 "Sonos DLNA",
1446 player_type=PlayerType.PROTOCOL,
1447 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1448 )
1449 protocol_player.set_initialized()
1450 controller._players = {protocol_player.player_id: protocol_player}
1451
1452 controller._try_link_protocol_to_native(protocol_player)
1453
1454 assert protocol_player.protocol_parent_id is None
1455 assert mock_mass.loop.call_later.call_args.args[0] == 45.0
1456
1457 native_provider = MockProvider("sonos", mass=mock_mass)
1458 native_player = MockPlayer(
1459 native_provider,
1460 "native_player_id",
1461 "Sonos Speaker",
1462 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1463 )
1464 native_player.set_initialized()
1465 controller._players[native_player.player_id] = native_player
1466
1467 controller._try_link_protocols_to_native(native_player)
1468
1469 assert protocol_player.protocol_parent_id == native_player.player_id
1470 assert any( # type: ignore[unreachable]
1471 link.output_protocol_id == protocol_player.player_id
1472 for link in native_player.linked_output_protocols
1473 )
1474
1475 with patch.object(controller, "_create_or_update_universal_player") as mock_create_up:
1476 await controller._delayed_protocol_evaluation(protocol_player.player_id)
1477
1478 mock_create_up.assert_not_called()
1479
1480
1481class TestSelectBestOutputProtocol:
1482 """Tests for output protocol selection logic."""
1483
1484 def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
1485 """Test that native protocol is selected when user prefers native."""
1486
1487 # Mock config to return "native" as preferred, but still handle min/max volume
1488 def _get_raw_config(
1489 _player_id: str, key: str, _default: str | int | None = None
1490 ) -> str | int | None:
1491 if key == "min_volume":
1492 return 0
1493 if key == "max_volume":
1494 return 100
1495 return "native"
1496
1497 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1498
1499 controller = PlayerController(mock_mass)
1500 provider = MockProvider("sonos", mass=mock_mass)
1501
1502 # Create native player with PLAY_MEDIA support
1503 native_player = MockPlayer(
1504 provider,
1505 "sonos_123",
1506 "Kantoor",
1507 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1508 )
1509 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1510
1511 # Wire up mock_mass.players to controller
1512 mock_mass.players = controller
1513
1514 # Register players
1515 controller._players = {"sonos_123": native_player}
1516
1517 # Select protocol
1518 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1519
1520 # Should select native player
1521 assert selected_player == native_player
1522 assert output_protocol is None # None means native playback
1523
1524 def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
1525 """Test that DLNA protocol is selected when user prefers DLNA."""
1526
1527 # Mock config to return the full player ID as preferred, but still handle min/max volume
1528 def _get_raw_config(
1529 _player_id: str, key: str, _default: str | int | None = None
1530 ) -> str | int | None:
1531 if key == "min_volume":
1532 return 0
1533 if key == "max_volume":
1534 return 100
1535 return "dlna_AABBCCDDEEFF"
1536
1537 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1538
1539 controller = PlayerController(mock_mass)
1540
1541 # Create native player with linked protocols
1542 sonos_provider = MockProvider("sonos", mass=mock_mass)
1543 native_player = MockPlayer(
1544 sonos_provider,
1545 "sonos_123",
1546 "Kantoor",
1547 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1548 )
1549 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1550
1551 # Create DLNA protocol player
1552 dlna_provider = MockProvider("dlna", mass=mock_mass)
1553 dlna_player = MockPlayer(
1554 dlna_provider,
1555 "dlna_AABBCCDDEEFF",
1556 "Kantoor DLNA",
1557 player_type=PlayerType.PROTOCOL,
1558 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1559 )
1560
1561 # Register players
1562 controller._players = {
1563 "sonos_123": native_player,
1564 "dlna_AABBCCDDEEFF": dlna_player,
1565 }
1566
1567 # Link DLNA protocol to native player
1568 native_player.set_linked_output_protocols(
1569 [
1570 LinkedOutputProtocol(
1571 output_protocol_id="dlna_AABBCCDDEEFF",
1572 protocol_domain="dlna",
1573 priority=50,
1574 )
1575 ]
1576 )
1577
1578 # Select protocol
1579 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1580
1581 # Should select DLNA player, not native
1582 assert selected_player == dlna_player
1583 assert output_protocol is not None
1584 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
1585
1586 def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
1587 """Test that AirPlay protocol is selected when user prefers AirPlay."""
1588
1589 # Mock config to return the full player ID as preferred, but still handle min/max volume
1590 def _get_raw_config(
1591 _player_id: str, key: str, _default: str | int | None = None
1592 ) -> str | int | None:
1593 if key == "min_volume":
1594 return 0
1595 if key == "max_volume":
1596 return 100
1597 return "airplay_AABBCCDDEEFF"
1598
1599 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1600
1601 controller = PlayerController(mock_mass)
1602
1603 # Create native player
1604 sonos_provider = MockProvider("sonos", mass=mock_mass)
1605 native_player = MockPlayer(
1606 sonos_provider,
1607 "sonos_123",
1608 "Kantoor",
1609 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1610 )
1611 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1612
1613 # Create AirPlay and DLNA protocol players
1614 airplay_provider = MockProvider("airplay", mass=mock_mass)
1615 airplay_player = MockPlayer(
1616 airplay_provider,
1617 "airplay_AABBCCDDEEFF",
1618 "Kantoor AirPlay",
1619 player_type=PlayerType.PROTOCOL,
1620 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1621 )
1622
1623 dlna_provider = MockProvider("dlna", mass=mock_mass)
1624 dlna_player = MockPlayer(
1625 dlna_provider,
1626 "dlna_AABBCCDDEEFF",
1627 "Kantoor DLNA",
1628 player_type=PlayerType.PROTOCOL,
1629 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1630 )
1631
1632 # Register players
1633 controller._players = {
1634 "sonos_123": native_player,
1635 "airplay_AABBCCDDEEFF": airplay_player,
1636 "dlna_AABBCCDDEEFF": dlna_player,
1637 }
1638
1639 # Link protocols to native player
1640 native_player.set_linked_output_protocols(
1641 [
1642 LinkedOutputProtocol(
1643 output_protocol_id="airplay_AABBCCDDEEFF",
1644 protocol_domain="airplay",
1645 priority=10,
1646 ),
1647 LinkedOutputProtocol(
1648 output_protocol_id="dlna_AABBCCDDEEFF",
1649 protocol_domain="dlna",
1650 priority=50,
1651 ),
1652 ]
1653 )
1654
1655 # Select protocol
1656 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1657
1658 # Should select AirPlay player (even though DLNA has lower priority value),
1659 # because user preference overrides priority
1660 assert selected_player == airplay_player
1661 assert output_protocol is not None
1662 assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
1663
1664 def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
1665 """Test that native playback is used when preference is auto."""
1666
1667 # Mock config to return "auto" as preferred, but still handle min/max volume
1668 def _get_raw_config(
1669 _player_id: str, key: str, _default: str | int | None = None
1670 ) -> str | int | None:
1671 if key == "min_volume":
1672 return 0
1673 if key == "max_volume":
1674 return 100
1675 return "auto"
1676
1677 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1678
1679 controller = PlayerController(mock_mass)
1680 provider = MockProvider("sonos", mass=mock_mass)
1681
1682 native_player = MockPlayer(
1683 provider,
1684 "sonos_123",
1685 "Kantoor",
1686 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1687 )
1688 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1689
1690 controller._players = {"sonos_123": native_player}
1691
1692 # Select protocol with auto preference
1693 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1694
1695 # Should select native player
1696 assert selected_player == native_player
1697 assert output_protocol is None # None means native playback
1698
1699 def test_skip_protocol_that_needs_setup(self, mock_mass: MagicMock) -> None:
1700 """Test that a protocol player which still needs setup is never selected."""
1701 controller = PlayerController(mock_mass)
1702
1703 # Universal player wrapping the protocols of a device without native playback
1704 universal_provider = MockProvider("universal_player", mass=mock_mass)
1705 universal_player = MockPlayer(
1706 universal_provider,
1707 "up_AABBCCDDEEFF",
1708 "Living Room TV",
1709 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1710 )
1711
1712 # AirPlay protocol player that is discovered but still awaits pairing
1713 airplay_provider = MockProvider("airplay", mass=mock_mass)
1714 airplay_player = MockPlayer(
1715 airplay_provider,
1716 "airplay_AABBCCDDEEFF",
1717 "Living Room TV AirPlay",
1718 player_type=PlayerType.PROTOCOL,
1719 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1720 )
1721 airplay_player._attr_needs_setup = True
1722
1723 dlna_provider = MockProvider("dlna", mass=mock_mass)
1724 dlna_player = MockPlayer(
1725 dlna_provider,
1726 "dlna_AABBCCDDEEFF",
1727 "Living Room TV DLNA",
1728 player_type=PlayerType.PROTOCOL,
1729 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1730 )
1731
1732 # Wire up mock_mass.players to controller
1733 mock_mass.players = controller
1734
1735 # Register players
1736 controller._players = {
1737 "up_AABBCCDDEEFF": universal_player,
1738 "airplay_AABBCCDDEEFF": airplay_player,
1739 "dlna_AABBCCDDEEFF": dlna_player,
1740 }
1741
1742 # Link both protocols, AirPlay with the better priority
1743 universal_player.set_linked_output_protocols(
1744 [
1745 LinkedOutputProtocol(
1746 output_protocol_id="airplay_AABBCCDDEEFF",
1747 protocol_domain="airplay",
1748 priority=10,
1749 ),
1750 LinkedOutputProtocol(
1751 output_protocol_id="dlna_AABBCCDDEEFF",
1752 protocol_domain="dlna",
1753 priority=50,
1754 ),
1755 ]
1756 )
1757
1758 selected_player, output_protocol = controller._select_best_output_protocol(universal_player)
1759
1760 # Should skip the unpaired AirPlay output and fall through to DLNA
1761 assert selected_player == dlna_player
1762 assert output_protocol is not None
1763 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
1764
1765 # and the unpaired output must be reported as unavailable
1766 universal_player.update_state(signal_event=False)
1767 airplay_output = next(
1768 protocol
1769 for protocol in universal_player.output_protocols
1770 if protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
1771 )
1772 assert airplay_output.available is False
1773
1774
1775class TestGetControlTarget:
1776 """Tests for resolving the target of an audio-path command."""
1777
1778 def test_fallback_follows_protocol_priority(self, mock_mass: MagicMock) -> None:
1779 """
1780 Without an active output the fallback picks the highest priority protocol.
1781
1782 An announcement on an idle player reaches this path, so it has to land on the
1783 same output that regular playback selection would have chosen.
1784 """
1785 controller = PlayerController(mock_mass)
1786 mock_mass.players = controller
1787
1788 universal_provider = MockProvider("universal_player", mass=mock_mass)
1789 universal_player = MockPlayer(universal_provider, "universal_123", "Kantoor")
1790 universal_player._attr_supported_features = set()
1791
1792 sendspin_player = MockPlayer(
1793 MockProvider("sendspin", mass=mock_mass),
1794 "sendspin_AABBCCDDEEFF",
1795 "Kantoor Sendspin",
1796 player_type=PlayerType.PROTOCOL,
1797 )
1798 sendspin_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1799 airplay_player = MockPlayer(
1800 MockProvider("airplay", mass=mock_mass),
1801 "airplay_AABBCCDDEEFF",
1802 "Kantoor AirPlay",
1803 player_type=PlayerType.PROTOCOL,
1804 )
1805 airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1806
1807 controller._players = {
1808 "universal_123": universal_player,
1809 "sendspin_AABBCCDDEEFF": sendspin_player,
1810 "airplay_AABBCCDDEEFF": airplay_player,
1811 }
1812 # sendspin links first, but airplay outranks it
1813 universal_player.set_linked_output_protocols(
1814 [
1815 LinkedOutputProtocol(
1816 output_protocol_id="sendspin_AABBCCDDEEFF",
1817 protocol_domain="sendspin",
1818 priority=40,
1819 ),
1820 LinkedOutputProtocol(
1821 output_protocol_id="airplay_AABBCCDDEEFF",
1822 protocol_domain="airplay",
1823 priority=10,
1824 ),
1825 ]
1826 )
1827
1828 target = controller._get_control_target(
1829 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1830 )
1831 assert target == airplay_player
1832
1833 def test_fallback_follows_preferred_output_protocol(self, mock_mass: MagicMock) -> None:
1834 """An explicitly preferred output beats the priority order."""
1835 controller = PlayerController(mock_mass)
1836 mock_mass.players = controller
1837
1838 def _get_raw_player_config_value(
1839 _player_id: str, key: str, default: str | int | None = None
1840 ) -> str | int | None:
1841 if key == CONF_PREFERRED_OUTPUT_PROTOCOL:
1842 return "sendspin_AABBCCDDEEFF"
1843 if key == "min_volume":
1844 return 0
1845 if key == "max_volume":
1846 return 100
1847 return default
1848
1849 mock_mass.config.get_raw_player_config_value = MagicMock(
1850 side_effect=_get_raw_player_config_value
1851 )
1852
1853 universal_player = MockPlayer(
1854 MockProvider("universal_player", mass=mock_mass), "universal_123", "Kantoor"
1855 )
1856 universal_player._attr_supported_features = set()
1857 sendspin_player = MockPlayer(
1858 MockProvider("sendspin", mass=mock_mass),
1859 "sendspin_AABBCCDDEEFF",
1860 "Kantoor Sendspin",
1861 player_type=PlayerType.PROTOCOL,
1862 )
1863 sendspin_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1864 airplay_player = MockPlayer(
1865 MockProvider("airplay", mass=mock_mass),
1866 "airplay_AABBCCDDEEFF",
1867 "Kantoor AirPlay",
1868 player_type=PlayerType.PROTOCOL,
1869 )
1870 airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1871
1872 controller._players = {
1873 "universal_123": universal_player,
1874 "sendspin_AABBCCDDEEFF": sendspin_player,
1875 "airplay_AABBCCDDEEFF": airplay_player,
1876 }
1877 # airplay outranks sendspin, but the user picked sendspin
1878 universal_player.set_linked_output_protocols(
1879 [
1880 LinkedOutputProtocol(
1881 output_protocol_id="airplay_AABBCCDDEEFF",
1882 protocol_domain="airplay",
1883 priority=10,
1884 ),
1885 LinkedOutputProtocol(
1886 output_protocol_id="sendspin_AABBCCDDEEFF",
1887 protocol_domain="sendspin",
1888 priority=40,
1889 ),
1890 ]
1891 )
1892
1893 target = controller._get_control_target(
1894 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1895 )
1896 assert target == sendspin_player
1897
1898 def test_fallback_ignores_preference_for_another_players_output(
1899 self, mock_mass: MagicMock
1900 ) -> None:
1901 """A preference left behind by a relink does not steal another speaker's output."""
1902 controller = PlayerController(mock_mass)
1903 mock_mass.players = controller
1904
1905 def _get_raw_player_config_value(
1906 _player_id: str, key: str, default: str | int | None = None
1907 ) -> str | int | None:
1908 if key == CONF_PREFERRED_OUTPUT_PROTOCOL:
1909 return "airplay_998877665544"
1910 if key == "min_volume":
1911 return 0
1912 if key == "max_volume":
1913 return 100
1914 return default
1915
1916 mock_mass.config.get_raw_player_config_value = MagicMock(
1917 side_effect=_get_raw_player_config_value
1918 )
1919
1920 universal_player = MockPlayer(
1921 MockProvider("universal_player", mass=mock_mass), "universal_123", "Kantoor"
1922 )
1923 universal_player._attr_supported_features = set()
1924 own_player = MockPlayer(
1925 MockProvider("sendspin", mass=mock_mass),
1926 "sendspin_AABBCCDDEEFF",
1927 "Kantoor Sendspin",
1928 player_type=PlayerType.PROTOCOL,
1929 )
1930 own_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1931 # the preference now points at an output belonging to a different speaker
1932 other_player = MockPlayer(
1933 MockProvider("airplay", mass=mock_mass),
1934 "airplay_998877665544",
1935 "Woonkamer AirPlay",
1936 player_type=PlayerType.PROTOCOL,
1937 )
1938 other_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1939
1940 controller._players = {
1941 "universal_123": universal_player,
1942 "sendspin_AABBCCDDEEFF": own_player,
1943 "airplay_998877665544": other_player,
1944 }
1945 universal_player.set_linked_output_protocols(
1946 [
1947 LinkedOutputProtocol(
1948 output_protocol_id="sendspin_AABBCCDDEEFF",
1949 protocol_domain="sendspin",
1950 priority=40,
1951 )
1952 ]
1953 )
1954
1955 target = controller._get_control_target(
1956 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1957 )
1958 assert target == own_player
1959
1960 def test_require_active_skips_the_fallback(self, mock_mass: MagicMock) -> None:
1961 """With require_active there is no fallback to an idle linked protocol."""
1962 controller = PlayerController(mock_mass)
1963 mock_mass.players = controller
1964
1965 universal_player = MockPlayer(
1966 MockProvider("universal_player", mass=mock_mass),
1967 "universal_123",
1968 "Kantoor",
1969 )
1970 universal_player._attr_supported_features = set()
1971 airplay_player = MockPlayer(
1972 MockProvider("airplay", mass=mock_mass),
1973 "airplay_AABBCCDDEEFF",
1974 "Kantoor AirPlay",
1975 player_type=PlayerType.PROTOCOL,
1976 )
1977 airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1978
1979 controller._players = {
1980 "universal_123": universal_player,
1981 "airplay_AABBCCDDEEFF": airplay_player,
1982 }
1983 universal_player.set_linked_output_protocols(
1984 [
1985 LinkedOutputProtocol(
1986 output_protocol_id="airplay_AABBCCDDEEFF",
1987 protocol_domain="airplay",
1988 priority=10,
1989 )
1990 ]
1991 )
1992
1993 assert (
1994 controller._get_control_target(
1995 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=True
1996 )
1997 is None
1998 )
1999
2000
2001class TestPlayerGrouping:
2002 """Tests for player grouping scenarios."""
2003
2004 def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
2005 """Test that native players from same provider can group together."""
2006 controller = PlayerController(mock_mass)
2007
2008 sonos_provider = MockProvider("sonos", mass=mock_mass)
2009
2010 # Create two Sonos players
2011 player_a = MockPlayer(
2012 sonos_provider,
2013 "sonos_123",
2014 "Living Room",
2015 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2016 )
2017 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2018 player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2019 player_a._attr_can_group_with = {"sonos_456"}
2020 player_a._cache.clear() # Clear cached properties after modifying attributes
2021
2022 player_b = MockPlayer(
2023 sonos_provider,
2024 "sonos_456",
2025 "Kitchen",
2026 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2027 )
2028 player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2029 player_b._cache.clear()
2030
2031 controller._players = {
2032 "sonos_123": player_a,
2033 "sonos_456": player_b,
2034 }
2035
2036 # Translate members for native grouping
2037 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2038 parent_player=player_a,
2039 player_ids=["sonos_456"],
2040 parent_protocol_player=None,
2041 parent_protocol_domain=None,
2042 )
2043
2044 # Should use native grouping (same provider)
2045 assert len(native_members) == 1
2046 assert "sonos_456" in native_members
2047 assert len(protocol_members) == 0
2048
2049 def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
2050 """Test that protocol players can group via shared protocol."""
2051 controller = PlayerController(mock_mass)
2052
2053 # Create two players with AirPlay protocol support
2054 sonos_provider = MockProvider("sonos", mass=mock_mass)
2055 wiim_provider = MockProvider("wiim", mass=mock_mass)
2056 airplay_provider = MockProvider("airplay", mass=mock_mass)
2057
2058 # Sonos player
2059 sonos_player = MockPlayer(
2060 sonos_provider,
2061 "sonos_123",
2062 "Living Room",
2063 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2064 )
2065 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2066 sonos_player._cache.clear()
2067
2068 # WiiM player
2069 wiim_player = MockPlayer(
2070 wiim_provider,
2071 "wiim_456",
2072 "Bedroom",
2073 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2074 )
2075 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2076 wiim_player._cache.clear()
2077
2078 # AirPlay protocol players
2079 sonos_airplay = MockPlayer(
2080 airplay_provider,
2081 "airplay_sonos",
2082 "Living Room (AirPlay)",
2083 player_type=PlayerType.PROTOCOL,
2084 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2085 )
2086 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2087 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2088 sonos_airplay._cache.clear()
2089 sonos_airplay.update_state(signal_event=False)
2090
2091 wiim_airplay = MockPlayer(
2092 airplay_provider,
2093 "airplay_wiim",
2094 "Bedroom (AirPlay)",
2095 player_type=PlayerType.PROTOCOL,
2096 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2097 )
2098
2099 # Link protocol players to native players
2100 sonos_player.set_linked_output_protocols(
2101 [
2102 LinkedOutputProtocol(
2103 output_protocol_id="airplay_sonos",
2104 protocol_domain="airplay",
2105 priority=10,
2106 )
2107 ]
2108 )
2109 wiim_player.set_linked_output_protocols(
2110 [
2111 LinkedOutputProtocol(
2112 output_protocol_id="airplay_wiim",
2113 protocol_domain="airplay",
2114 priority=10,
2115 )
2116 ]
2117 )
2118
2119 controller._players = {
2120 "sonos_123": sonos_player,
2121 "wiim_456": wiim_player,
2122 "airplay_sonos": sonos_airplay,
2123 "airplay_wiim": wiim_airplay,
2124 }
2125
2126 # Translate members for protocol grouping (via AirPlay)
2127 protocol_members, native_members, protocol_player, _ = (
2128 controller._translate_members_for_protocols(
2129 parent_player=sonos_player,
2130 player_ids=["wiim_456"],
2131 parent_protocol_player=sonos_airplay,
2132 parent_protocol_domain="airplay",
2133 )
2134 )
2135
2136 # Should use protocol grouping (AirPlay)
2137 assert len(protocol_members) == 1
2138 assert "airplay_wiim" in protocol_members
2139 assert len(native_members) == 0
2140 assert protocol_player == sonos_airplay
2141
2142 def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
2143 """Test hybrid grouping: native + protocol players in same group."""
2144 controller = PlayerController(mock_mass)
2145
2146 # Create Sonos players (native grouping capability)
2147 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2148 sonos_a = MockPlayer(
2149 sonos_provider,
2150 "sonos_123",
2151 "Living Room",
2152 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2153 )
2154 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2155 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2156 sonos_a._attr_can_group_with = {"sonos_456"}
2157 sonos_a._cache.clear()
2158
2159 sonos_b = MockPlayer(
2160 sonos_provider,
2161 "sonos_456",
2162 "Kitchen",
2163 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2164 )
2165 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2166 sonos_b._cache.clear()
2167
2168 # Create WiiM player with AirPlay protocol
2169 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
2170 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2171
2172 wiim_player = MockPlayer(
2173 wiim_provider,
2174 "wiim_789",
2175 "Bedroom",
2176 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2177 )
2178 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2179 wiim_player._cache.clear()
2180
2181 # AirPlay protocol players
2182 sonos_airplay = MockPlayer(
2183 airplay_provider,
2184 "airplay_sonos",
2185 "Living Room (AirPlay)",
2186 player_type=PlayerType.PROTOCOL,
2187 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2188 )
2189 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2190 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2191 sonos_airplay._cache.clear()
2192 sonos_airplay.update_state(signal_event=False)
2193
2194 wiim_airplay = MockPlayer(
2195 airplay_provider,
2196 "airplay_wiim",
2197 "Bedroom (AirPlay)",
2198 player_type=PlayerType.PROTOCOL,
2199 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2200 )
2201
2202 # Link AirPlay to Sonos A
2203 sonos_a.set_linked_output_protocols(
2204 [
2205 LinkedOutputProtocol(
2206 output_protocol_id="airplay_sonos",
2207 protocol_domain="airplay",
2208 priority=10,
2209 )
2210 ]
2211 )
2212 wiim_player.set_linked_output_protocols(
2213 [
2214 LinkedOutputProtocol(
2215 output_protocol_id="airplay_wiim",
2216 protocol_domain="airplay",
2217 priority=10,
2218 )
2219 ]
2220 )
2221 wiim_player.set_active_output_protocol("airplay_wiim")
2222 wiim_player.set_protocol_parent_id("airplay_wiim")
2223
2224 # Wire up mock_mass.players to controller so player lookups resolve
2225 mock_mass.players = controller
2226
2227 controller._players = {
2228 "sonos_123": sonos_a,
2229 "sonos_456": sonos_b,
2230 "wiim_789": wiim_player,
2231 "airplay_sonos": sonos_airplay,
2232 "airplay_wiim": wiim_airplay,
2233 }
2234
2235 # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
2236 protocol_members, native_members, _protocol_player, _ = (
2237 controller._translate_members_for_protocols(
2238 parent_player=sonos_a,
2239 player_ids=["sonos_456", "wiim_789"],
2240 parent_protocol_player=sonos_airplay,
2241 parent_protocol_domain="airplay",
2242 )
2243 )
2244
2245 # Should have hybrid group: native Sonos B + protocol WiiM
2246 assert len(native_members) == 1
2247 assert "sonos_456" in native_members
2248 assert len(protocol_members) == 1
2249 assert "airplay_wiim" in protocol_members
2250
2251 def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
2252 """Test that only protocols with SET_MEMBERS support are selected for grouping."""
2253 controller = PlayerController(mock_mass)
2254
2255 sonos_provider = MockProvider("sonos", mass=mock_mass)
2256 wiim_provider = MockProvider("wiim", mass=mock_mass)
2257 dlna_provider = MockProvider("dlna", mass=mock_mass)
2258 airplay_provider = MockProvider("airplay", mass=mock_mass)
2259
2260 # Sonos player
2261 sonos_player = MockPlayer(
2262 sonos_provider,
2263 "sonos_123",
2264 "Living Room",
2265 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2266 )
2267 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2268 sonos_player._cache.clear()
2269
2270 # WiiM player
2271 wiim_player = MockPlayer(
2272 wiim_provider,
2273 "wiim_456",
2274 "Bedroom",
2275 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2276 )
2277 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2278 wiim_player._cache.clear()
2279
2280 # DLNA protocol (does NOT support SET_MEMBERS)
2281 sonos_dlna = MockPlayer(
2282 dlna_provider,
2283 "dlna_sonos",
2284 "Living Room (DLNA)",
2285 player_type=PlayerType.PROTOCOL,
2286 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2287 )
2288 # Note: NO SET_MEMBERS feature
2289
2290 wiim_dlna = MockPlayer(
2291 dlna_provider,
2292 "dlna_wiim",
2293 "Bedroom (DLNA)",
2294 player_type=PlayerType.PROTOCOL,
2295 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2296 )
2297
2298 # AirPlay protocol (DOES support SET_MEMBERS)
2299 sonos_airplay = MockPlayer(
2300 airplay_provider,
2301 "airplay_sonos",
2302 "Living Room (AirPlay)",
2303 player_type=PlayerType.PROTOCOL,
2304 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2305 )
2306 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2307 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2308 sonos_airplay._cache.clear()
2309
2310 wiim_airplay = MockPlayer(
2311 airplay_provider,
2312 "airplay_wiim",
2313 "Bedroom (AirPlay)",
2314 player_type=PlayerType.PROTOCOL,
2315 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2316 )
2317 wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2318 wiim_airplay._attr_can_group_with = {"airplay_sonos"}
2319 wiim_airplay._cache.clear()
2320
2321 # Link protocols (DLNA has lower priority than AirPlay)
2322 sonos_player.set_linked_output_protocols(
2323 [
2324 LinkedOutputProtocol(
2325 output_protocol_id="dlna_sonos",
2326 protocol_domain="dlna",
2327 priority=50, # Lower priority (higher number)
2328 ),
2329 LinkedOutputProtocol(
2330 output_protocol_id="airplay_sonos",
2331 protocol_domain="airplay",
2332 priority=10, # Higher priority (lower number)
2333 ),
2334 ]
2335 )
2336 wiim_player.set_linked_output_protocols(
2337 [
2338 LinkedOutputProtocol(
2339 output_protocol_id="dlna_wiim",
2340 protocol_domain="dlna",
2341 priority=50,
2342 ),
2343 LinkedOutputProtocol(
2344 output_protocol_id="airplay_wiim",
2345 protocol_domain="airplay",
2346 priority=10,
2347 ),
2348 ]
2349 )
2350
2351 mock_mass.players = controller
2352 controller._players = {
2353 "sonos_123": sonos_player,
2354 "wiim_456": wiim_player,
2355 "dlna_sonos": sonos_dlna,
2356 "dlna_wiim": wiim_dlna,
2357 "airplay_sonos": sonos_airplay,
2358 "airplay_wiim": wiim_airplay,
2359 }
2360
2361 # Update state after modifying attributes
2362 sonos_dlna.update_state(signal_event=False)
2363 wiim_dlna.update_state(signal_event=False)
2364 sonos_airplay.update_state(signal_event=False)
2365 wiim_airplay.update_state(signal_event=False)
2366
2367 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
2368 protocol_members, _native_members, protocol_player, protocol_domain = (
2369 controller._translate_members_for_protocols(
2370 parent_player=sonos_player,
2371 player_ids=["wiim_456"],
2372 parent_protocol_player=None,
2373 parent_protocol_domain=None,
2374 )
2375 )
2376
2377 # Should select AirPlay (supports SET_MEMBERS) not DLNA
2378 assert len(protocol_members) == 1
2379 assert "airplay_wiim" in protocol_members
2380 assert protocol_domain == "airplay"
2381 assert protocol_player == sonos_airplay
2382
2383 # A group already on DLNA must be moved off it as well, because DLNA cannot
2384 # group members itself
2385 protocol_members, _native_members, protocol_player, protocol_domain = (
2386 controller._translate_members_for_protocols(
2387 parent_player=sonos_player,
2388 player_ids=["wiim_456"],
2389 parent_protocol_player=sonos_dlna,
2390 parent_protocol_domain="dlna",
2391 )
2392 )
2393
2394 assert protocol_members == ["airplay_wiim"]
2395 assert protocol_domain == "airplay"
2396 assert protocol_player == sonos_airplay
2397
2398
2399class TestJoinActiveNativeSession:
2400 """
2401 Grouping a child onto a parent that is already playing natively.
2402
2403 A child's preferred output protocol must only steer protocol selection when the child
2404 initiates its own playback. When the child joins a parent that already holds an active
2405 native session, it should adopt native grouping if compatible, instead of forcing the
2406 whole group onto the child's preferred protocol.
2407 """
2408
2409 @staticmethod
2410 def _mark_native_playing(player: MockPlayer) -> None:
2411 """Put the player into a live native playback session."""
2412 player._attr_playback_state = PlaybackState.PLAYING
2413 player._cache.clear()
2414 player.set_active_output_protocol("native")
2415
2416 @staticmethod
2417 def _link_airplay(native_player: MockPlayer, airplay_id: str) -> None:
2418 """Link an AirPlay protocol player to a native player."""
2419 native_player.set_linked_output_protocols(
2420 [
2421 LinkedOutputProtocol(
2422 output_protocol_id=airplay_id,
2423 protocol_domain="airplay",
2424 priority=10,
2425 )
2426 ]
2427 )
2428
2429 def _build_topology(
2430 self, mock_mass: MagicMock
2431 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2432 """
2433 Build parent Sonos A + child Sonos B, both AirPlay-capable and natively groupable.
2434
2435 The parent also exposes an AirPlay protocol player that supports SET_MEMBERS, so the
2436 child's preferred AirPlay protocol *would* be selectable if it were given priority.
2437 """
2438 controller = PlayerController(mock_mass)
2439 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2440 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2441
2442 sonos_a = MockPlayer(
2443 sonos_provider,
2444 "sonos_a",
2445 "Office",
2446 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2447 )
2448 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2449 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2450 sonos_a._attr_can_group_with = {"sonos_b"}
2451 sonos_a._cache.clear()
2452
2453 sonos_b = MockPlayer(
2454 sonos_provider,
2455 "sonos_b",
2456 "Bathroom",
2457 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2458 )
2459 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2460 sonos_b._attr_can_group_with = {"sonos_a"}
2461 sonos_b._cache.clear()
2462
2463 airplay_a = MockPlayer(
2464 airplay_provider,
2465 "airplay_a",
2466 "Office (AirPlay)",
2467 player_type=PlayerType.PROTOCOL,
2468 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2469 )
2470 airplay_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2471 airplay_a._attr_can_group_with = {"airplay_b"}
2472 airplay_a._cache.clear()
2473
2474 airplay_b = MockPlayer(
2475 airplay_provider,
2476 "airplay_b",
2477 "Bathroom (AirPlay)",
2478 player_type=PlayerType.PROTOCOL,
2479 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2480 )
2481 airplay_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2482 airplay_b._attr_can_group_with = {"airplay_a"}
2483 airplay_b._cache.clear()
2484
2485 self._link_airplay(sonos_a, "airplay_a")
2486 self._link_airplay(sonos_b, "airplay_b")
2487
2488 mock_mass.players = controller
2489 players = {
2490 "sonos_a": sonos_a,
2491 "sonos_b": sonos_b,
2492 "airplay_a": airplay_a,
2493 "airplay_b": airplay_b,
2494 }
2495 controller._players = dict(players)
2496 for player in players.values():
2497 player.update_state(signal_event=False)
2498 return controller, players
2499
2500 def _add_airplay_only_child(
2501 self, controller: PlayerController, players: dict[str, MockPlayer], mock_mass: MagicMock
2502 ) -> None:
2503 """
2504 Add a WiiM child that can only group via AirPlay (no native grouping with Sonos).
2505
2506 Different provider instance and absent from the parent's can_group_with, so
2507 _can_use_native_grouping returns False for it.
2508 """
2509 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
2510 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2511 wiim_c = MockPlayer(
2512 wiim_provider,
2513 "wiim_c",
2514 "Bedroom",
2515 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2516 )
2517 wiim_c._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2518 wiim_c._cache.clear()
2519 airplay_c = MockPlayer(
2520 airplay_provider,
2521 "airplay_c",
2522 "Bedroom (AirPlay)",
2523 player_type=PlayerType.PROTOCOL,
2524 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2525 )
2526 airplay_c._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2527 airplay_c._attr_can_group_with = {"airplay_a"}
2528 airplay_c._cache.clear()
2529 self._link_airplay(wiim_c, "airplay_c")
2530 # Allow the parent's AirPlay protocol to group with the new AirPlay child.
2531 players["airplay_a"]._attr_can_group_with = {"airplay_b", "airplay_c"}
2532 players["airplay_a"]._cache.clear()
2533 controller._players["wiim_c"] = wiim_c
2534 controller._players["airplay_c"] = airplay_c
2535 wiim_c.update_state(signal_event=False)
2536 airplay_c.update_state(signal_event=False)
2537
2538 def test_native_active_parent_ignores_child_preferred_protocol(
2539 self, mock_mass: MagicMock
2540 ) -> None:
2541 """A child joining a natively-playing parent groups natively, not via preferred AirPlay."""
2542
2543 def _get_raw(
2544 player_id: str, key: str, default: str | int | None = None
2545 ) -> str | int | None:
2546 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2547 return "airplay_b"
2548 if key == "min_volume":
2549 return 0
2550 if key == "max_volume":
2551 return 100
2552 return default
2553
2554 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2555
2556 controller, players = self._build_topology(mock_mass)
2557 # Parent A is already playing natively.
2558 self._mark_native_playing(players["sonos_a"])
2559
2560 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2561 parent_player=players["sonos_a"],
2562 player_ids=["sonos_b"],
2563 parent_protocol_player=None,
2564 parent_protocol_domain=None,
2565 )
2566
2567 # Native grouping is used; the child's AirPlay preference is ignored on join.
2568 assert native_members == ["sonos_b"]
2569 assert protocol_members == []
2570
2571 def test_fresh_group_still_honors_child_preferred_protocol(self, mock_mass: MagicMock) -> None:
2572 """When the parent is not playing, the child's preferred protocol still steers selection."""
2573
2574 def _get_raw(
2575 player_id: str, key: str, default: str | int | None = None
2576 ) -> str | int | None:
2577 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2578 return "airplay_b"
2579 if key == "min_volume":
2580 return 0
2581 if key == "max_volume":
2582 return 100
2583 return default
2584
2585 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2586
2587 controller, players = self._build_topology(mock_mass)
2588 # Parent A has no active session (active_output_protocol stays None).
2589 assert players["sonos_a"].active_output_protocol is None
2590
2591 protocol_members, native_members, _, protocol_domain = (
2592 controller._translate_members_for_protocols(
2593 parent_player=players["sonos_a"],
2594 player_ids=["sonos_b"],
2595 parent_protocol_player=None,
2596 parent_protocol_domain=None,
2597 )
2598 )
2599
2600 # Same topology as the active-native case, but here the preference applies.
2601 assert protocol_members == ["airplay_b"]
2602 assert native_members == []
2603 assert protocol_domain == "airplay"
2604
2605 def test_idle_parent_with_stale_native_protocol_honors_child_preferred(
2606 self, mock_mass: MagicMock
2607 ) -> None:
2608 """A stopped parent (active protocol lingers briefly) does not force the native-join path."""
2609
2610 def _get_raw(
2611 player_id: str, key: str, default: str | int | None = None
2612 ) -> str | int | None:
2613 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2614 return "airplay_b"
2615 if key == "min_volume":
2616 return 0
2617 if key == "max_volume":
2618 return 100
2619 return default
2620
2621 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2622
2623 controller, players = self._build_topology(mock_mass)
2624 # Parent A stopped: active protocol still reads "native" but playback is idle.
2625 players["sonos_a"].set_active_output_protocol("native")
2626 assert players["sonos_a"].state.playback_state == PlaybackState.IDLE
2627
2628 protocol_members, native_members, _, protocol_domain = (
2629 controller._translate_members_for_protocols(
2630 parent_player=players["sonos_a"],
2631 player_ids=["sonos_b"],
2632 parent_protocol_player=None,
2633 parent_protocol_domain=None,
2634 )
2635 )
2636
2637 # No live session to join, so the child's preference applies.
2638 assert protocol_members == ["airplay_b"]
2639 assert native_members == []
2640 assert protocol_domain == "airplay"
2641
2642 def test_native_active_parent_airplay_only_child_uses_protocol(
2643 self, mock_mass: MagicMock
2644 ) -> None:
2645 """An AirPlay-only child (cannot group natively) still joins via its preferred protocol."""
2646
2647 def _get_raw(
2648 player_id: str, key: str, default: str | int | None = None
2649 ) -> str | int | None:
2650 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "wiim_c":
2651 return "airplay_c"
2652 if key == "min_volume":
2653 return 0
2654 if key == "max_volume":
2655 return 100
2656 return default
2657
2658 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2659
2660 controller, players = self._build_topology(mock_mass)
2661 self._add_airplay_only_child(controller, players, mock_mass)
2662 self._mark_native_playing(players["sonos_a"])
2663
2664 protocol_members, native_members, _, protocol_domain = (
2665 controller._translate_members_for_protocols(
2666 parent_player=players["sonos_a"],
2667 player_ids=["wiim_c"],
2668 parent_protocol_player=None,
2669 parent_protocol_domain=None,
2670 )
2671 )
2672
2673 # No native path exists for the WiiM, so it falls back to AirPlay grouping.
2674 assert protocol_members == ["airplay_c"]
2675 assert native_members == []
2676 assert protocol_domain == "airplay"
2677
2678 def test_native_active_parent_protocol_only_child_switches_via_common_protocol(
2679 self, mock_mass: MagicMock
2680 ) -> None:
2681 """
2682 A child with no native path (and no explicit preference) still forces the switch.
2683
2684 Even without a configured preferred protocol, a child that can only reach the group via
2685 a shared protocol (here AirPlay, which the natively-playing parent also supports) is
2686 grouped via that protocol - moving the group onto it.
2687 """
2688 controller, players = self._build_topology(mock_mass)
2689 self._add_airplay_only_child(controller, players, mock_mass)
2690 self._mark_native_playing(players["sonos_a"])
2691
2692 # No preferred_output_protocol override - the WiiM resolves via the common-protocol path.
2693 protocol_members, native_members, _, protocol_domain = (
2694 controller._translate_members_for_protocols(
2695 parent_player=players["sonos_a"],
2696 player_ids=["wiim_c"],
2697 parent_protocol_player=None,
2698 parent_protocol_domain=None,
2699 )
2700 )
2701
2702 assert protocol_members == ["airplay_c"]
2703 assert native_members == []
2704 assert protocol_domain == "airplay"
2705
2706 @pytest.mark.parametrize(
2707 "member_order",
2708 [["wiim_c", "sonos_b"], ["sonos_b", "wiim_c"]],
2709 )
2710 def test_native_active_parent_keeps_mixed_batch_cohesive(
2711 self, mock_mass: MagicMock, member_order: list[str]
2712 ) -> None:
2713 """
2714 A mixed batch stays cohesive on one protocol regardless of member order.
2715
2716 When an AirPlay-only child forces the group onto AirPlay, the natively-groupable Sonos
2717 child adopts that same protocol rather than splitting off into a native sub-group -
2718 whether it is added before or after the AirPlay-only child.
2719 """
2720 controller, players = self._build_topology(mock_mass)
2721 self._add_airplay_only_child(controller, players, mock_mass)
2722 self._mark_native_playing(players["sonos_a"])
2723
2724 protocol_members, native_members, _, protocol_domain = (
2725 controller._translate_members_for_protocols(
2726 parent_player=players["sonos_a"],
2727 player_ids=member_order,
2728 parent_protocol_player=None,
2729 parent_protocol_domain=None,
2730 )
2731 )
2732
2733 assert native_members == []
2734 assert set(protocol_members) == {"airplay_c", "airplay_b"}
2735 assert protocol_domain == "airplay"
2736
2737
2738class TestSessionBoundNativeGrouping:
2739 """
2740 Grouping a parent whose native grouping only works while it renders its own stream.
2741
2742 Such native members are attached to that very stream, so they cannot stay natively
2743 grouped once the group is moved onto one of the parent's output protocols. Parents
2744 with device-side native grouping are unaffected and keep their hybrid groups.
2745 """
2746
2747 @staticmethod
2748 def _link_bridge(player: MockPlayer, bridge_id: str) -> None:
2749 """Link a bridge protocol player to a native player."""
2750 player.set_linked_output_protocols(
2751 [
2752 LinkedOutputProtocol(
2753 output_protocol_id=bridge_id,
2754 protocol_domain="sendspin",
2755 priority=40,
2756 )
2757 ]
2758 )
2759
2760 def _build_topology(
2761 self,
2762 mock_mass: MagicMock,
2763 parent_class: type[MockPlayer],
2764 with_orphan: bool = False,
2765 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2766 """
2767 Build a leader with a natively groupable member and a bridge-only member.
2768
2769 The leader and its member speak the same (speaker) provider, so they group natively.
2770 Both also expose a bridge protocol player, which is the only way to reach the third
2771 member: a native player of the bridge domain.
2772
2773 :param mock_mass: The mocked MusicAssistant instance.
2774 :param parent_class: The player class to build the leader from.
2775 :param with_orphan: Add a second natively groupable member without a bridge protocol.
2776 """
2777 controller = PlayerController(mock_mass)
2778 speaker_provider = MockProvider("speaker", instance_id="speaker_instance", mass=mock_mass)
2779 bridge_provider = MockProvider("sendspin", instance_id="sendspin_instance", mass=mock_mass)
2780
2781 leader = parent_class(speaker_provider, "speaker_leader", "Living Room")
2782 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2783 leader._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2784 leader._cache.clear()
2785
2786 member = MockPlayer(speaker_provider, "speaker_member", "Kitchen")
2787 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2788 member._cache.clear()
2789
2790 bridge_leader = MockPlayer(
2791 bridge_provider,
2792 "bridge_leader",
2793 "Living Room (Bridge)",
2794 player_type=PlayerType.PROTOCOL,
2795 )
2796 bridge_leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2797 bridge_leader._cache.clear()
2798 bridge_leader.set_protocol_parent_id("speaker_leader")
2799
2800 bridge_member = MockPlayer(
2801 bridge_provider,
2802 "bridge_member",
2803 "Kitchen (Bridge)",
2804 player_type=PlayerType.PROTOCOL,
2805 )
2806 bridge_member._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2807 bridge_member._cache.clear()
2808 bridge_member.set_protocol_parent_id("speaker_member")
2809
2810 # native player of the bridge domain: only reachable through the bridge protocol
2811 bridge_only = MockPlayer(bridge_provider, "bridge_only", "Voice Assistant")
2812 bridge_only._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2813 bridge_only._cache.clear()
2814
2815 self._link_bridge(leader, "bridge_leader")
2816 self._link_bridge(member, "bridge_member")
2817
2818 players: dict[str, MockPlayer] = {
2819 "speaker_leader": leader,
2820 "speaker_member": member,
2821 "bridge_leader": bridge_leader,
2822 "bridge_member": bridge_member,
2823 "bridge_only": bridge_only,
2824 }
2825 if with_orphan:
2826 orphan = MockPlayer(speaker_provider, "speaker_orphan", "Bedroom")
2827 orphan._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2828 orphan._cache.clear()
2829 players["speaker_orphan"] = orphan
2830
2831 mock_mass.players = controller
2832 controller._players = dict(players)
2833 for player in players.values():
2834 player.update_state(signal_event=False)
2835 return controller, players
2836
2837 @pytest.mark.parametrize(
2838 "member_order",
2839 [
2840 ["speaker_member", "bridge_only"],
2841 ["bridge_only", "speaker_member"],
2842 ],
2843 )
2844 def test_native_members_follow_the_group_protocol(
2845 self, mock_mass: MagicMock, member_order: list[str]
2846 ) -> None:
2847 """A natively groupable member moves onto the protocol the group ends up on."""
2848 controller, players = self._build_topology(mock_mass, SessionBoundMockPlayer)
2849
2850 protocol_members, native_members, _, protocol_domain = (
2851 controller._translate_members_for_protocols(
2852 parent_player=players["speaker_leader"],
2853 player_ids=member_order,
2854 parent_protocol_player=None,
2855 parent_protocol_domain=None,
2856 )
2857 )
2858
2859 assert native_members == []
2860 assert set(protocol_members) == {"bridge_only", "bridge_member"}
2861 assert protocol_domain == "sendspin"
2862
2863 def test_member_without_the_group_protocol_is_refused(
2864 self, mock_mass: MagicMock, caplog: pytest.LogCaptureFixture
2865 ) -> None:
2866 """A native member that lacks the group's protocol is dropped, the others still move."""
2867 controller, players = self._build_topology(
2868 mock_mass, SessionBoundMockPlayer, with_orphan=True
2869 )
2870
2871 with caplog.at_level(logging.WARNING):
2872 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2873 parent_player=players["speaker_leader"],
2874 player_ids=["speaker_member", "speaker_orphan", "bridge_only"],
2875 parent_protocol_player=None,
2876 parent_protocol_domain=None,
2877 )
2878
2879 assert native_members == []
2880 assert set(protocol_members) == {"bridge_only", "bridge_member"}
2881 assert (
2882 "Cannot group Bedroom with Living Room: the group plays through the sendspin "
2883 "protocol, which Bedroom does not support" in caplog.text
2884 )
2885
2886 def test_device_side_parent_keeps_its_hybrid_group(self, mock_mass: MagicMock) -> None:
2887 """A parent with device-side native grouping keeps its members natively grouped."""
2888 controller, players = self._build_topology(mock_mass, MockPlayer)
2889
2890 protocol_members, native_members, _, protocol_domain = (
2891 controller._translate_members_for_protocols(
2892 parent_player=players["speaker_leader"],
2893 player_ids=["speaker_member", "bridge_only"],
2894 parent_protocol_player=None,
2895 parent_protocol_domain=None,
2896 )
2897 )
2898
2899 assert native_members == ["speaker_member"]
2900 assert protocol_members == ["bridge_only"]
2901 assert protocol_domain == "sendspin"
2902
2903
2904class TestCanGroupWith:
2905 """Tests for can_group_with property with three scenarios."""
2906
2907 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
2908 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
2909 controller = PlayerController(mock_mass)
2910
2911 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2912 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2913
2914 # Create Sonos player with native and AirPlay support
2915 sonos_player = MockPlayer(
2916 sonos_provider,
2917 "sonos_123",
2918 "Living Room",
2919 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2920 )
2921 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2922 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2923 sonos_player._attr_can_group_with = {"sonos_456"}
2924 sonos_player._cache.clear()
2925 sonos_player.set_active_output_protocol("native")
2926
2927 # Create another Sonos player
2928 sonos_player_b = MockPlayer(
2929 sonos_provider,
2930 "sonos_456",
2931 "Kitchen",
2932 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2933 )
2934
2935 # Create AirPlay protocol player
2936 sonos_airplay = MockPlayer(
2937 airplay_provider,
2938 "airplay_sonos",
2939 "Living Room (AirPlay)",
2940 player_type=PlayerType.PROTOCOL,
2941 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2942 )
2943 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2944 sonos_airplay._attr_can_group_with = {"airplay_other"}
2945 sonos_airplay._cache.clear()
2946 sonos_airplay.set_protocol_parent_id("sonos_123")
2947
2948 sonos_player.set_linked_output_protocols(
2949 [
2950 LinkedOutputProtocol(
2951 output_protocol_id="airplay_sonos",
2952 protocol_domain="airplay",
2953 priority=10,
2954 )
2955 ]
2956 )
2957
2958 # Wire up mock_mass.players to controller so player lookups resolve
2959 mock_mass.players = controller
2960
2961 controller._players = {
2962 "sonos_123": sonos_player,
2963 "sonos_456": sonos_player_b,
2964 "airplay_sonos": sonos_airplay,
2965 }
2966
2967 # Update state after modifying attributes and registering with controller
2968 sonos_player.update_state(signal_event=False)
2969 sonos_player_b.update_state(signal_event=False)
2970 sonos_airplay.update_state(signal_event=False)
2971
2972 # Get can_group_with while native is active
2973 groupable = sonos_player.state.can_group_with
2974
2975 # NEW BEHAVIOR: Should show both native AND protocol players
2976 # even when native protocol is active
2977 assert "sonos_456" in groupable # Native Sonos player
2978 # Note: airplay_other is not registered in controller._players, so it won't appear
2979 # But the logic should still allow showing AirPlay options if they were registered
2980
2981 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
2982 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
2983 controller = PlayerController(mock_mass)
2984
2985 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2986 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2987
2988 # Create Sonos player with AirPlay active
2989 sonos_player = MockPlayer(
2990 sonos_provider,
2991 "sonos_123",
2992 "Living Room",
2993 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2994 )
2995 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2996 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2997 sonos_player._attr_can_group_with = {"sonos_456"}
2998 sonos_player._cache.clear()
2999
3000 # Create another Sonos player
3001 sonos_player_b = MockPlayer(
3002 sonos_provider,
3003 "sonos_456",
3004 "Kitchen",
3005 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3006 )
3007
3008 # Create AirPlay protocol player
3009 sonos_airplay = MockPlayer(
3010 airplay_provider,
3011 "airplay_sonos",
3012 "Living Room (AirPlay)",
3013 player_type=PlayerType.PROTOCOL,
3014 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3015 )
3016 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3017 sonos_airplay._attr_can_group_with = {"airplay_other"}
3018 sonos_airplay._cache.clear()
3019 sonos_airplay.set_protocol_parent_id("sonos_123")
3020
3021 # Create another device with AirPlay
3022 wiim_player = MockPlayer(
3023 sonos_provider,
3024 "wiim_789",
3025 "Bedroom",
3026 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3027 )
3028
3029 airplay_other = MockPlayer(
3030 airplay_provider,
3031 "airplay_other",
3032 "Bedroom (AirPlay)",
3033 player_type=PlayerType.PROTOCOL,
3034 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3035 )
3036 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3037 airplay_other._attr_can_group_with = {"airplay_sonos"}
3038 airplay_other._cache.clear()
3039 airplay_other.set_protocol_parent_id("wiim_789")
3040
3041 wiim_player.set_linked_output_protocols(
3042 [
3043 LinkedOutputProtocol(
3044 output_protocol_id="airplay_other",
3045 protocol_domain="airplay",
3046 priority=10,
3047 ),
3048 ]
3049 )
3050
3051 sonos_player.set_linked_output_protocols(
3052 [
3053 LinkedOutputProtocol(
3054 output_protocol_id="airplay_sonos",
3055 protocol_domain="airplay",
3056 priority=10,
3057 )
3058 ]
3059 )
3060 sonos_player.set_active_output_protocol("airplay_sonos")
3061
3062 # Wire up mock_mass.players to controller so player lookups resolve
3063 mock_mass.players = controller
3064
3065 controller._players = {
3066 "sonos_123": sonos_player,
3067 "sonos_456": sonos_player_b,
3068 "wiim_789": wiim_player,
3069 "airplay_sonos": sonos_airplay,
3070 "airplay_other": airplay_other,
3071 }
3072
3073 # Clear cache after setting linked protocols
3074 sonos_player._cache.clear()
3075 wiim_player._cache.clear()
3076
3077 # Refresh state after modifying attributes and registering with controller
3078 # (refresh_state: registration changed cross-player state without the
3079 # production fan-out running, as events are suppressed in this test)
3080 # IMPORTANT: Update protocol players FIRST, then parent players
3081 sonos_airplay.refresh_state(signal_event=False)
3082 airplay_other.refresh_state(signal_event=False)
3083 sonos_player.refresh_state(signal_event=False)
3084 sonos_player_b.refresh_state(signal_event=False)
3085 wiim_player.refresh_state(signal_event=False)
3086
3087 # Get can_group_with while AirPlay is active
3088 groupable = sonos_player.state.can_group_with
3089
3090 # NEW BEHAVIOR: Should show ALL protocols + native players
3091 # regardless of which protocol is active
3092 assert "sonos_456" in groupable # Native Sonos player
3093 assert "wiim_789" in groupable # Via airplay_other protocol
3094
3095 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
3096 """Test Scenario 3: No active output -> show all compatible protocols + native."""
3097 controller = PlayerController(mock_mass)
3098
3099 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3100 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3101 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
3102
3103 # Create Sonos player (no active protocol)
3104 sonos_player = MockPlayer(
3105 sonos_provider,
3106 "sonos_123",
3107 "Living Room",
3108 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3109 )
3110 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3111 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3112 sonos_player._attr_can_group_with = {"sonos_456"}
3113 sonos_player._cache.clear()
3114 # No active output protocol set
3115
3116 # Create another Sonos player
3117 sonos_player_b = MockPlayer(
3118 sonos_provider,
3119 "sonos_456",
3120 "Kitchen",
3121 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3122 )
3123
3124 # Create AirPlay protocol player (supports SET_MEMBERS)
3125 sonos_airplay = MockPlayer(
3126 airplay_provider,
3127 "airplay_sonos",
3128 "Living Room (AirPlay)",
3129 player_type=PlayerType.PROTOCOL,
3130 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3131 )
3132 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3133 sonos_airplay._attr_can_group_with = {"airplay_other"}
3134 sonos_airplay._cache.clear()
3135 sonos_airplay.set_protocol_parent_id("sonos_123")
3136
3137 # Create DLNA protocol player (does NOT support SET_MEMBERS)
3138 sonos_dlna = MockPlayer(
3139 dlna_provider,
3140 "dlna_sonos",
3141 "Living Room (DLNA)",
3142 player_type=PlayerType.PROTOCOL,
3143 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3144 )
3145 # No SET_MEMBERS support
3146 sonos_dlna._attr_can_group_with = {"dlna_other"}
3147 sonos_dlna.set_protocol_parent_id("sonos_123")
3148
3149 # Another device
3150 wiim_player = MockPlayer(
3151 sonos_provider,
3152 "wiim_789",
3153 "Bedroom",
3154 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3155 )
3156
3157 airplay_other = MockPlayer(
3158 airplay_provider,
3159 "airplay_other",
3160 "Bedroom (AirPlay)",
3161 player_type=PlayerType.PROTOCOL,
3162 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3163 )
3164 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3165 airplay_other._attr_can_group_with = {"airplay_sonos"}
3166 airplay_other._cache.clear()
3167 airplay_other.set_protocol_parent_id("wiim_789")
3168
3169 sonos_player.set_linked_output_protocols(
3170 [
3171 LinkedOutputProtocol(
3172 output_protocol_id="airplay_sonos",
3173 protocol_domain="airplay",
3174 priority=10,
3175 ),
3176 LinkedOutputProtocol(
3177 output_protocol_id="dlna_sonos",
3178 protocol_domain="dlna",
3179 priority=50,
3180 ),
3181 ]
3182 )
3183
3184 wiim_player.set_linked_output_protocols(
3185 [
3186 LinkedOutputProtocol(
3187 output_protocol_id="airplay_other",
3188 protocol_domain="airplay",
3189 priority=10,
3190 ),
3191 ]
3192 )
3193
3194 # Clear cache after setting linked protocols (output_protocols is cached)
3195 sonos_player._cache.clear()
3196 wiim_player._cache.clear()
3197
3198 # Wire up mock_mass.players to controller so player lookups resolve
3199 mock_mass.players = controller
3200
3201 controller._players = {
3202 "sonos_123": sonos_player,
3203 "sonos_456": sonos_player_b,
3204 "wiim_789": wiim_player,
3205 "airplay_sonos": sonos_airplay,
3206 "airplay_other": airplay_other,
3207 "dlna_sonos": sonos_dlna,
3208 }
3209
3210 # Update state after modifying attributes and registering with controller
3211 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
3212 # is a MagicMock, we need to manually call update_state
3213 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
3214 # access protocol_player.state.can_group_with during their update_state()
3215 sonos_airplay.update_state(signal_event=False)
3216 airplay_other.update_state(signal_event=False)
3217 sonos_dlna.update_state(signal_event=False)
3218 sonos_player.update_state(signal_event=False)
3219 sonos_player_b.update_state(signal_event=False)
3220 wiim_player.update_state(signal_event=False)
3221
3222 # Get can_group_with with no active protocol
3223 groupable = sonos_player.state.can_group_with
3224
3225 # Should show native players + AirPlay players (supports SET_MEMBERS)
3226 # but NOT DLNA players (no SET_MEMBERS support)
3227 assert "sonos_456" in groupable
3228 assert "wiim_789" in groupable # Via AirPlay protocol
3229 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
3230
3231
3232class TestNativePlayerProtocolGrouping:
3233 """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players."""
3234
3235 def test_native_airplay_player_sees_protocol_players_as_visible_parents(
3236 self, mock_mass: MagicMock
3237 ) -> None:
3238 """Test that a native PLAYER type translates protocol players to visible parents."""
3239 controller = PlayerController(mock_mass)
3240
3241 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3242 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3243
3244 # HomePod: native AirPlay PLAYER (not PROTOCOL)
3245 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
3246 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3247 homepod._attr_can_group_with = {"airplay"} # Provider instance ID
3248 homepod._cache.clear()
3249
3250 # Sonos native player (visible to the user)
3251 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
3252 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3253 sonos_player._cache.clear()
3254
3255 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
3256 sonos_airplay = MockPlayer(
3257 airplay_provider,
3258 "airplay_sonos_1",
3259 "Kitchen (AirPlay)",
3260 player_type=PlayerType.PROTOCOL,
3261 )
3262 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3263 sonos_airplay._attr_can_group_with = {"airplay"}
3264 sonos_airplay._cache.clear()
3265 sonos_airplay.set_protocol_parent_id("sonos_1")
3266
3267 sonos_player.set_linked_output_protocols(
3268 [
3269 LinkedOutputProtocol(
3270 output_protocol_id="airplay_sonos_1",
3271 protocol_domain="airplay",
3272 priority=10,
3273 )
3274 ]
3275 )
3276
3277 mock_mass.players = controller
3278 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
3279
3280 controller._players = {
3281 "homepod_1": homepod,
3282 "sonos_1": sonos_player,
3283 "airplay_sonos_1": sonos_airplay,
3284 }
3285
3286 # Mark players as initialized so they are returned by all_players()
3287 homepod.set_initialized()
3288 sonos_player.set_initialized()
3289 sonos_airplay.set_initialized()
3290
3291 # Update protocol players first, then parents
3292 sonos_airplay.update_state(signal_event=False)
3293 sonos_player.update_state(signal_event=False)
3294 homepod.update_state(signal_event=False)
3295
3296 groupable = homepod.state.can_group_with
3297
3298 # HomePod should see Sonos's VISIBLE player, not the hidden protocol player
3299 assert "sonos_1" in groupable
3300 assert "airplay_sonos_1" not in groupable # Hidden protocol ID must NOT appear
3301
3302 def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None:
3303 """Test that a player with linked AirPlay protocol sees native PLAYER type players."""
3304 controller = PlayerController(mock_mass)
3305
3306 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3307 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3308
3309 # HomePod: native AirPlay PLAYER
3310 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
3311 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3312 homepod._attr_can_group_with = {"airplay"}
3313 homepod._cache.clear()
3314
3315 # Sonos native player (visible to the user)
3316 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
3317 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3318 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3319 sonos_player._attr_can_group_with = set() # No native Sonos grouping peers
3320 sonos_player._cache.clear()
3321
3322 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
3323 sonos_airplay = MockPlayer(
3324 airplay_provider,
3325 "airplay_sonos_1",
3326 "Kitchen (AirPlay)",
3327 player_type=PlayerType.PROTOCOL,
3328 )
3329 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3330 sonos_airplay._attr_can_group_with = {"airplay"} # Provider instance ID
3331 sonos_airplay._cache.clear()
3332 sonos_airplay.set_protocol_parent_id("sonos_1")
3333
3334 sonos_player.set_linked_output_protocols(
3335 [
3336 LinkedOutputProtocol(
3337 output_protocol_id="airplay_sonos_1",
3338 protocol_domain="airplay",
3339 priority=10,
3340 )
3341 ]
3342 )
3343
3344 mock_mass.players = controller
3345 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
3346
3347 controller._players = {
3348 "homepod_1": homepod,
3349 "sonos_1": sonos_player,
3350 "airplay_sonos_1": sonos_airplay,
3351 }
3352
3353 # Mark players as initialized so they are returned by all_players()
3354 homepod.set_initialized()
3355 sonos_player.set_initialized()
3356 sonos_airplay.set_initialized()
3357
3358 # Update protocol players first, then parents
3359 sonos_airplay.update_state(signal_event=False)
3360 homepod.update_state(signal_event=False)
3361 sonos_player.update_state(signal_event=False)
3362
3363 groupable = sonos_player.state.can_group_with
3364
3365 # Sonos should see HomePod via its linked AirPlay protocol's can_group_with
3366 assert "homepod_1" in groupable
3367
3368
3369class TestProtocolSwitchingDuringPlayback:
3370 """Tests for dynamic protocol switching when group members change during playback."""
3371
3372 def _build_session_bound_group(
3373 self, mock_mass: MagicMock, playback_state: PlaybackState
3374 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3375 """
3376 Build a leader that renders its own stream, one native member and a bridge-only player.
3377
3378 :param mock_mass: The mocked MusicAssistant instance.
3379 :param playback_state: The state the leader is in when the switch happens.
3380 :return: The controller and its players by ID.
3381 """
3382 controller = PlayerController(mock_mass)
3383 speaker_provider = MockProvider("speaker", instance_id="speaker_instance", mass=mock_mass)
3384 bridge_provider = MockProvider("sendspin", instance_id="sendspin_instance", mass=mock_mass)
3385
3386 leader = SessionBoundMockPlayer(speaker_provider, "speaker_leader", "Living Room")
3387 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3388 leader._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3389 leader._attr_group_members = ["speaker_leader", "speaker_member"]
3390 leader._attr_playback_state = playback_state
3391 leader._cache.clear()
3392
3393 member = MockPlayer(speaker_provider, "speaker_member", "Kitchen")
3394 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3395 member._cache.clear()
3396
3397 bridge_only = MockPlayer(bridge_provider, "bridge_only", "Voice Assistant")
3398 bridge_only._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3399 bridge_only._cache.clear()
3400
3401 players: dict[str, MockPlayer] = {
3402 "speaker_leader": leader,
3403 "speaker_member": member,
3404 "bridge_only": bridge_only,
3405 }
3406 for parent_id, bridge_id, bridge_name in (
3407 ("speaker_leader", "bridge_leader", "Living Room (Bridge)"),
3408 ("speaker_member", "bridge_member", "Kitchen (Bridge)"),
3409 ):
3410 bridge = MockPlayer(
3411 bridge_provider, bridge_id, bridge_name, player_type=PlayerType.PROTOCOL
3412 )
3413 bridge._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3414 bridge._cache.clear()
3415 bridge.set_protocol_parent_id(parent_id)
3416 players[bridge_id] = bridge
3417 players[parent_id].set_linked_output_protocols(
3418 [
3419 LinkedOutputProtocol(
3420 output_protocol_id=bridge_id,
3421 protocol_domain="sendspin",
3422 priority=40,
3423 )
3424 ]
3425 )
3426
3427 mock_mass.players = controller
3428 controller._players = dict(players)
3429 for registered_player in players.values():
3430 registered_player.update_state(signal_event=False)
3431 leader.set_active_output_protocol("native")
3432 return controller, players
3433
3434 async def test_no_protocol_set_during_grouping_without_playback(
3435 self, mock_mass: MagicMock
3436 ) -> None:
3437 """Test that no protocol is set when grouping players without active playback."""
3438 controller = PlayerController(mock_mass)
3439
3440 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3441 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3442
3443 # Create Sonos player with AirPlay support
3444 sonos_player = MockPlayer(
3445 sonos_provider,
3446 "sonos_123",
3447 "Living Room",
3448 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3449 )
3450 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3451 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3452 sonos_player._attr_can_group_with = {"sonos_456"}
3453
3454 # Create another Sonos player
3455 sonos_player_b = MockPlayer(
3456 sonos_provider,
3457 "sonos_456",
3458 "Kitchen",
3459 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3460 )
3461 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3462
3463 # Create AirPlay protocol player
3464 sonos_airplay = MockPlayer(
3465 airplay_provider,
3466 "airplay_sonos",
3467 "Living Room (AirPlay)",
3468 player_type=PlayerType.PROTOCOL,
3469 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3470 )
3471 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3472 sonos_airplay.set_protocol_parent_id("sonos_123")
3473
3474 sonos_player.set_linked_output_protocols(
3475 [
3476 LinkedOutputProtocol(
3477 output_protocol_id="airplay_sonos",
3478 protocol_domain="airplay",
3479 priority=10,
3480 )
3481 ]
3482 )
3483
3484 mock_mass.players = controller
3485 controller._players = {
3486 "sonos_123": sonos_player,
3487 "sonos_456": sonos_player_b,
3488 "airplay_sonos": sonos_airplay,
3489 }
3490
3491 # Group players via protocol (simulate grouping through AirPlay)
3492 # This should NOT set active_output_protocol anymore
3493 await controller._forward_protocol_set_members(
3494 parent_player=sonos_player,
3495 parent_protocol_player=sonos_airplay,
3496 protocol_members_to_add=["airplay_other"], # Add a protocol member
3497 protocol_members_to_remove=[],
3498 )
3499
3500 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
3501 # After grouping, protocol should not be activated until playback starts
3502 assert sonos_player.active_output_protocol is None
3503
3504 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
3505 """Test that protocol is selected when playback starts, not during grouping."""
3506 controller = PlayerController(mock_mass)
3507
3508 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3509 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3510
3511 # Create Sonos player with AirPlay support
3512 sonos_player = MockPlayer(
3513 sonos_provider,
3514 "sonos_123",
3515 "Living Room",
3516 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3517 )
3518 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3519 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3520
3521 # Create AirPlay protocol player with group members
3522 sonos_airplay = MockPlayer(
3523 airplay_provider,
3524 "airplay_sonos",
3525 "Living Room (AirPlay)",
3526 player_type=PlayerType.PROTOCOL,
3527 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3528 )
3529 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3530 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3531 sonos_airplay.set_protocol_parent_id("sonos_123")
3532 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
3533 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
3534
3535 sonos_player.set_linked_output_protocols(
3536 [
3537 LinkedOutputProtocol(
3538 output_protocol_id="airplay_sonos",
3539 protocol_domain="airplay",
3540 priority=10,
3541 )
3542 ]
3543 )
3544
3545 mock_mass.players = controller
3546 controller._players = {
3547 "sonos_123": sonos_player,
3548 "airplay_sonos": sonos_airplay,
3549 }
3550
3551 # Update state to apply group members to state
3552 sonos_airplay.update_state(signal_event=False)
3553 sonos_player.update_state(signal_event=False)
3554
3555 # Protocol should not be set yet
3556 assert sonos_player.active_output_protocol is None
3557
3558 # Select protocol for playback
3559 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
3560
3561 # Should select AirPlay protocol because it has group members (Priority 1)
3562 assert selected_player == sonos_airplay
3563 assert output_protocol is not None
3564 assert output_protocol.output_protocol_id == "airplay_sonos"
3565
3566 async def test_no_restart_from_handle_set_members(self, mock_mass: MagicMock) -> None:
3567 """
3568 Test that _handle_set_members does NOT restart playback.
3569
3570 Protocol switching and playback restarts are handled in _forward_protocol_set_members,
3571 not in _handle_set_members. This test verifies that _handle_set_members doesn't
3572 trigger any redundant playback restarts.
3573 """
3574 controller = PlayerController(mock_mass)
3575
3576 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3577 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3578
3579 # Create Sonos player currently playing via AirPlay
3580 sonos_player = MockPlayer(
3581 sonos_provider,
3582 "sonos_123",
3583 "Living Room",
3584 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3585 )
3586 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3587 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3588 sonos_player._attr_playback_state = PlaybackState.PLAYING
3589 sonos_player._attr_group_members = ["sonos_123", "sonos_456"]
3590
3591 # Create another Sonos player in the group (member of sonos_123's group)
3592 sonos_player_b = MockPlayer(
3593 sonos_provider,
3594 "sonos_456",
3595 "Kitchen",
3596 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3597 )
3598 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3599 # sonos_player_b's synced_to is derived from group_members, not a direct attribute
3600
3601 # Create AirPlay protocol player (was used for grouping)
3602 sonos_airplay = MockPlayer(
3603 airplay_provider,
3604 "airplay_sonos",
3605 "Living Room (AirPlay)",
3606 player_type=PlayerType.PROTOCOL,
3607 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3608 )
3609 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3610 sonos_airplay.set_protocol_parent_id("sonos_123")
3611
3612 sonos_player.set_linked_output_protocols(
3613 [
3614 LinkedOutputProtocol(
3615 output_protocol_id="airplay_sonos",
3616 protocol_domain="airplay",
3617 priority=10,
3618 )
3619 ]
3620 )
3621
3622 mock_mass.players = controller
3623 controller._players = {
3624 "sonos_123": sonos_player,
3625 "sonos_456": sonos_player_b,
3626 "airplay_sonos": sonos_airplay,
3627 }
3628
3629 # Update state and set active output protocol AFTER registering with controller
3630 sonos_player.update_state(signal_event=False)
3631 sonos_player_b.update_state(signal_event=False)
3632 sonos_airplay.update_state(signal_event=False)
3633
3634 # Set active output protocol (must be done after controller is set up)
3635 sonos_player.set_active_output_protocol("airplay_sonos")
3636
3637 # Track if cmd_resume was called
3638 resume_called = False
3639
3640 async def mock_cmd_resume(
3641 player_id: str, # noqa: ARG001
3642 source: str | None = None, # noqa: ARG001
3643 media: PlayerMedia | None = None, # noqa: ARG001
3644 ) -> None:
3645 nonlocal resume_called
3646 resume_called = True
3647
3648 controller.cmd_resume = mock_cmd_resume # type: ignore[method-assign]
3649
3650 # Remove member - now only the parent player is left
3651 # After removal, _select_best_output_protocol would return native
3652 sonos_player._attr_group_members = ["sonos_123"]
3653 sonos_player._cache.clear()
3654
3655 # Call _handle_set_members directly to trigger the protocol change check
3656 await controller._handle_set_members(
3657 sonos_player,
3658 player_ids_to_add=None,
3659 player_ids_to_remove=["sonos_456"],
3660 )
3661
3662 # Playback should NOT have been restarted because we're going back to native
3663 assert not resume_called, "cmd_resume should not be called when switching to native"
3664
3665 @pytest.mark.parametrize("playback_state", [PlaybackState.PLAYING, PlaybackState.PAUSED])
3666 async def test_native_session_is_stopped_and_members_migrate(
3667 self, mock_mass: MagicMock, playback_state: PlaybackState
3668 ) -> None:
3669 """
3670 A parent that renders its own stream hands its native members over to the new protocol.
3671
3672 The members join the protocol group in the same call as the new member, after which
3673 the native session releases them and stops. Playback resumes only for a player that
3674 was playing, so adding a member to a paused group does not start it.
3675 """
3676 controller, players = self._build_session_bound_group(mock_mass, playback_state)
3677 leader = players["speaker_leader"]
3678 bridge_leader = players["bridge_leader"]
3679
3680 # record the sequence of provider level calls the switch performs
3681 calls: list[tuple[str, Any]] = []
3682 leader_set_members = leader.set_members
3683 bridge_set_members = bridge_leader.set_members
3684
3685 async def record_leader_set_members(
3686 player_ids_to_add: list[str] | None = None,
3687 player_ids_to_remove: list[str] | None = None,
3688 ) -> None:
3689 calls.append(("leader_set_members", player_ids_to_remove))
3690 await leader_set_members(player_ids_to_add, player_ids_to_remove)
3691
3692 async def record_bridge_set_members(
3693 player_ids_to_add: list[str] | None = None,
3694 player_ids_to_remove: list[str] | None = None,
3695 ) -> None:
3696 calls.append(("bridge_set_members", player_ids_to_add))
3697 await bridge_set_members(player_ids_to_add, player_ids_to_remove)
3698
3699 async def record_leader_stop() -> None:
3700 calls.append(("leader_stop", None))
3701
3702 leader.set_members = record_leader_set_members # type: ignore[method-assign]
3703 leader.stop = record_leader_stop # type: ignore[method-assign]
3704 bridge_leader.set_members = record_bridge_set_members # type: ignore[method-assign]
3705 controller.cmd_resume = AsyncMock() # type: ignore[method-assign]
3706 controller._handle_set_members = AsyncMock() # type: ignore[method-assign]
3707
3708 await controller._forward_protocol_set_members(
3709 parent_player=leader,
3710 parent_protocol_player=bridge_leader,
3711 protocol_members_to_add=["bridge_only"],
3712 protocol_members_to_remove=[],
3713 )
3714
3715 assert calls == [
3716 # the native member joins the protocol group together with the new member
3717 ("bridge_set_members", ["bridge_only", "bridge_member"]),
3718 ("leader_set_members", ["speaker_member"]),
3719 ("leader_stop", None),
3720 ]
3721 assert leader.active_output_protocol == "bridge_leader"
3722 # Only a player that was playing resumes: adding a member to a paused group
3723 # hands the output over without starting playback.
3724 if playback_state == PlaybackState.PLAYING:
3725 controller.cmd_resume.assert_awaited_once_with("speaker_leader")
3726 else:
3727 controller.cmd_resume.assert_not_awaited()
3728 controller._handle_set_members.assert_not_awaited()
3729
3730 async def test_joining_member_protocol_is_active_before_its_stream_starts(
3731 self, mock_mass: MagicMock
3732 ) -> None:
3733 """
3734 A joining member's parent knows which protocol carries its audio before its stream starts.
3735
3736 The member's stream is started from inside set_members, and providers resolve the
3737 member's volume control while starting it. A device that also exposes a higher
3738 priority sibling interface (e.g. a soundbar that is both Chromecast and AirPlay)
3739 would otherwise resolve volume to that sibling and apply its volume semantics to
3740 a stream it does not attenuate.
3741 """
3742 controller = PlayerController(mock_mass)
3743
3744 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3745 cast_provider = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
3746
3747 leader = MockPlayer(airplay_provider, "leader", "Kitchen")
3748 leader_airplay = MockPlayer(
3749 airplay_provider,
3750 "leader_airplay",
3751 "Kitchen (AirPlay)",
3752 player_type=PlayerType.PROTOCOL,
3753 )
3754 leader_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3755 leader_airplay.set_protocol_parent_id("leader")
3756
3757 # the joining device exposes both a Chromecast and an AirPlay interface, and has no
3758 # native volume of its own, so its volume control is one of the two protocol players
3759 joiner = MockPlayer(cast_provider, "joiner", "Living Room")
3760 joiner._attr_supported_features = set()
3761 joiner_cast = MockPlayer(
3762 cast_provider,
3763 "joiner_cast",
3764 "Living Room (Cast)",
3765 player_type=PlayerType.PROTOCOL,
3766 )
3767 joiner_cast.set_protocol_parent_id("joiner")
3768 joiner_airplay = MockPlayer(
3769 airplay_provider,
3770 "joiner_airplay",
3771 "Living Room (AirPlay)",
3772 player_type=PlayerType.PROTOCOL,
3773 )
3774 joiner_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3775 joiner_airplay.set_protocol_parent_id("joiner")
3776
3777 leader.set_linked_output_protocols(
3778 [
3779 LinkedOutputProtocol(
3780 output_protocol_id="leader_airplay",
3781 protocol_domain="airplay",
3782 priority=10,
3783 )
3784 ]
3785 )
3786 joiner.set_linked_output_protocols(
3787 [
3788 LinkedOutputProtocol(
3789 output_protocol_id="joiner_cast",
3790 protocol_domain="chromecast",
3791 priority=10,
3792 ),
3793 LinkedOutputProtocol(
3794 output_protocol_id="joiner_airplay",
3795 protocol_domain="airplay",
3796 priority=20,
3797 ),
3798 ]
3799 )
3800
3801 mock_mass.players = controller
3802 controller._players = {
3803 player.player_id: player
3804 for player in (leader, leader_airplay, joiner, joiner_cast, joiner_airplay)
3805 }
3806 for player in controller._players.values():
3807 player.update_state(signal_event=False)
3808
3809 # While the device sits idle its Chromecast interface owns the volume: the winner is
3810 # decided by the hardcoded per-domain control priority, not by the link priorities.
3811 assert joiner.volume_control == "joiner_cast"
3812
3813 observed_volume_controls: list[str] = []
3814
3815 async def record_set_members(
3816 player_ids_to_add: list[str] | None = None, # noqa: ARG001
3817 player_ids_to_remove: list[str] | None = None, # noqa: ARG001
3818 ) -> None:
3819 observed_volume_controls.append(joiner.volume_control)
3820
3821 leader_airplay.set_members = record_set_members # type: ignore[method-assign]
3822 controller._handle_set_members = AsyncMock() # type: ignore[method-assign]
3823
3824 await controller._forward_protocol_set_members(
3825 parent_player=leader,
3826 parent_protocol_player=leader_airplay,
3827 protocol_members_to_add=["joiner_airplay"],
3828 protocol_members_to_remove=[],
3829 )
3830
3831 assert observed_volume_controls == ["joiner_airplay"]
3832
3833
3834class TestNativeProtocolPlayerGrouping:
3835 """Tests for grouping with native protocol players (e.g., native AirPlay like Apple TV)."""
3836
3837 def test_native_airplay_groups_with_protocol_linked_player(self, mock_mass: MagicMock) -> None:
3838 """
3839 Test grouping a native AirPlay player (Apple TV) with a protocol-linked player (Sonos).
3840
3841 This tests the scenario where:
3842 - Apple TV is a native AirPlay PLAYER (not PROTOCOL type)
3843 - Sonos has AirPlay as a linked protocol
3844 - Apple TV groups with Sonos via the common AirPlay protocol
3845 """
3846 controller = PlayerController(mock_mass)
3847
3848 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3849 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3850
3851 # Apple TV: native AirPlay PLAYER (supports grouping via AirPlay)
3852 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV Slaapkamer")
3853 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3854 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3855 apple_tv._attr_can_group_with = {"airplay"} # Provider instance ID
3856 apple_tv._cache.clear()
3857
3858 # Sonos native player (visible)
3859 sonos_player = MockPlayer(sonos_provider, "sonos_badkamer", "Badkamer")
3860 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3861 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3862 sonos_player._cache.clear()
3863
3864 # AirPlay protocol player for Sonos (hidden, linked to sonos)
3865 sonos_airplay = MockPlayer(
3866 airplay_provider,
3867 "airplay_sonos",
3868 "Badkamer (AirPlay)",
3869 player_type=PlayerType.PROTOCOL,
3870 )
3871 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3872 sonos_airplay._attr_can_group_with = {"airplay"}
3873 sonos_airplay._cache.clear()
3874 sonos_airplay.set_protocol_parent_id("sonos_badkamer")
3875
3876 sonos_player.set_linked_output_protocols(
3877 [
3878 LinkedOutputProtocol(
3879 output_protocol_id="airplay_sonos",
3880 protocol_domain="airplay",
3881 priority=10,
3882 )
3883 ]
3884 )
3885
3886 mock_mass.players = controller
3887 controller._players = {
3888 "apple_tv_1": apple_tv,
3889 "sonos_badkamer": sonos_player,
3890 "airplay_sonos": sonos_airplay,
3891 }
3892
3893 # Update states
3894 sonos_airplay.update_state(signal_event=False)
3895 sonos_player.update_state(signal_event=False)
3896 apple_tv.update_state(signal_event=False)
3897
3898 # Translate members for grouping Sonos to Apple TV
3899 protocol_members, _native_members, protocol_player, protocol_domain = (
3900 controller._translate_members_for_protocols(
3901 parent_player=apple_tv,
3902 player_ids=["sonos_badkamer"],
3903 parent_protocol_player=None,
3904 parent_protocol_domain=None,
3905 )
3906 )
3907
3908 # Should find common AirPlay protocol
3909 assert len(protocol_members) == 1
3910 assert "airplay_sonos" in protocol_members
3911 assert protocol_domain == "airplay"
3912 # For native AirPlay player, protocol_player should be the Apple TV itself
3913 assert protocol_player == apple_tv
3914
3915 def test_get_output_protocol_by_domain_finds_native(self, mock_mass: MagicMock) -> None:
3916 """Test that get_output_protocol_by_domain finds native protocol."""
3917 controller = PlayerController(mock_mass)
3918
3919 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3920
3921 # Native AirPlay player
3922 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
3923 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3924 apple_tv._cache.clear()
3925
3926 mock_mass.players = controller
3927 controller._players = {"apple_tv_1": apple_tv}
3928
3929 apple_tv.update_state(signal_event=False)
3930
3931 # Should find native AirPlay protocol
3932 protocol = apple_tv.get_output_protocol_by_domain("airplay")
3933 assert protocol is not None
3934 assert protocol.output_protocol_id == "native"
3935 assert protocol.protocol_domain == "airplay"
3936 assert protocol.is_native is True
3937
3938
3939class TestFinalGroupMembersTranslation:
3940 """Tests for __final_group_members translation of protocol player IDs."""
3941
3942 def test_final_group_members_translates_protocol_ids(self, mock_mass: MagicMock) -> None:
3943 """
3944 Test that __final_group_members translates protocol player IDs to visible IDs.
3945
3946 When a native AirPlay player (Apple TV) has protocol players in its group_members,
3947 the final state should show the visible parent player IDs instead.
3948 """
3949 controller = PlayerController(mock_mass)
3950
3951 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3952 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3953
3954 # Apple TV with group members containing a protocol player ID
3955 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
3956 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3957 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3958 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
3959 apple_tv._cache.clear()
3960
3961 # Sonos visible player
3962 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
3963 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3964 sonos_player._cache.clear()
3965
3966 # AirPlay protocol player for Sonos
3967 sonos_airplay = MockPlayer(
3968 airplay_provider,
3969 "airplay_sonos",
3970 "Sonos (AirPlay)",
3971 player_type=PlayerType.PROTOCOL,
3972 )
3973 sonos_airplay._cache.clear()
3974 sonos_airplay.set_protocol_parent_id("sonos_1")
3975
3976 mock_mass.players = controller
3977 controller._players = {
3978 "apple_tv_1": apple_tv,
3979 "sonos_1": sonos_player,
3980 "airplay_sonos": sonos_airplay,
3981 }
3982
3983 sonos_airplay.update_state(signal_event=False)
3984 sonos_player.update_state(signal_event=False)
3985 apple_tv.update_state(signal_event=False)
3986
3987 # Final group_members should show visible player IDs
3988 final_members = apple_tv.state.group_members
3989 assert "apple_tv_1" in final_members
3990 assert "sonos_1" in final_members
3991 # Protocol player ID should NOT appear in final state
3992 assert "airplay_sonos" not in final_members
3993
3994
3995class TestFinalActiveGroupTranslation:
3996 """Tests for __final_active_group with translated protocol player IDs."""
3997
3998 def test_active_group_uses_translated_group_members(self, mock_mass: MagicMock) -> None:
3999 """Test that visible players resolve active_group from translated group members."""
4000 controller = PlayerController(mock_mass)
4001
4002 group_provider = MockProvider(
4003 "universal_group", instance_id="universal_group", mass=mock_mass
4004 )
4005 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4006 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4007
4008 group_player = MockPlayer(
4009 group_provider,
4010 "group_1",
4011 "House Group",
4012 player_type=PlayerType.GROUP,
4013 )
4014 group_player._attr_playback_state = PlaybackState.PLAYING
4015 group_player._attr_group_members = ["airplay_sonos"]
4016 group_player._cache.clear()
4017
4018 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4019 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4020 sonos_player._cache.clear()
4021
4022 sonos_airplay = MockPlayer(
4023 airplay_provider,
4024 "airplay_sonos",
4025 "Sonos (AirPlay)",
4026 player_type=PlayerType.PROTOCOL,
4027 )
4028 sonos_airplay.set_protocol_parent_id("sonos_1")
4029 sonos_airplay._cache.clear()
4030
4031 mock_mass.players = controller
4032 controller._players = {
4033 "group_1": group_player,
4034 "sonos_1": sonos_player,
4035 "airplay_sonos": sonos_airplay,
4036 }
4037
4038 group_player.set_initialized()
4039 sonos_player.set_initialized()
4040 sonos_airplay.set_initialized()
4041
4042 group_player.update_state(signal_event=False)
4043 sonos_airplay.update_state(signal_event=False)
4044 sonos_player.update_state(signal_event=False)
4045
4046 assert sonos_player.state.active_group == "group_1"
4047 assert sonos_airplay.state.active_group is None
4048
4049
4050class TestFinalSyncedToWithNativeProtocolParent:
4051 """Tests for __final_synced_to when sync parent is a native protocol player."""
4052
4053 def test_synced_to_native_airplay_player(self, mock_mass: MagicMock) -> None:
4054 """
4055 Test that synced_to correctly shows native AirPlay player as parent.
4056
4057 When a Sonos player's AirPlay protocol player is synced to a native AirPlay
4058 player (Apple TV), the Sonos's final synced_to should show the Apple TV.
4059 """
4060 controller = PlayerController(mock_mass)
4061
4062 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4063 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4064
4065 # Apple TV: native AirPlay PLAYER (the group leader)
4066 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4067 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4068 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4069 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4070 apple_tv._cache.clear()
4071
4072 # Sonos visible player
4073 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4074 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4075 sonos_player._cache.clear()
4076
4077 # AirPlay protocol player for Sonos - synced to Apple TV
4078 sonos_airplay = MockPlayer(
4079 airplay_provider,
4080 "airplay_sonos",
4081 "Sonos (AirPlay)",
4082 player_type=PlayerType.PROTOCOL,
4083 )
4084 # Set group_members with Apple TV first to indicate synced_to Apple TV
4085 sonos_airplay._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4086 sonos_airplay._cache.clear()
4087 sonos_airplay.set_protocol_parent_id("sonos_1")
4088
4089 sonos_player.set_linked_output_protocols(
4090 [
4091 LinkedOutputProtocol(
4092 output_protocol_id="airplay_sonos",
4093 protocol_domain="airplay",
4094 priority=10,
4095 )
4096 ]
4097 )
4098
4099 mock_mass.players = controller
4100 controller._players = {
4101 "apple_tv_1": apple_tv,
4102 "sonos_1": sonos_player,
4103 "airplay_sonos": sonos_airplay,
4104 }
4105
4106 apple_tv.set_initialized()
4107 sonos_player.set_initialized()
4108 sonos_airplay.set_initialized()
4109
4110 apple_tv.update_state(signal_event=False)
4111 sonos_airplay.update_state(signal_event=False)
4112 sonos_player.update_state(signal_event=False)
4113
4114 # Sonos's final synced_to should be Apple TV (visible player)
4115 assert sonos_player.state.synced_to == "apple_tv_1"
4116
4117
4118class TestUngroupTranslation:
4119 """Tests for translation when ungrouping from native protocol players."""
4120
4121 def test_ungroup_translates_visible_to_protocol_id(self, mock_mass: MagicMock) -> None:
4122 """
4123 Test that ungrouping correctly translates visible ID to protocol ID.
4124
4125 When ungrouping Sonos from Apple TV, the visible Sonos ID should be
4126 translated to its AirPlay protocol player ID for the removal.
4127 """
4128 controller = PlayerController(mock_mass)
4129
4130 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4131 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4132
4133 # Apple TV with Sonos's AirPlay protocol player in group_members
4134 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4135 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4136 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4137 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4138 apple_tv._cache.clear()
4139
4140 # Sonos visible player
4141 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4142 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4143 sonos_player._cache.clear()
4144
4145 # AirPlay protocol player for Sonos
4146 sonos_airplay = MockPlayer(
4147 airplay_provider,
4148 "airplay_sonos",
4149 "Sonos (AirPlay)",
4150 player_type=PlayerType.PROTOCOL,
4151 )
4152 sonos_airplay._cache.clear()
4153 sonos_airplay.set_protocol_parent_id("sonos_1")
4154
4155 sonos_player.set_linked_output_protocols(
4156 [
4157 LinkedOutputProtocol(
4158 output_protocol_id="airplay_sonos",
4159 protocol_domain="airplay",
4160 priority=10,
4161 )
4162 ]
4163 )
4164
4165 mock_mass.players = controller
4166 controller._players = {
4167 "apple_tv_1": apple_tv,
4168 "sonos_1": sonos_player,
4169 "airplay_sonos": sonos_airplay,
4170 }
4171
4172 sonos_airplay.update_state(signal_event=False)
4173 sonos_player.update_state(signal_event=False)
4174 apple_tv.update_state(signal_event=False)
4175
4176 # Translate members for removal - visible ID should become protocol ID
4177 _protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
4178 parent_player=apple_tv,
4179 player_ids=["sonos_1"], # Visible player ID
4180 parent_protocol_player=None,
4181 parent_protocol_domain=None,
4182 )
4183
4184 # Should translate to the protocol player ID for native removal
4185 assert "airplay_sonos" in native_members
4186 assert "sonos_1" not in native_members
4187
4188
4189class TestNativeProtocolDomainPlayerGrouping:
4190 """
4191 Tests for grouping with native protocol-domain players.
4192
4193 This tests the scenario where a player's native provider domain IS the protocol
4194 domain (e.g., a sendspin web player with PlayerType.PLAYER and provider.domain="sendspin"),
4195 rather than having the protocol as a linked protocol player.
4196 """
4197
4198 def test_native_protocol_player_groups_via_active_protocol(self, mock_mass: MagicMock) -> None:
4199 """
4200 Test grouping a native protocol player via the parent's active protocol.
4201
4202 Scenario: Kantoor (has sendspin linked protocol, active) groups with
4203 Web player (native sendspin PlayerType.PLAYER).
4204 This should use Priority 2 (parent's active protocol).
4205 """
4206 controller = PlayerController(mock_mass)
4207
4208 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4209 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4210
4211 # Kantoor: native Sonos player with sendspin as linked protocol
4212 kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4213 kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4214 kantoor._cache.clear()
4215
4216 # Sendspin protocol player linked to Kantoor
4217 sendspin_kantoor = MockPlayer(
4218 sendspin_provider,
4219 "sendspin_kantoor",
4220 "Kantoor (Sendspin)",
4221 player_type=PlayerType.PROTOCOL,
4222 )
4223 sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4224 sendspin_kantoor._attr_can_group_with = {"sendspin"}
4225 sendspin_kantoor._cache.clear()
4226 sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4227
4228 # Web player: native sendspin player (PlayerType.PLAYER, standalone)
4229 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4230 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4231 web_player._cache.clear()
4232
4233 # Link sendspin protocol to Kantoor
4234 kantoor.set_linked_output_protocols(
4235 [
4236 LinkedOutputProtocol(
4237 output_protocol_id="sendspin_kantoor",
4238 protocol_domain="sendspin",
4239 priority=40,
4240 )
4241 ]
4242 )
4243
4244 mock_mass.players = controller
4245 controller._players = {
4246 "sonos_kantoor": kantoor,
4247 "sendspin_kantoor": sendspin_kantoor,
4248 "sendspin_web": web_player,
4249 }
4250
4251 # Update states
4252 sendspin_kantoor.update_state(signal_event=False)
4253 kantoor.update_state(signal_event=False)
4254 web_player.update_state(signal_event=False)
4255
4256 # Group Web player with Kantoor, with sendspin as active protocol
4257 protocol_members, native_members, protocol_player, _ = (
4258 controller._translate_members_for_protocols(
4259 parent_player=kantoor,
4260 player_ids=["sendspin_web"],
4261 parent_protocol_player=sendspin_kantoor,
4262 parent_protocol_domain="sendspin",
4263 )
4264 )
4265
4266 # Web player's own player_id should be in protocol_members
4267 assert len(protocol_members) == 1
4268 assert "sendspin_web" in protocol_members
4269 assert len(native_members) == 0
4270 assert protocol_player == sendspin_kantoor
4271
4272 def test_native_protocol_player_groups_via_common_protocol(self, mock_mass: MagicMock) -> None:
4273 """
4274 Test grouping a native protocol player via common protocol search (Priority 4).
4275
4276 Same scenario but without a pre-set active protocol — the common protocol
4277 search should find sendspin as the shared protocol.
4278 """
4279 controller = PlayerController(mock_mass)
4280
4281 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4282 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4283
4284 # Kantoor: native Sonos player with sendspin as linked protocol
4285 kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4286 kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4287 kantoor._cache.clear()
4288
4289 # Sendspin protocol player linked to Kantoor
4290 sendspin_kantoor = MockPlayer(
4291 sendspin_provider,
4292 "sendspin_kantoor",
4293 "Kantoor (Sendspin)",
4294 player_type=PlayerType.PROTOCOL,
4295 )
4296 sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4297 sendspin_kantoor._attr_can_group_with = {"sendspin"}
4298 sendspin_kantoor._cache.clear()
4299 sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4300
4301 # Web player: native sendspin player (PlayerType.PLAYER, standalone)
4302 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4303 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4304 web_player._cache.clear()
4305
4306 # Link sendspin protocol to Kantoor
4307 kantoor.set_linked_output_protocols(
4308 [
4309 LinkedOutputProtocol(
4310 output_protocol_id="sendspin_kantoor",
4311 protocol_domain="sendspin",
4312 priority=40,
4313 )
4314 ]
4315 )
4316
4317 mock_mass.players = controller
4318 controller._players = {
4319 "sonos_kantoor": kantoor,
4320 "sendspin_kantoor": sendspin_kantoor,
4321 "sendspin_web": web_player,
4322 }
4323
4324 # Update states
4325 sendspin_kantoor.update_state(signal_event=False)
4326 kantoor.update_state(signal_event=False)
4327 web_player.update_state(signal_event=False)
4328
4329 # Group Web player with Kantoor, without pre-set active protocol
4330 protocol_members, native_members, protocol_player, protocol_domain = (
4331 controller._translate_members_for_protocols(
4332 parent_player=kantoor,
4333 player_ids=["sendspin_web"],
4334 parent_protocol_player=None,
4335 parent_protocol_domain=None,
4336 )
4337 )
4338
4339 # Should find common sendspin protocol via Priority 4
4340 assert len(protocol_members) == 1
4341 assert "sendspin_web" in protocol_members
4342 assert len(native_members) == 0
4343 assert protocol_domain == "sendspin"
4344 assert protocol_player == sendspin_kantoor
4345
4346 def test_ungroup_native_protocol_player(self, mock_mass: MagicMock) -> None:
4347 """
4348 Test ungrouping a native protocol player from a protocol-linked parent.
4349
4350 When ungrouping a native sendspin web player from Kantoor's sendspin group,
4351 the web player's own player_id should be used for removal.
4352 """
4353 controller = PlayerController(mock_mass)
4354
4355 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4356 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4357
4358 # Kantoor with sendspin linked protocol
4359 kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4360 kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4361 kantoor._cache.clear()
4362
4363 # Sendspin protocol player linked to Kantoor, with web player in group
4364 sendspin_kantoor = MockPlayer(
4365 sendspin_provider,
4366 "sendspin_kantoor",
4367 "Kantoor (Sendspin)",
4368 player_type=PlayerType.PROTOCOL,
4369 )
4370 sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4371 sendspin_kantoor._attr_group_members = ["sendspin_kantoor", "sendspin_web"]
4372 sendspin_kantoor._cache.clear()
4373 sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4374
4375 # Web player: native sendspin player
4376 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4377 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4378 web_player._cache.clear()
4379
4380 kantoor.set_linked_output_protocols(
4381 [
4382 LinkedOutputProtocol(
4383 output_protocol_id="sendspin_kantoor",
4384 protocol_domain="sendspin",
4385 priority=40,
4386 )
4387 ]
4388 )
4389
4390 mock_mass.players = controller
4391 controller._players = {
4392 "sonos_kantoor": kantoor,
4393 "sendspin_kantoor": sendspin_kantoor,
4394 "sendspin_web": web_player,
4395 }
4396
4397 sendspin_kantoor.update_state(signal_event=False)
4398 kantoor.update_state(signal_event=False)
4399 web_player.update_state(signal_event=False)
4400
4401 # Translate removal — web player's own player_id should be used
4402 protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
4403 parent_player=kantoor,
4404 player_ids=["sendspin_web"],
4405 parent_protocol_player=sendspin_kantoor,
4406 parent_protocol_domain="sendspin",
4407 )
4408
4409 # Web player's player_id should be in protocol removal list
4410 assert "sendspin_web" in protocol_members
4411 assert len(native_members) == 0
4412
4413 def test_filter_protocol_members_accepts_native_protocol_player(
4414 self, mock_mass: MagicMock
4415 ) -> None:
4416 """
4417 Test that _filter_protocol_members accepts native protocol-domain players.
4418
4419 A PlayerType.PLAYER with matching provider domain should pass through the
4420 filter, not just PlayerType.PROTOCOL players.
4421 """
4422 controller = PlayerController(mock_mass)
4423
4424 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4425
4426 # Protocol player (the parent's linked sendspin)
4427 sendspin_parent = MockPlayer(
4428 sendspin_provider,
4429 "sendspin_parent",
4430 "Parent (Sendspin)",
4431 player_type=PlayerType.PROTOCOL,
4432 )
4433 sendspin_parent._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4434 sendspin_parent._cache.clear()
4435
4436 # Native sendspin web player (PlayerType.PLAYER)
4437 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4438 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4439 web_player._cache.clear()
4440
4441 # Another protocol type sendspin player
4442 sendspin_other = MockPlayer(
4443 sendspin_provider,
4444 "sendspin_other",
4445 "Other (Sendspin)",
4446 player_type=PlayerType.PROTOCOL,
4447 )
4448 sendspin_other._cache.clear()
4449
4450 mock_mass.players = controller
4451 controller._players = {
4452 "sendspin_parent": sendspin_parent,
4453 "sendspin_web": web_player,
4454 "sendspin_other": sendspin_other,
4455 }
4456
4457 sendspin_parent.update_state(signal_event=False)
4458 web_player.update_state(signal_event=False)
4459 sendspin_other.update_state(signal_event=False)
4460
4461 # Both native and protocol players should pass through the filter
4462 filtered = controller._filter_protocol_members(
4463 ["sendspin_web", "sendspin_other"],
4464 sendspin_parent,
4465 )
4466
4467 assert "sendspin_web" in filtered
4468 assert "sendspin_other" in filtered
4469 assert len(filtered) == 2
4470
4471 def test_sendspin_visualizer_groups_via_common_protocol(self, mock_mass: MagicMock) -> None:
4472 """
4473 Test grouping a sendspin visualizer (e.g. Hue light) with a sendspin-bridged parent.
4474
4475 Visualizer players have no PLAY_MEDIA so they are not native players, but the
4476 Sendspin provider advertises a self-referential sendspin output protocol on them
4477 (is_native=True) so the common-protocol search (Priority 4) can match them against
4478 any parent that has a sendspin bridge linked.
4479 """
4480 controller = PlayerController(mock_mass)
4481
4482 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4483 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4484
4485 # Mancave: native Sonos parent with sendspin bridge linked
4486 mancave = MockPlayer(sonos_provider, "sonos_mancave", "Mancave")
4487 mancave._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4488 mancave._cache.clear()
4489
4490 # Sendspin bridge protocol player linked to Mancave
4491 sendspin_bridge = MockPlayer(
4492 sendspin_provider,
4493 "spb_mancave",
4494 "Mancave (AirPlay)",
4495 player_type=PlayerType.PROTOCOL,
4496 )
4497 sendspin_bridge._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4498 sendspin_bridge._attr_can_group_with = {"sendspin"}
4499 sendspin_bridge._cache.clear()
4500 sendspin_bridge.set_protocol_parent_id("sonos_mancave")
4501
4502 # Hue visualizer: sendspin provider, no PLAY_MEDIA, has SET_MEMBERS, type LIGHT
4503 hue_light = MockPlayer(
4504 sendspin_provider,
4505 "hue-mancave",
4506 "Hue: Mancave",
4507 player_type=PlayerType.LIGHT,
4508 )
4509 hue_light._attr_supported_features = {PlayerFeature.SET_MEMBERS}
4510 hue_light._cache.clear()
4511
4512 mancave.set_linked_output_protocols(
4513 [
4514 LinkedOutputProtocol(
4515 output_protocol_id="spb_mancave",
4516 protocol_domain="sendspin",
4517 priority=40,
4518 )
4519 ]
4520 )
4521
4522 mock_mass.players = controller
4523 controller._players = {
4524 "sonos_mancave": mancave,
4525 "spb_mancave": sendspin_bridge,
4526 "hue-mancave": hue_light,
4527 }
4528
4529 sendspin_bridge.update_state(signal_event=False)
4530 mancave.update_state(signal_event=False)
4531 hue_light.update_state(signal_event=False)
4532
4533 protocol_members, native_members, protocol_player, protocol_domain = (
4534 controller._translate_members_for_protocols(
4535 parent_player=mancave,
4536 player_ids=["hue-mancave"],
4537 parent_protocol_player=None,
4538 parent_protocol_domain=None,
4539 )
4540 )
4541
4542 # Priority 4 should match sendspin↔sendspin and route via the bridge,
4543 # using the visualizer's player_id directly (is_native=True).
4544 assert protocol_members == ["hue-mancave"]
4545 assert native_members == []
4546 assert protocol_domain == "sendspin"
4547 assert protocol_player == sendspin_bridge
4548
4549
4550class TestEnrichPlayerIdentifiers:
4551 """Tests for MAC address enrichment via ARP lookup."""
4552
4553 @pytest.mark.asyncio
4554 async def test_no_ip_skips_enrichment(self, mock_mass: MagicMock) -> None:
4555 """Test that enrichment is skipped when no IP address is available."""
4556 provider = MockProvider("sendspin", mass=mock_mass)
4557 # Sendspin bridge player has MAC but no IP
4558 player = MockPlayer(
4559 provider,
4560 "spb_62e5974593d3",
4561 "Apple TV (Sendspin)",
4562 player_type=PlayerType.PROTOCOL,
4563 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4564 )
4565
4566 await enrich_device_mac_address(player.device_info)
4567
4568 # MAC should remain unchanged (no IP = no ARP lookup)
4569 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "62:E5:97:45:93:D3"
4570
4571 @pytest.mark.asyncio
4572 async def test_valid_hardware_mac_skips_enrichment(self, mock_mass: MagicMock) -> None:
4573 """Test that a valid, non-locally-administered MAC skips enrichment."""
4574 provider = MockProvider("sonos", mass=mock_mass)
4575 player = MockPlayer(
4576 provider,
4577 "sonos_123",
4578 "Sonos Speaker",
4579 player_type=PlayerType.PLAYER,
4580 identifiers={
4581 IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0",
4582 IdentifierType.IP_ADDRESS: "192.168.1.100",
4583 },
4584 )
4585
4586 await enrich_device_mac_address(player.device_info)
4587
4588 # MAC should remain unchanged (valid hardware MAC, not locally administered)
4589 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "54:78:C9:E6:0D:A0"
4590
4591 @pytest.mark.asyncio
4592 async def test_locally_administered_bit_difference_replaces_mac(
4593 self, mock_mass: MagicMock
4594 ) -> None:
4595 """
4596 Test that ARP MAC replaces reported MAC when only the locally-administered bit differs.
4597
4598 Example: AirPlay reports 56:78:C9:E6:0D:A0, ARP resolves 54:78:C9:E6:0D:A0.
4599 These differ only in bit 1 of the first octet (locally-administered bit).
4600 """
4601 provider = MockProvider("airplay", mass=mock_mass)
4602 player = MockPlayer(
4603 provider,
4604 "ap_5678c9e60da0",
4605 "WiiM Pro (AirPlay)",
4606 player_type=PlayerType.PROTOCOL,
4607 identifiers={
4608 IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0",
4609 IdentifierType.IP_ADDRESS: "192.168.1.50",
4610 },
4611 )
4612
4613 with patch(
4614 "music_assistant.helpers.util.resolve_real_mac_address",
4615 new_callable=AsyncMock,
4616 return_value="54:78:C9:E6:0D:A0",
4617 ):
4618 await enrich_device_mac_address(player.device_info)
4619
4620 # MAC should be replaced with the ARP-resolved hardware MAC
4621 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "54:78:C9:E6:0D:A0"
4622
4623 @pytest.mark.asyncio
4624 async def test_completely_different_mac_replaced_by_arp(self, mock_mass: MagicMock) -> None:
4625 """
4626 Test that ARP result always replaces a locally-administered MAC.
4627
4628 Even when the ARP MAC is completely different from the reported MAC
4629 (e.g., Apple devices with random private MACs), the ARP result is used
4630 because it's the hardware truth that reliably unifies protocols.
4631 Sendspin bridges match via AIRPLAY_ID/CAST_UUID, not MAC.
4632 """
4633 provider = MockProvider("airplay", mass=mock_mass)
4634 player = MockPlayer(
4635 provider,
4636 "ap62e5974593d3",
4637 "Apple TV (AirPlay)",
4638 player_type=PlayerType.PLAYER,
4639 identifiers={
4640 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4641 IdentifierType.IP_ADDRESS: "192.168.1.200",
4642 },
4643 )
4644
4645 with patch(
4646 "music_assistant.helpers.util.resolve_real_mac_address",
4647 new_callable=AsyncMock,
4648 return_value="C0:95:6D:51:34:E0",
4649 ):
4650 await enrich_device_mac_address(player.device_info)
4651
4652 # MAC should be replaced with ARP result (hardware truth)
4653 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "C0:95:6D:51:34:E0"
4654
4655 @pytest.mark.asyncio
4656 async def test_no_reported_mac_uses_arp_result(self, mock_mass: MagicMock) -> None:
4657 """Test that ARP MAC is used when no MAC was reported at all."""
4658 provider = MockProvider("chromecast", mass=mock_mass)
4659 player = MockPlayer(
4660 provider,
4661 "cc_123",
4662 "Chromecast",
4663 player_type=PlayerType.PROTOCOL,
4664 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.75"},
4665 )
4666
4667 with patch(
4668 "music_assistant.helpers.util.resolve_real_mac_address",
4669 new_callable=AsyncMock,
4670 return_value="AA:BB:CC:DD:EE:FF",
4671 ):
4672 await enrich_device_mac_address(player.device_info)
4673
4674 # MAC should be set from ARP since there was none before
4675 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "AA:BB:CC:DD:EE:FF"
4676
4677 @pytest.mark.asyncio
4678 async def test_invalid_mac_replaced_by_arp(self, mock_mass: MagicMock) -> None:
4679 """Test that an invalid MAC (00:00:00:00:00:00) is replaced by ARP result."""
4680 provider = MockProvider("dlna", mass=mock_mass)
4681 player = MockPlayer(
4682 provider,
4683 "dlna_123",
4684 "DLNA Device",
4685 player_type=PlayerType.PROTOCOL,
4686 identifiers={
4687 IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00",
4688 IdentifierType.IP_ADDRESS: "192.168.1.60",
4689 },
4690 )
4691
4692 with patch(
4693 "music_assistant.helpers.util.resolve_real_mac_address",
4694 new_callable=AsyncMock,
4695 return_value="11:22:33:44:55:66",
4696 ):
4697 await enrich_device_mac_address(player.device_info)
4698
4699 # Invalid MAC should be replaced
4700 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "11:22:33:44:55:66"
4701
4702 @pytest.mark.asyncio
4703 async def test_arp_returns_none_no_change(self, mock_mass: MagicMock) -> None:
4704 """Test that MAC is unchanged when ARP lookup returns None."""
4705 provider = MockProvider("airplay", mass=mock_mass)
4706 player = MockPlayer(
4707 provider,
4708 "ap_123",
4709 "AirPlay Device",
4710 player_type=PlayerType.PROTOCOL,
4711 identifiers={
4712 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4713 IdentifierType.IP_ADDRESS: "192.168.1.100",
4714 },
4715 )
4716
4717 with patch(
4718 "music_assistant.helpers.util.resolve_real_mac_address",
4719 new_callable=AsyncMock,
4720 return_value=None,
4721 ):
4722 await enrich_device_mac_address(player.device_info)
4723
4724 # MAC should remain unchanged
4725 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "62:E5:97:45:93:D3"
4726
4727 @pytest.mark.asyncio
4728 async def test_ipv6_mapped_ipv4_normalized(self, mock_mass: MagicMock) -> None:
4729 """Test that IPv6-mapped IPv4 addresses are normalized."""
4730 provider = MockProvider("airplay", mass=mock_mass)
4731 player = MockPlayer(
4732 provider,
4733 "ap_123",
4734 "AirPlay Device",
4735 player_type=PlayerType.PROTOCOL,
4736 identifiers={
4737 IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0",
4738 IdentifierType.IP_ADDRESS: "::ffff:192.168.1.100",
4739 },
4740 )
4741
4742 await enrich_device_mac_address(player.device_info)
4743
4744 # IP should be normalized to IPv4
4745 assert player.device_info.identifiers[IdentifierType.IP_ADDRESS] == "192.168.1.100"
4746
4747
4748def _create_universal_player(
4749 mock_mass: MagicMock,
4750 player_id: str,
4751 name: str,
4752 protocol_player_ids: list[str],
4753 identifiers: dict[IdentifierType, str] | None = None,
4754) -> UniversalPlayer:
4755 """Create a UniversalPlayer for testing."""
4756 universal_provider = create_mock_universal_provider(mock_mass)
4757 # Set up config so provider.instance_id works
4758 provider_config = MagicMock()
4759 provider_config.instance_id = "universal_player"
4760 provider_config.name = None
4761 universal_provider.config = provider_config
4762 mock_mass.config.get_base_player_config.return_value = create_mock_config(name)
4763
4764 device_info = DeviceInfo(
4765 model="Universal Player",
4766 manufacturer="Music Assistant",
4767 )
4768 if identifiers:
4769 for conn_type, value in identifiers.items():
4770 device_info.add_identifier(conn_type, value)
4771
4772 player = UniversalPlayer(
4773 provider=universal_provider,
4774 player_id=player_id,
4775 name=name,
4776 device_info=device_info,
4777 protocol_player_ids=protocol_player_ids,
4778 )
4779 player._attr_available = True
4780 player._cache.clear()
4781 player.set_initialized()
4782 return player
4783
4784
4785class TestProtocolToUniversalIdentifierFallback:
4786 """
4787 Tests for Fix 1: identifier-based fallback matching for Universal Players.
4788
4789 When a new protocol player (like Sendspin bridge) registers and its ID isn't
4790 in the Universal Player's stored _protocol_player_ids list, the system should
4791 fall back to identifier matching (MAC address) to find the correct parent.
4792 """
4793
4794 def test_new_protocol_matches_universal_by_mac(self, mock_mass: MagicMock) -> None:
4795 """Test that a new protocol player matches a Universal Player by MAC address."""
4796 controller = PlayerController(mock_mass)
4797
4798 # No cached parent_id for the sendspin player
4799 def mock_config_get(_key: str, default: str | None = None) -> str | None:
4800 return default
4801
4802 mock_mass.config.get.side_effect = mock_config_get
4803
4804 # Create existing universal player with known MAC (from AirPlay)
4805 universal = _create_universal_player(
4806 mock_mass,
4807 "up_62e5974593d3",
4808 "Apple TV",
4809 protocol_player_ids=["ap62e5974593d3"],
4810 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4811 )
4812
4813 # Create AirPlay protocol player (already linked)
4814 airplay_provider = MockProvider("airplay", mass=mock_mass)
4815 airplay_player = MockPlayer(
4816 airplay_provider,
4817 "ap62e5974593d3",
4818 "Apple TV (AirPlay)",
4819 player_type=PlayerType.PROTOCOL,
4820 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4821 )
4822 airplay_player.set_protocol_parent_id("up_62e5974593d3")
4823
4824 # Create NEW Sendspin bridge player with same MAC (not yet in _protocol_player_ids)
4825 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
4826 sendspin_player = MockPlayer(
4827 sendspin_provider,
4828 "spb_62e5974593d3",
4829 "Apple TV (Sendspin)",
4830 player_type=PlayerType.PROTOCOL,
4831 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4832 )
4833
4834 mock_mass.players = controller
4835 controller._players = {
4836 "up_62e5974593d3": universal,
4837 "ap62e5974593d3": airplay_player,
4838 "spb_62e5974593d3": sendspin_player,
4839 }
4840
4841 # Initialize players so all_players() returns them
4842 airplay_player.set_initialized()
4843 sendspin_player.set_initialized()
4844
4845 # Try to link Sendspin player
4846 controller._try_link_protocol_to_native(sendspin_player)
4847
4848 # Should be linked to the universal player via identifier matching
4849 assert sendspin_player.protocol_parent_id == "up_62e5974593d3"
4850
4851 # Should be added to universal player's protocol list
4852 assert "spb_62e5974593d3" in universal._protocol_player_ids
4853
4854 # Should have a linked output protocol
4855 assert any(
4856 link.output_protocol_id == "spb_62e5974593d3"
4857 for link in universal.linked_output_protocols
4858 )
4859
4860 def test_new_protocol_no_match_skips_universal(self, mock_mass: MagicMock) -> None:
4861 """Test that a protocol player with different MAC doesn't match the wrong Universal."""
4862 controller = PlayerController(mock_mass)
4863
4864 # No cached parent_id
4865 def mock_config_get(_key: str, default: str | None = None) -> str | None:
4866 return default
4867
4868 mock_mass.config.get.side_effect = mock_config_get
4869
4870 # Create universal player for Device A
4871 universal = _create_universal_player(
4872 mock_mass,
4873 "up_aabbccddeeff",
4874 "Device A",
4875 protocol_player_ids=["ap_aabbccddeeff"],
4876 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4877 )
4878
4879 # Create protocol player for a completely different device
4880 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
4881 sendspin_player = MockPlayer(
4882 sendspin_provider,
4883 "spb_112233445566",
4884 "Device B (Sendspin)",
4885 player_type=PlayerType.PROTOCOL,
4886 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
4887 )
4888
4889 mock_mass.players = controller
4890 controller._players = {
4891 "up_aabbccddeeff": universal,
4892 "spb_112233445566": sendspin_player,
4893 }
4894
4895 sendspin_player.set_initialized()
4896
4897 controller._try_link_protocol_to_native(sendspin_player)
4898
4899 # Should NOT be linked to the wrong universal player
4900 assert sendspin_player.protocol_parent_id != "up_aabbccddeeff"
4901
4902 def test_known_protocol_id_still_works(self, mock_mass: MagicMock) -> None:
4903 """Test that the existing path (player_id in _protocol_player_ids) still works."""
4904 controller = PlayerController(mock_mass)
4905
4906 # No cached parent_id
4907 def mock_config_get(_key: str, default: str | None = None) -> str | None:
4908 return default
4909
4910 mock_mass.config.get.side_effect = mock_config_get
4911
4912 airplay_provider = MockProvider("airplay", mass=mock_mass)
4913 airplay_player = MockPlayer(
4914 airplay_provider,
4915 "ap_aabbccddeeff",
4916 "AirPlay Device",
4917 player_type=PlayerType.PROTOCOL,
4918 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4919 )
4920
4921 # Universal player already has this player_id in its list
4922 universal = _create_universal_player(
4923 mock_mass,
4924 "up_aabbccddeeff",
4925 "Device",
4926 protocol_player_ids=["ap_aabbccddeeff"],
4927 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4928 )
4929
4930 mock_mass.players = controller
4931 controller._players = {
4932 "up_aabbccddeeff": universal,
4933 "ap_aabbccddeeff": airplay_player,
4934 }
4935
4936 airplay_player.set_initialized()
4937
4938 controller._try_link_protocol_to_native(airplay_player)
4939
4940 # Should be linked via the existing stored ID path
4941 assert airplay_player.protocol_parent_id == "up_aabbccddeeff"
4942
4943
4944class TestCachedParentIdentifierCopying:
4945 """
4946 Tests for Fix 2: identifier copying when restoring cached parent links.
4947
4948 When a protocol player reconnects and links to a Universal Player via the
4949 cached_parent_id fast path, identifiers must be copied to the Universal Player.
4950 Restored Universal Players start with empty identifiers, so without this copy,
4951 subsequent protocol players (like Sendspin bridges) cannot match by identifiers.
4952 """
4953
4954 def test_identifiers_copied_on_cached_parent_restore(self, mock_mass: MagicMock) -> None:
4955 """Test that identifiers are copied from protocol player to Universal Player on restore."""
4956 controller = PlayerController(mock_mass)
4957
4958 # Mock config to return cached parent_id
4959 def mock_config_get(key: str, default: str | None = None) -> str | None:
4960 if "protocol_parent_id" in str(key):
4961 return "up_62e5974593d3"
4962 return default
4963
4964 mock_mass.config.get.side_effect = mock_config_get
4965
4966 # Create restored universal player with EMPTY identifiers (simulates restart)
4967 universal = _create_universal_player(
4968 mock_mass,
4969 "up_62e5974593d3",
4970 "Apple TV",
4971 protocol_player_ids=["ap62e5974593d3"],
4972 # No identifiers - simulates restored state
4973 )
4974
4975 # Create AirPlay protocol player reconnecting with its identifiers
4976 airplay_provider = MockProvider("airplay", mass=mock_mass)
4977 airplay_player = MockPlayer(
4978 airplay_provider,
4979 "ap62e5974593d3",
4980 "Apple TV (AirPlay)",
4981 player_type=PlayerType.PROTOCOL,
4982 identifiers={
4983 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4984 IdentifierType.IP_ADDRESS: "192.168.1.200",
4985 },
4986 )
4987
4988 mock_mass.players = controller
4989 controller._players = {
4990 "up_62e5974593d3": universal,
4991 "ap62e5974593d3": airplay_player,
4992 }
4993
4994 # Link protocol player via cached parent path
4995 controller._try_link_protocol_to_native(airplay_player)
4996
4997 # Should be linked
4998 assert airplay_player.protocol_parent_id == "up_62e5974593d3"
4999
5000 # Universal player should now have the MAC from the protocol player
5001 assert (
5002 universal.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "62:E5:97:45:93:D3"
5003 )
5004
5005 def test_sendspin_matches_after_identifier_copy(self, mock_mass: MagicMock) -> None:
5006 """
5007 Test the full scenario: AirPlay restores, copies identifiers, Sendspin matches.
5008
5009 AirPlay restores via cache, copies identifiers to the Universal Player,
5010 then the Sendspin bridge matches the Universal Player by MAC.
5011 This is the end-to-end test for the combined Fix 2 + Fix 1 interaction.
5012 """
5013 controller = PlayerController(mock_mass)
5014
5015 # Step 1: Mock config - AirPlay has cached parent, Sendspin does not
5016 def mock_config_get(key: str, default: str | None = None) -> str | None:
5017 if "ap62e5974593d3" in str(key) and "protocol_parent_id" in str(key):
5018 return "up_62e5974593d3"
5019 return default
5020
5021 mock_mass.config.get.side_effect = mock_config_get
5022
5023 # Create restored universal player with empty identifiers
5024 universal = _create_universal_player(
5025 mock_mass,
5026 "up_62e5974593d3",
5027 "Apple TV",
5028 protocol_player_ids=["ap62e5974593d3"],
5029 )
5030
5031 # Create AirPlay protocol player
5032 airplay_provider = MockProvider("airplay", mass=mock_mass)
5033 airplay_player = MockPlayer(
5034 airplay_provider,
5035 "ap62e5974593d3",
5036 "Apple TV (AirPlay)",
5037 player_type=PlayerType.PROTOCOL,
5038 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
5039 )
5040
5041 # Create Sendspin bridge player with same MAC
5042 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
5043 sendspin_player = MockPlayer(
5044 sendspin_provider,
5045 "spb_62e5974593d3",
5046 "Apple TV (Sendspin)",
5047 player_type=PlayerType.PROTOCOL,
5048 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
5049 )
5050
5051 mock_mass.players = controller
5052 controller._players = {
5053 "up_62e5974593d3": universal,
5054 "ap62e5974593d3": airplay_player,
5055 "spb_62e5974593d3": sendspin_player,
5056 }
5057
5058 # Initialize players so all_players() returns them
5059 airplay_player.set_initialized()
5060 sendspin_player.set_initialized()
5061
5062 # Step 2: AirPlay reconnects first - links via cached parent_id
5063 controller._try_link_protocol_to_native(airplay_player)
5064
5065 # Verify AirPlay linked and identifiers copied
5066 assert airplay_player.protocol_parent_id == "up_62e5974593d3"
5067 assert (
5068 universal.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "62:E5:97:45:93:D3"
5069 )
5070
5071 # Step 3: Sendspin registers next - should match Universal Player by MAC
5072 controller._try_link_protocol_to_native(sendspin_player)
5073
5074 # Sendspin should be linked to the SAME universal player
5075 assert sendspin_player.protocol_parent_id == "up_62e5974593d3"
5076 assert "spb_62e5974593d3" in universal._protocol_player_ids
5077
5078 def test_non_universal_parent_skips_identifier_copy(self, mock_mass: MagicMock) -> None:
5079 """Test that identifier copy is only done for Universal Players, not native players."""
5080 controller = PlayerController(mock_mass)
5081
5082 # Mock config to return cached parent_id pointing to a native player
5083 def mock_config_get(key: str, default: str | None = None) -> str | None:
5084 if "protocol_parent_id" in str(key):
5085 return "sonos_123"
5086 return default
5087
5088 mock_mass.config.get.side_effect = mock_config_get
5089
5090 # Create native Sonos player
5091 sonos_provider = MockProvider("sonos", mass=mock_mass)
5092 sonos_player = MockPlayer(
5093 sonos_provider,
5094 "sonos_123",
5095 "Sonos Speaker",
5096 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5097 )
5098 original_mac = sonos_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5099
5100 # Create DLNA protocol player with different MAC
5101 dlna_provider = MockProvider("dlna", mass=mock_mass)
5102 dlna_player = MockPlayer(
5103 dlna_provider,
5104 "dlna_123",
5105 "Sonos DLNA",
5106 player_type=PlayerType.PROTOCOL,
5107 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
5108 )
5109
5110 mock_mass.players = controller
5111 controller._players = {
5112 "sonos_123": sonos_player,
5113 "dlna_123": dlna_player,
5114 }
5115
5116 controller._try_link_protocol_to_native(dlna_player)
5117
5118 # Should be linked
5119 assert dlna_player.protocol_parent_id == "sonos_123"
5120
5121 # Native player's MAC should NOT be overwritten
5122 assert sonos_player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == original_mac
5123
5124
5125class TestEnrichAndMatchIntegration:
5126 """
5127 Integration tests for the interaction between MAC enrichment and identifier matching.
5128
5129 These tests verify that Fix 3 (preserving original MAC when ARP resolves a
5130 completely different address) works correctly with the identifier matching system.
5131 """
5132
5133 @pytest.mark.asyncio
5134 async def test_apple_device_sendspin_matches_via_airplay_id(self, mock_mass: MagicMock) -> None:
5135 """
5136 Test the full Apple device scenario end-to-end.
5137
5138 1. AirPlay player registers with private MAC 62:E5:97:45:93:D3
5139 2. ARP resolves to hardware MAC C0:95:6D:51:34:E0 (always used)
5140 3. Sendspin bridge matches via AIRPLAY_ID, not MAC
5141 """
5142 controller = PlayerController(mock_mass)
5143
5144 # Create AirPlay player (Apple TV - native PLAYER type)
5145 airplay_provider = MockProvider("airplay", mass=mock_mass)
5146 airplay_player = MockPlayer(
5147 airplay_provider,
5148 "ap62e5974593d3",
5149 "Apple TV",
5150 player_type=PlayerType.PLAYER,
5151 identifiers={
5152 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
5153 IdentifierType.AIRPLAY_ID: "62:E5:97:45:93:D3",
5154 IdentifierType.IP_ADDRESS: "192.168.1.200",
5155 },
5156 )
5157
5158 # ARP enrichment replaces MAC with hardware truth
5159 with patch(
5160 "music_assistant.helpers.util.resolve_real_mac_address",
5161 new_callable=AsyncMock,
5162 return_value="C0:95:6D:51:34:E0",
5163 ):
5164 await enrich_device_mac_address(airplay_player.device_info)
5165
5166 # MAC replaced with ARP result
5167 assert (
5168 airplay_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5169 == "C0:95:6D:51:34:E0"
5170 )
5171
5172 # Create Sendspin bridge player with AIRPLAY_ID (how Sendspin actually matches)
5173 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
5174 sendspin_player = MockPlayer(
5175 sendspin_provider,
5176 "spb_62e5974593d3",
5177 "Apple TV (Sendspin)",
5178 player_type=PlayerType.PROTOCOL,
5179 identifiers={IdentifierType.AIRPLAY_ID: "62:E5:97:45:93:D3"},
5180 )
5181
5182 # Identifiers match via AIRPLAY_ID
5183 assert controller._identifiers_match(airplay_player, sendspin_player, "sendspin") is True
5184
5185 @pytest.mark.asyncio
5186 async def test_wiim_device_mac_replaced_still_matches(self, mock_mass: MagicMock) -> None:
5187 """
5188 Test that WiiM devices match after ARP enrichment.
5189
5190 WiiM AirPlay reports 56:78:C9:E6:0D:A0 (locally administered), ARP resolves to
5191 54:78:C9:E6:0D:A0 (hardware). The MAC is always replaced with the ARP result.
5192 """
5193 controller = PlayerController(mock_mass)
5194
5195 # Create AirPlay player (WiiM - PROTOCOL type)
5196 airplay_provider = MockProvider("airplay", mass=mock_mass)
5197 airplay_player = MockPlayer(
5198 airplay_provider,
5199 "ap_5678c9e60da0",
5200 "WiiM Pro (AirPlay)",
5201 player_type=PlayerType.PROTOCOL,
5202 identifiers={
5203 IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0",
5204 IdentifierType.IP_ADDRESS: "192.168.1.50",
5205 },
5206 )
5207
5208 # ARP resolves MAC that differs only in locally-administered bit
5209 with patch(
5210 "music_assistant.helpers.util.resolve_real_mac_address",
5211 new_callable=AsyncMock,
5212 return_value="54:78:C9:E6:0D:A0",
5213 ):
5214 await enrich_device_mac_address(airplay_player.device_info)
5215
5216 # MAC should be replaced (only bit difference)
5217 assert (
5218 airplay_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5219 == "54:78:C9:E6:0D:A0"
5220 )
5221
5222 # DLNA player also reports hardware MAC
5223 dlna_provider = MockProvider("dlna", mass=mock_mass)
5224 dlna_player = MockPlayer(
5225 dlna_provider,
5226 "dlna_5478c9e60da0",
5227 "WiiM Pro (DLNA)",
5228 player_type=PlayerType.PROTOCOL,
5229 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
5230 )
5231
5232 # Should match by MAC
5233 assert controller._identifiers_match(airplay_player, dlna_player, "dlna") is True
5234
5235
5236class TestDuplicateProtocolPrevention:
5237 """
5238 Tests for preventing duplicate protocol domain linking.
5239
5240 When multiple instances of the same protocol (e.g., two AirPlay or ShairPort-Sync
5241 instances, or multi-zone Bluesound players) run on the same host, they share the
5242 same IP/MAC. These must NOT all bind to the same parent player — each should get
5243 its own separate universal player.
5244 """
5245
5246 def test_parent_has_active_protocol_from_domain_empty(self, mock_mass: MagicMock) -> None:
5247 """Test helper returns False when parent has no linked protocols."""
5248 controller = PlayerController(mock_mass)
5249
5250 sonos_provider = MockProvider("sonos")
5251 sonos_player = MockPlayer(
5252 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5253 )
5254
5255 assert controller._parent_has_active_protocol_from_domain(sonos_player, "airplay") is False
5256
5257 def test_parent_has_active_protocol_from_domain_true(self, mock_mass: MagicMock) -> None:
5258 """Test helper returns True when parent has an active link from the given domain."""
5259 controller = PlayerController(mock_mass)
5260
5261 sonos_provider = MockProvider("sonos")
5262 sonos_player = MockPlayer(
5263 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5264 )
5265
5266 airplay_provider = MockProvider("airplay")
5267 airplay_player = MockPlayer(
5268 airplay_provider,
5269 "ap_1",
5270 "AirPlay 1",
5271 player_type=PlayerType.PROTOCOL,
5272 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5273 )
5274 airplay_player._attr_available = True
5275 airplay_player._cache.clear()
5276 airplay_player.update_state(signal_event=False)
5277
5278 # Register players
5279 controller._players = {
5280 "sonos_1": sonos_player,
5281 "ap_1": airplay_player,
5282 }
5283 airplay_player.set_initialized()
5284 sonos_player.set_initialized()
5285
5286 # Add a protocol link
5287 controller._add_protocol_link(sonos_player, airplay_player, "airplay")
5288
5289 assert controller._parent_has_active_protocol_from_domain(sonos_player, "airplay") is True
5290 assert controller._parent_has_active_protocol_from_domain(sonos_player, "dlna") is False
5291
5292 def test_parent_has_active_protocol_excludes_player_id(self, mock_mass: MagicMock) -> None:
5293 """Test helper excludes the specified player_id from the check."""
5294 controller = PlayerController(mock_mass)
5295
5296 sonos_provider = MockProvider("sonos")
5297 sonos_player = MockPlayer(
5298 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5299 )
5300
5301 airplay_provider = MockProvider("airplay")
5302 airplay_player = MockPlayer(
5303 airplay_provider,
5304 "ap_1",
5305 "AirPlay 1",
5306 player_type=PlayerType.PROTOCOL,
5307 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5308 )
5309 airplay_player._attr_available = True
5310 airplay_player._cache.clear()
5311 airplay_player.update_state(signal_event=False)
5312
5313 controller._players = {
5314 "sonos_1": sonos_player,
5315 "ap_1": airplay_player,
5316 }
5317 airplay_player.set_initialized()
5318 sonos_player.set_initialized()
5319
5320 controller._add_protocol_link(sonos_player, airplay_player, "airplay")
5321
5322 # Should return False when the only match is the excluded player
5323 assert (
5324 controller._parent_has_active_protocol_from_domain(
5325 sonos_player, "airplay", exclude_player_id="ap_1"
5326 )
5327 is False
5328 )
5329
5330 def test_add_protocol_link_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
5331 """Test that _add_protocol_link refuses when domain already has an active link."""
5332 controller = PlayerController(mock_mass)
5333
5334 sonos_provider = MockProvider("sonos")
5335 sonos_player = MockPlayer(
5336 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5337 )
5338
5339 airplay_provider = MockProvider("airplay")
5340 ap1 = MockPlayer(
5341 airplay_provider,
5342 "ap_1",
5343 "AirPlay 1",
5344 player_type=PlayerType.PROTOCOL,
5345 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5346 )
5347 ap2 = MockPlayer(
5348 airplay_provider,
5349 "ap_2",
5350 "AirPlay 2",
5351 player_type=PlayerType.PROTOCOL,
5352 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5353 )
5354
5355 # Both available
5356 ap1._attr_available = True
5357 ap1._cache.clear()
5358 ap1.update_state(signal_event=False)
5359 ap2._attr_available = True
5360 ap2._cache.clear()
5361 ap2.update_state(signal_event=False)
5362
5363 controller._players = {
5364 "sonos_1": sonos_player,
5365 "ap_1": ap1,
5366 "ap_2": ap2,
5367 }
5368 ap1.set_initialized()
5369 ap2.set_initialized()
5370 sonos_player.set_initialized()
5371
5372 # Link first AirPlay - should succeed
5373 controller._add_protocol_link(sonos_player, ap1, "airplay")
5374 assert ap1.protocol_parent_id == "sonos_1"
5375 assert len(sonos_player.linked_output_protocols) == 1
5376
5377 # Link second AirPlay - should be refused (domain already active)
5378 controller._add_protocol_link(sonos_player, ap2, "airplay")
5379 assert ap2.protocol_parent_id is None
5380 assert len(sonos_player.linked_output_protocols) == 1
5381 assert sonos_player.linked_output_protocols[0].output_protocol_id == "ap_1"
5382
5383 def test_add_protocol_link_allows_different_domains(self, mock_mass: MagicMock) -> None:
5384 """Test that _add_protocol_link allows links from different protocol domains."""
5385 controller = PlayerController(mock_mass)
5386
5387 sonos_provider = MockProvider("sonos")
5388 sonos_player = MockPlayer(
5389 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5390 )
5391
5392 airplay_provider = MockProvider("airplay")
5393 ap1 = MockPlayer(
5394 airplay_provider,
5395 "ap_1",
5396 "AirPlay",
5397 player_type=PlayerType.PROTOCOL,
5398 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5399 )
5400
5401 dlna_provider = MockProvider("dlna")
5402 dlna1 = MockPlayer(
5403 dlna_provider,
5404 "dlna_1",
5405 "DLNA",
5406 player_type=PlayerType.PROTOCOL,
5407 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5408 )
5409
5410 ap1._attr_available = True
5411 ap1._cache.clear()
5412 ap1.update_state(signal_event=False)
5413 dlna1._attr_available = True
5414 dlna1._cache.clear()
5415 dlna1.update_state(signal_event=False)
5416
5417 controller._players = {
5418 "sonos_1": sonos_player,
5419 "ap_1": ap1,
5420 "dlna_1": dlna1,
5421 }
5422 ap1.set_initialized()
5423 dlna1.set_initialized()
5424 sonos_player.set_initialized()
5425
5426 # Link AirPlay - should succeed
5427 controller._add_protocol_link(sonos_player, ap1, "airplay")
5428 assert ap1.protocol_parent_id == "sonos_1"
5429
5430 # Link DLNA - should also succeed (different domain)
5431 controller._add_protocol_link(sonos_player, dlna1, "dlna")
5432 assert dlna1.protocol_parent_id == "sonos_1"
5433 assert len(sonos_player.linked_output_protocols) == 2
5434
5435 def test_add_protocol_link_allows_replacing_removed_player(self, mock_mass: MagicMock) -> None:
5436 """Test that a new link can replace a stale one after the old player is removed."""
5437 controller = PlayerController(mock_mass)
5438
5439 sonos_provider = MockProvider("sonos")
5440 sonos_player = MockPlayer(
5441 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5442 )
5443
5444 airplay_provider = MockProvider("airplay")
5445 ap1 = MockPlayer(
5446 airplay_provider,
5447 "ap_1",
5448 "AirPlay 1",
5449 player_type=PlayerType.PROTOCOL,
5450 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5451 )
5452 ap2 = MockPlayer(
5453 airplay_provider,
5454 "ap_2",
5455 "AirPlay 2",
5456 player_type=PlayerType.PROTOCOL,
5457 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5458 )
5459
5460 ap1._attr_available = True
5461 ap1._cache.clear()
5462 ap1.update_state(signal_event=False)
5463 ap2._attr_available = True
5464 ap2._cache.clear()
5465 ap2.update_state(signal_event=False)
5466
5467 controller._players = {
5468 "sonos_1": sonos_player,
5469 "ap_1": ap1,
5470 "ap_2": ap2,
5471 }
5472 ap1.set_initialized()
5473 ap2.set_initialized()
5474 sonos_player.set_initialized()
5475
5476 # Link first AirPlay
5477 controller._add_protocol_link(sonos_player, ap1, "airplay")
5478 assert ap1.protocol_parent_id == "sonos_1"
5479
5480 # Remove first AirPlay from registry (provider cleaned up stale player)
5481 del controller._players["ap_1"]
5482
5483 # Link second AirPlay - should succeed since first was removed
5484 controller._add_protocol_link(sonos_player, ap2, "airplay")
5485 assert ap2.protocol_parent_id == "sonos_1"
5486
5487 def test_add_protocol_link_blocks_when_existing_still_registered(
5488 self, mock_mass: MagicMock
5489 ) -> None:
5490 """Test that a new link is blocked when the old player is still registered."""
5491 controller = PlayerController(mock_mass)
5492
5493 sonos_provider = MockProvider("sonos")
5494 sonos_player = MockPlayer(
5495 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5496 )
5497
5498 snap_provider = MockProvider("snapcast")
5499 snap1 = MockPlayer(
5500 snap_provider,
5501 "snap_1",
5502 "Snapcast 1",
5503 player_type=PlayerType.PROTOCOL,
5504 identifiers={IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F"},
5505 )
5506 snap2 = MockPlayer(
5507 snap_provider,
5508 "snap_2",
5509 "Snapcast 2",
5510 player_type=PlayerType.PROTOCOL,
5511 identifiers={IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F"},
5512 )
5513
5514 # Both unavailable
5515 snap1._attr_available = False
5516 snap1._cache.clear()
5517 snap1.update_state(signal_event=False)
5518 snap2._attr_available = False
5519 snap2._cache.clear()
5520 snap2.update_state(signal_event=False)
5521
5522 controller._players = {
5523 "sonos_1": sonos_player,
5524 "snap_1": snap1,
5525 "snap_2": snap2,
5526 }
5527 snap1.set_initialized()
5528 snap2.set_initialized()
5529 sonos_player.set_initialized()
5530
5531 # Link first snapcast
5532 controller._add_protocol_link(sonos_player, snap1, "snapcast")
5533 assert snap1.protocol_parent_id == "sonos_1"
5534
5535 # Try to link second snapcast - should be BLOCKED (snap1 still registered)
5536 controller._add_protocol_link(sonos_player, snap2, "snapcast")
5537 assert snap2.protocol_parent_id is None
5538
5539 def test_try_link_protocol_to_native_skips_duplicate_domain(self, mock_mass: MagicMock) -> None:
5540 """Test that _try_link_protocol_to_native falls through when domain is duplicate."""
5541 controller = PlayerController(mock_mass)
5542
5543 sonos_provider = MockProvider("sonos")
5544 sonos_player = MockPlayer(
5545 sonos_provider,
5546 "sonos_1",
5547 "Sonos Speaker",
5548 player_type=PlayerType.PLAYER,
5549 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5550 )
5551
5552 airplay_provider = MockProvider("airplay")
5553 ap1 = MockPlayer(
5554 airplay_provider,
5555 "ap_1",
5556 "AirPlay 1",
5557 player_type=PlayerType.PROTOCOL,
5558 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5559 )
5560 ap2 = MockPlayer(
5561 airplay_provider,
5562 "ap_2",
5563 "AirPlay 2",
5564 player_type=PlayerType.PROTOCOL,
5565 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5566 )
5567
5568 ap1._attr_available = True
5569 ap1._cache.clear()
5570 ap1.update_state(signal_event=False)
5571 ap2._attr_available = True
5572 ap2._cache.clear()
5573 ap2.update_state(signal_event=False)
5574
5575 controller._players = {
5576 "sonos_1": sonos_player,
5577 "ap_1": ap1,
5578 "ap_2": ap2,
5579 }
5580 ap1.set_initialized()
5581 ap2.set_initialized()
5582 sonos_player.set_initialized()
5583
5584 # Link first AirPlay to native player
5585 controller._add_protocol_link(sonos_player, ap1, "airplay")
5586 assert ap1.protocol_parent_id == "sonos_1"
5587
5588 # Mock _schedule_protocol_evaluation to track if it's called
5589 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
5590 controller._try_link_protocol_to_native(ap2)
5591
5592 # ap2 should NOT have been linked to sonos
5593 assert ap2.protocol_parent_id is None
5594 # ap2 should be scheduled for delayed evaluation (which will create its own
5595 # universal player)
5596 mock_schedule.assert_called_once_with(ap2)
5597
5598 def test_try_link_protocols_to_native_skips_duplicate_domain(
5599 self, mock_mass: MagicMock
5600 ) -> None:
5601 """Test that _try_link_protocols_to_native skips protocol players with duplicate domain."""
5602 controller = PlayerController(mock_mass)
5603
5604 # Config.get returns {} for CONF_PLAYERS dict lookups
5605 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default or {})
5606
5607 sonos_provider = MockProvider("sonos")
5608 sonos_player = MockPlayer(
5609 sonos_provider,
5610 "sonos_1",
5611 "Sonos Speaker",
5612 player_type=PlayerType.PLAYER,
5613 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5614 )
5615
5616 airplay_provider = MockProvider("airplay")
5617 ap1 = MockPlayer(
5618 airplay_provider,
5619 "ap_1",
5620 "AirPlay 1",
5621 player_type=PlayerType.PROTOCOL,
5622 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5623 )
5624 ap2 = MockPlayer(
5625 airplay_provider,
5626 "ap_2",
5627 "AirPlay 2",
5628 player_type=PlayerType.PROTOCOL,
5629 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5630 )
5631
5632 dlna_provider = MockProvider("dlna")
5633 dlna_player = MockPlayer(
5634 dlna_provider,
5635 "dlna_1",
5636 "DLNA",
5637 player_type=PlayerType.PROTOCOL,
5638 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5639 )
5640
5641 ap1._attr_available = True
5642 ap1._cache.clear()
5643 ap1.update_state(signal_event=False)
5644 ap2._attr_available = True
5645 ap2._cache.clear()
5646 ap2.update_state(signal_event=False)
5647 dlna_player._attr_available = True
5648 dlna_player._cache.clear()
5649 dlna_player.update_state(signal_event=False)
5650
5651 controller._players = {
5652 "ap_1": ap1,
5653 "ap_2": ap2,
5654 "dlna_1": dlna_player,
5655 "sonos_1": sonos_player,
5656 }
5657 ap1.set_initialized()
5658 ap2.set_initialized()
5659 dlna_player.set_initialized()
5660 sonos_player.set_initialized()
5661
5662 # Call _try_link_protocols_to_native for the Sonos player
5663 # It should link ap1 and dlna but NOT ap2 (duplicate airplay domain)
5664 controller._try_link_protocols_to_native(sonos_player)
5665
5666 # First AirPlay should be linked
5667 assert ap1.protocol_parent_id == "sonos_1"
5668 # DLNA should be linked (different domain)
5669 assert dlna_player.protocol_parent_id == "sonos_1"
5670 # Second AirPlay should NOT be linked (duplicate domain)
5671 assert ap2.protocol_parent_id is None
5672 assert len(sonos_player.linked_output_protocols) == 2
5673
5674 def test_normal_multi_protocol_device_still_works(self, mock_mass: MagicMock) -> None:
5675 """Test that a device with AirPlay+DLNA (different protocols) still links correctly."""
5676 controller = PlayerController(mock_mass)
5677
5678 # Config.get returns {} for CONF_PLAYERS dict lookups
5679 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default or {})
5680
5681 sonos_provider = MockProvider("sonos")
5682 sonos_player = MockPlayer(
5683 sonos_provider,
5684 "sonos_1",
5685 "Sonos Speaker",
5686 player_type=PlayerType.PLAYER,
5687 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5688 )
5689
5690 airplay_provider = MockProvider("airplay")
5691 ap = MockPlayer(
5692 airplay_provider,
5693 "ap_1",
5694 "AirPlay",
5695 player_type=PlayerType.PROTOCOL,
5696 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5697 )
5698
5699 dlna_provider = MockProvider("dlna")
5700 dlna = MockPlayer(
5701 dlna_provider,
5702 "dlna_1",
5703 "DLNA",
5704 player_type=PlayerType.PROTOCOL,
5705 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5706 )
5707
5708 ap._attr_available = True
5709 ap._cache.clear()
5710 ap.update_state(signal_event=False)
5711 dlna._attr_available = True
5712 dlna._cache.clear()
5713 dlna.update_state(signal_event=False)
5714
5715 controller._players = {
5716 "sonos_1": sonos_player,
5717 "ap_1": ap,
5718 "dlna_1": dlna,
5719 }
5720 ap.set_initialized()
5721 dlna.set_initialized()
5722 sonos_player.set_initialized()
5723
5724 # Link both protocols
5725 controller._try_link_protocols_to_native(sonos_player)
5726
5727 assert ap.protocol_parent_id == "sonos_1"
5728 assert dlna.protocol_parent_id == "sonos_1"
5729 assert len(sonos_player.linked_output_protocols) == 2
5730
5731 def test_cached_parent_restore_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
5732 """Test that restoring a cached parent is refused when domain already has active link."""
5733 controller = PlayerController(mock_mass)
5734
5735 sonos_provider = MockProvider("sonos")
5736 sonos_player = MockPlayer(
5737 sonos_provider,
5738 "sonos_1",
5739 "Sonos Speaker",
5740 player_type=PlayerType.PLAYER,
5741 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5742 )
5743
5744 airplay_provider = MockProvider("airplay")
5745 ap1 = MockPlayer(
5746 airplay_provider,
5747 "ap_1",
5748 "AirPlay 1",
5749 player_type=PlayerType.PROTOCOL,
5750 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5751 )
5752 ap2 = MockPlayer(
5753 airplay_provider,
5754 "ap_2",
5755 "AirPlay 2",
5756 player_type=PlayerType.PROTOCOL,
5757 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5758 )
5759
5760 ap1._attr_available = True
5761 ap1._cache.clear()
5762 ap1.update_state(signal_event=False)
5763 ap2._attr_available = True
5764 ap2._cache.clear()
5765 ap2.update_state(signal_event=False)
5766
5767 controller._players = {
5768 "sonos_1": sonos_player,
5769 "ap_1": ap1,
5770 "ap_2": ap2,
5771 }
5772 ap1.set_initialized()
5773 ap2.set_initialized()
5774 sonos_player.set_initialized()
5775
5776 # Link first AirPlay
5777 controller._add_protocol_link(sonos_player, ap1, "airplay")
5778 assert ap1.protocol_parent_id == "sonos_1"
5779
5780 # Simulate ap2 having a cached parent from previous session
5781 with (
5782 patch.object(
5783 controller,
5784 "_get_cached_protocol_parent_id",
5785 return_value="sonos_1",
5786 ),
5787 patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule,
5788 ):
5789 controller._try_link_protocol_to_native(ap2)
5790
5791 # ap2 should NOT have been linked (domain already active on sonos_1)
5792 assert ap2.protocol_parent_id is None
5793 # ap2 should be scheduled for delayed evaluation
5794 mock_schedule.assert_called_once_with(ap2)
5795
5796
5797class TestUniversalPlayerMerging:
5798 """Tests for merging universal players when identifiers overlap."""
5799
5800 def test_merge_universal_players_on_identifier_copy(self, mock_mass: MagicMock) -> None:
5801 """
5802 Test that two universal players merge when they share a MAC after identifier copy.
5803
5804 Simulates the Edifier scenario: DLNA creates a UUID-based universal player,
5805 AirPlay creates a MAC-based universal player. When ARP enrichment adds the
5806 real MAC to the DLNA player and it's copied to the DLNA universal player,
5807 the two universal players should merge because they now share the same MAC.
5808 """
5809 controller = PlayerController(mock_mass)
5810 up_provider = create_mock_universal_provider(mock_mass)
5811
5812 # Universal player 1: from DLNA (UUID-based, no MAC initially)
5813 up1 = UniversalPlayer(
5814 provider=up_provider,
5815 player_id="upf15fff97f002",
5816 name="Speaker Liv",
5817 device_info=DeviceInfo(model="DLNA Speaker", manufacturer="Edifier"),
5818 protocol_player_ids=["dlna_uuid_1"],
5819 )
5820 up1._attr_device_info.add_identifier(
5821 IdentifierType.UUID, "FF97F002-783E-6505-6579-F15FFF97F002"
5822 )
5823 up1._cache.clear()
5824 up1.update_state(signal_event=False)
5825 up1.set_initialized()
5826
5827 # Universal player 2: from AirPlay (MAC-based)
5828 up2 = UniversalPlayer(
5829 provider=up_provider,
5830 player_id="up50411c2f00ee",
5831 name="Speaker Liv",
5832 device_info=DeviceInfo(model="AirPlay Speaker", manufacturer="Edifier"),
5833 protocol_player_ids=["ap_1", "spb_1"],
5834 )
5835 up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "50:41:1C:2F:00:EE")
5836 up2._attr_device_info.add_identifier(IdentifierType.AIRPLAY_ID, "52:41:1C:2F:00:EE")
5837 up2._cache.clear()
5838 up2.update_state(signal_event=False)
5839 up2.set_initialized()
5840
5841 # Create mock DLNA protocol player (has MAC from ARP enrichment)
5842 dlna_provider = MockProvider("dlna", mass=mock_mass)
5843 dlna_player = MockPlayer(
5844 dlna_provider,
5845 "dlna_uuid_1",
5846 "Speaker Liv DLNA",
5847 player_type=PlayerType.PROTOCOL,
5848 identifiers={
5849 IdentifierType.UUID: "FF97F002-783E-6505-6579-F15FFF97F002",
5850 IdentifierType.MAC_ADDRESS: "50:41:1C:2F:00:EE", # From ARP
5851 IdentifierType.IP_ADDRESS: "192.168.1.80",
5852 },
5853 )
5854 dlna_player.set_initialized()
5855
5856 # Create mock AirPlay protocol player
5857 airplay_provider = MockProvider("airplay", mass=mock_mass)
5858 ap_player = MockPlayer(
5859 airplay_provider,
5860 "ap_1",
5861 "Speaker Liv AirPlay",
5862 player_type=PlayerType.PROTOCOL,
5863 identifiers={
5864 IdentifierType.MAC_ADDRESS: "50:41:1C:2F:00:EE",
5865 IdentifierType.AIRPLAY_ID: "52:41:1C:2F:00:EE",
5866 },
5867 )
5868 ap_player.set_initialized()
5869
5870 # Link protocols to their respective universal players
5871 controller._players = {
5872 "upf15fff97f002": up1,
5873 "up50411c2f00ee": up2,
5874 "dlna_uuid_1": dlna_player,
5875 "ap_1": ap_player,
5876 }
5877
5878 # Link DLNA to up1
5879 controller._add_protocol_link(up1, dlna_player, "dlna")
5880 assert dlna_player.protocol_parent_id == "upf15fff97f002"
5881
5882 # Link AirPlay to up2
5883 controller._add_protocol_link(up2, ap_player, "airplay")
5884 assert ap_player.protocol_parent_id == "up50411c2f00ee"
5885
5886 # Now simulate what happens when DLNA player copies identifiers to up1
5887 # (this happens in _try_restore_cached_parent or _try_link_to_existing_player)
5888 for conn_type, value in dlna_player.device_info.identifiers.items():
5889 up1.device_info.add_identifier(conn_type, value)
5890
5891 # up1 now has MAC 50:41:1C:2F:00:EE which matches up2
5892 assert up1.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "50:41:1C:2F:00:EE"
5893
5894 # Call merge check
5895 controller._check_merge_universal_players(up1)
5896
5897 # Both UPs have one linked protocol -> equal-count merge falls through to
5898 # the deterministic tiebreaker on player_id. "up50411c2f00ee" (up2) is
5899 # lex-smaller than "upf15fff97f002" (up1), so up2 absorbs up1.
5900
5901 # up2 (lex-smaller player_id) should have absorbed up1's DLNA link
5902 protocol_domains = {link.protocol_domain for link in up2.linked_output_protocols}
5903 assert "dlna" in protocol_domains
5904 assert "airplay" in protocol_domains
5905
5906 # up1 should have no more protocol links
5907 assert len(up1.linked_output_protocols) == 0
5908
5909 # DLNA player should now be linked to up2 (was on up1 before merge)
5910 assert dlna_player.protocol_parent_id == "up50411c2f00ee"
5911
5912 # AirPlay player should still be linked to up2
5913 assert ap_player.protocol_parent_id == "up50411c2f00ee"
5914
5915 # up2 should have protocol player IDs from both
5916 assert "dlna_uuid_1" in up2._protocol_player_ids
5917 assert "ap_1" in up2._protocol_player_ids
5918
5919 # Unregister should have been called for up1
5920 mock_mass.create_task.assert_called()
5921
5922 def test_no_merge_when_identifiers_dont_match(self, mock_mass: MagicMock) -> None:
5923 """Test that universal players are not merged when identifiers don't overlap."""
5924 controller = PlayerController(mock_mass)
5925 up_provider = create_mock_universal_provider(mock_mass)
5926
5927 up1 = UniversalPlayer(
5928 provider=up_provider,
5929 player_id="up_1",
5930 name="Speaker A",
5931 device_info=DeviceInfo(model="Model A", manufacturer="Maker A"),
5932 protocol_player_ids=["dlna_1"],
5933 )
5934 up1._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:01")
5935 up1._cache.clear()
5936 up1.update_state(signal_event=False)
5937 up1.set_initialized()
5938
5939 up2 = UniversalPlayer(
5940 provider=up_provider,
5941 player_id="up_2",
5942 name="Speaker B",
5943 device_info=DeviceInfo(model="Model B", manufacturer="Maker B"),
5944 protocol_player_ids=["ap_1"],
5945 )
5946 up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:02")
5947 up2._cache.clear()
5948 up2.update_state(signal_event=False)
5949 up2.set_initialized()
5950
5951 controller._players = {"up_1": up1, "up_2": up2}
5952
5953 # Call merge - should do nothing since MACs are different
5954 controller._check_merge_universal_players(up1)
5955
5956 # Both players should remain untouched
5957 assert "up_1" in controller._players
5958 assert "up_2" in controller._players
5959
5960 def test_merge_keeps_player_with_more_protocols(self, mock_mass: MagicMock) -> None:
5961 """Test that of two equally old universal players, the one with more links wins."""
5962 controller = PlayerController(mock_mass)
5963 up_provider = create_mock_universal_provider(mock_mass)
5964
5965 # up1 has only 1 protocol link (DLNA)
5966 up1 = UniversalPlayer(
5967 provider=up_provider,
5968 player_id="up_small",
5969 name="Small Player",
5970 device_info=DeviceInfo(model="Test", manufacturer="Test"),
5971 protocol_player_ids=["dlna_1"],
5972 )
5973 up1._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
5974 up1._cache.clear()
5975 up1.update_state(signal_event=False)
5976 up1.set_initialized()
5977
5978 # up2 has 2 protocol links (AirPlay + Sendspin)
5979 up2 = UniversalPlayer(
5980 provider=up_provider,
5981 player_id="up_big",
5982 name="Big Player",
5983 device_info=DeviceInfo(model="Test", manufacturer="Test"),
5984 protocol_player_ids=["ap_1", "spb_1"],
5985 )
5986 up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
5987 up2._cache.clear()
5988 up2.update_state(signal_event=False)
5989 up2.set_initialized()
5990
5991 # Create protocol players
5992 dlna_provider = MockProvider("dlna", mass=mock_mass)
5993 dlna_player = MockPlayer(
5994 dlna_provider,
5995 "dlna_1",
5996 "DLNA",
5997 player_type=PlayerType.PROTOCOL,
5998 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5999 )
6000 dlna_player.set_initialized()
6001
6002 airplay_provider = MockProvider("airplay", mass=mock_mass)
6003 ap_player = MockPlayer(
6004 airplay_provider,
6005 "ap_1",
6006 "AirPlay",
6007 player_type=PlayerType.PROTOCOL,
6008 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6009 )
6010 ap_player.set_initialized()
6011
6012 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
6013 spb_player = MockPlayer(
6014 sendspin_provider,
6015 "spb_1",
6016 "Sendspin",
6017 player_type=PlayerType.PROTOCOL,
6018 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6019 )
6020 spb_player.set_initialized()
6021
6022 controller._players = {
6023 "up_small": up1,
6024 "up_big": up2,
6025 "dlna_1": dlna_player,
6026 "ap_1": ap_player,
6027 "spb_1": spb_player,
6028 }
6029
6030 # Link DLNA to small, AirPlay+Sendspin to big
6031 controller._add_protocol_link(up1, dlna_player, "dlna")
6032 controller._add_protocol_link(up2, ap_player, "airplay")
6033 controller._add_protocol_link(up2, spb_player, "sendspin")
6034
6035 # Merge from the small player's perspective
6036 controller._check_merge_universal_players(up1)
6037
6038 # up2 (bigger) should have absorbed up1's DLNA link
6039 protocol_domains = {link.protocol_domain for link in up2.linked_output_protocols}
6040 assert "airplay" in protocol_domains
6041 assert "sendspin" in protocol_domains
6042 assert "dlna" in protocol_domains
6043
6044 # DLNA player should now point to up_big
6045 assert dlna_player.protocol_parent_id == "up_big"
6046
6047 # up1 should have no more links
6048 assert len(up1.linked_output_protocols) == 0
6049
6050 def test_merge_keeps_the_older_player_with_fewer_protocols(self, mock_mass: MagicMock) -> None:
6051 """
6052 The oldest universal player wins even when it serves fewer protocols.
6053
6054 Its player id is the one API consumers have been bound to the longest, and the
6055 links of the absorbed player move over anyway. A player stored before ids were
6056 minted has no creation moment at all, which must count as the oldest of the two.
6057 """
6058 controller = PlayerController(mock_mass)
6059 up_provider = create_mock_universal_provider(mock_mass)
6060 _wire_nested_config(
6061 mock_mass,
6062 {
6063 CONF_PLAYERS: {
6064 # the established player, from before player ids were minted
6065 "up_old": {"player_id": "up_old", "enabled": True, "values": {}},
6066 "up_new": {
6067 "player_id": "up_new",
6068 "enabled": True,
6069 "values": {CONF_CREATED_AT: 9000},
6070 },
6071 }
6072 },
6073 )
6074
6075 players: dict[str, UniversalPlayer] = {}
6076 for player_id, protocol_ids in (("up_old", ["dlna_1"]), ("up_new", ["ap_1", "spb_1"])):
6077 player = UniversalPlayer(
6078 provider=up_provider,
6079 player_id=player_id,
6080 name=player_id,
6081 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6082 protocol_player_ids=protocol_ids,
6083 )
6084 player._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6085 player._cache.clear()
6086 player.update_state(signal_event=False)
6087 player.set_initialized()
6088 players[player_id] = player
6089
6090 protocol_players: dict[str, MockPlayer] = {}
6091 for protocol_id, domain in (("dlna_1", "dlna"), ("ap_1", "airplay"), ("spb_1", "sendspin")):
6092 protocol_player = MockPlayer(
6093 MockProvider(domain, mass=mock_mass),
6094 protocol_id,
6095 domain,
6096 player_type=PlayerType.PROTOCOL,
6097 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6098 )
6099 protocol_player.set_initialized()
6100 protocol_players[protocol_id] = protocol_player
6101
6102 controller._players = {**players, **protocol_players}
6103 controller._add_protocol_link(players["up_old"], protocol_players["dlna_1"], "dlna")
6104 controller._add_protocol_link(players["up_new"], protocol_players["ap_1"], "airplay")
6105 controller._add_protocol_link(players["up_new"], protocol_players["spb_1"], "sendspin")
6106
6107 controller._check_merge_universal_players(players["up_new"])
6108
6109 domains = {link.protocol_domain for link in players["up_old"].linked_output_protocols}
6110 assert domains == {"dlna", "airplay", "sendspin"}
6111 assert protocol_players["ap_1"].protocol_parent_id == "up_old"
6112 assert len(players["up_new"].linked_output_protocols) == 0
6113
6114 @staticmethod
6115 def _setup_merge_tiebreak(
6116 mock_mass: MagicMock, created_at: dict[str, int]
6117 ) -> tuple[PlayerController, UniversalPlayer, UniversalPlayer, MockPlayer, MockPlayer]:
6118 """
6119 Set up two universal players of one device, each with a single protocol link.
6120
6121 :param mock_mass: The mocked MusicAssistant instance to wire.
6122 :param created_at: Stored creation timestamp per universal player id.
6123 :return: Tuple of (controller, up_aaa, up_bbb, its DLNA player, its AirPlay player).
6124 """
6125 controller = PlayerController(mock_mass)
6126 up_provider = create_mock_universal_provider(mock_mass)
6127 _wire_nested_config(
6128 mock_mass,
6129 {
6130 CONF_PLAYERS: {
6131 player_id: {
6132 "player_id": player_id,
6133 "provider": "universal_player",
6134 "enabled": True,
6135 "values": {CONF_CREATED_AT: timestamp},
6136 }
6137 for player_id, timestamp in created_at.items()
6138 }
6139 },
6140 )
6141
6142 # up_a: lex-smaller player_id, one protocol link (DLNA)
6143 up_a = UniversalPlayer(
6144 provider=up_provider,
6145 player_id="up_aaa",
6146 name="Player A",
6147 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6148 protocol_player_ids=["dlna_1"],
6149 )
6150 up_a._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6151 up_a._cache.clear()
6152 up_a.update_state(signal_event=False)
6153 up_a.set_initialized()
6154
6155 # up_b: lex-larger player_id, one protocol link (AirPlay)
6156 up_b = UniversalPlayer(
6157 provider=up_provider,
6158 player_id="up_bbb",
6159 name="Player B",
6160 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6161 protocol_player_ids=["ap_1"],
6162 )
6163 up_b._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6164 up_b._cache.clear()
6165 up_b.update_state(signal_event=False)
6166 up_b.set_initialized()
6167
6168 # Create protocol players
6169 dlna_provider = MockProvider("dlna", mass=mock_mass)
6170 dlna_player = MockPlayer(
6171 dlna_provider,
6172 "dlna_1",
6173 "DLNA",
6174 player_type=PlayerType.PROTOCOL,
6175 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6176 )
6177 dlna_player.set_initialized()
6178
6179 airplay_provider = MockProvider("airplay", mass=mock_mass)
6180 ap_player = MockPlayer(
6181 airplay_provider,
6182 "ap_1",
6183 "AirPlay",
6184 player_type=PlayerType.PROTOCOL,
6185 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6186 )
6187 ap_player.set_initialized()
6188
6189 controller._players = {
6190 "up_aaa": up_a,
6191 "up_bbb": up_b,
6192 "dlna_1": dlna_player,
6193 "ap_1": ap_player,
6194 }
6195
6196 controller._add_protocol_link(up_a, dlna_player, "dlna")
6197 controller._add_protocol_link(up_b, ap_player, "airplay")
6198
6199 return controller, up_a, up_b, dlna_player, ap_player
6200
6201 def test_merge_deterministic_tiebreaker_on_equal_link_counts(
6202 self, mock_mass: MagicMock
6203 ) -> None:
6204 """Test that merges of equally old players resolve to the lex-smaller player_id."""
6205 controller, up_a, up_b, _, ap_player = self._setup_merge_tiebreak(
6206 mock_mass, {"up_aaa": 1000, "up_bbb": 1000}
6207 )
6208
6209 # Call merge from the lex-larger side. Under the old non-deterministic
6210 # behavior the caller (up_b) would have won. With the tiebreaker the
6211 # lex-smaller player_id (up_a) wins regardless of which side calls.
6212 controller._check_merge_universal_players(up_b)
6213
6214 # up_a (lex-smaller) should have absorbed up_b's AirPlay link
6215 protocol_domains = {link.protocol_domain for link in up_a.linked_output_protocols}
6216 assert protocol_domains == {"dlna", "airplay"}
6217
6218 # AirPlay player should now point to up_aaa
6219 assert ap_player.protocol_parent_id == "up_aaa"
6220
6221 # up_b should have no more links
6222 assert len(up_b.linked_output_protocols) == 0
6223
6224 def test_merge_tiebreaker_prefers_the_older_universal_player(
6225 self, mock_mass: MagicMock
6226 ) -> None:
6227 """Test that the player that has been around longest absorbs the other one."""
6228 controller, up_a, up_b, dlna_player, _ = self._setup_merge_tiebreak(
6229 mock_mass, {"up_aaa": 2000, "up_bbb": 1000}
6230 )
6231
6232 controller._check_merge_universal_players(up_a)
6233
6234 # up_b is older, so it wins despite its lex-larger player_id
6235 protocol_domains = {link.protocol_domain for link in up_b.linked_output_protocols}
6236 assert protocol_domains == {"dlna", "airplay"}
6237 assert dlna_player.protocol_parent_id == "up_bbb"
6238 assert len(up_a.linked_output_protocols) == 0
6239
6240 async def test_merge_preserves_moved_protocols_during_parent_cleanup(
6241 self, mock_mass: MagicMock
6242 ) -> None:
6243 """Moved protocol ownership must survive the removed parent's permanent cleanup."""
6244 controller = PlayerController(mock_mass)
6245 up_provider = create_mock_universal_provider(mock_mass)
6246
6247 config_store: dict[str, object] = {
6248 "players/up_keep": {"enabled": True},
6249 "players/up_remove": {"enabled": True},
6250 "players/ap_keep": {"enabled": True},
6251 "players/dlna_live": {"enabled": True},
6252 "players/dlna_cached": {
6253 "enabled": True,
6254 "provider": "dlna--test",
6255 "player_type": "protocol",
6256 "values": {"protocol_parent_id": "up_remove"},
6257 },
6258 }
6259
6260 def config_get(key: str, default: object = None) -> object:
6261 return config_store.get(key, default)
6262
6263 def config_set(key: str, value: object) -> None:
6264 config_store[key] = value
6265
6266 def config_remove(key: str) -> None:
6267 config_store.pop(key, None)
6268
6269 scheduled_tasks: list[Awaitable[object]] = []
6270
6271 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6272 scheduled_tasks.append(task)
6273
6274 mock_mass.config.get = MagicMock(side_effect=config_get)
6275 mock_mass.config.set = MagicMock(side_effect=config_set)
6276 mock_mass.config.remove = MagicMock(side_effect=config_remove)
6277 mock_mass.players = controller
6278 mock_mass.player_queues = MagicMock()
6279 mock_mass.call_later = MagicMock()
6280 mock_mass.loop = MagicMock()
6281 mock_mass.create_task = MagicMock(side_effect=capture_task)
6282
6283 keep = UniversalPlayer(
6284 provider=up_provider,
6285 player_id="up_keep",
6286 name="Keep",
6287 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6288 protocol_player_ids=["ap_keep"],
6289 )
6290 keep._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6291 keep._cache.clear()
6292 keep.update_state(signal_event=False)
6293 keep.set_initialized()
6294
6295 remove = UniversalPlayer(
6296 provider=up_provider,
6297 player_id="up_remove",
6298 name="Remove",
6299 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6300 protocol_player_ids=["dlna_live", "dlna_cached"],
6301 )
6302 remove._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6303 remove._cache.clear()
6304 remove.update_state(signal_event=False)
6305 remove.set_initialized()
6306
6307 airplay_provider = MockProvider("airplay", mass=mock_mass)
6308 ap_keep = MockPlayer(
6309 airplay_provider,
6310 "ap_keep",
6311 "AirPlay",
6312 player_type=PlayerType.PROTOCOL,
6313 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6314 )
6315 ap_keep.set_initialized()
6316
6317 dlna_provider = MockProvider("dlna", mass=mock_mass)
6318 dlna_live = MockPlayer(
6319 dlna_provider,
6320 "dlna_live",
6321 "DLNA",
6322 player_type=PlayerType.PROTOCOL,
6323 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6324 )
6325 dlna_live.set_initialized()
6326
6327 controller._players = {
6328 "up_keep": keep,
6329 "up_remove": remove,
6330 "ap_keep": ap_keep,
6331 "dlna_live": dlna_live,
6332 }
6333
6334 controller._add_protocol_link(keep, ap_keep, "airplay")
6335 controller._add_protocol_link(remove, dlna_live, "dlna")
6336
6337 with patch.object(controller, "_save_universal_player_data"):
6338 controller._check_merge_universal_players(keep)
6339
6340 assert dlna_live.protocol_parent_id == "up_keep"
6341 assert config_store["players/dlna_cached/values/protocol_parent_id"] == "up_keep"
6342 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6343 assert len(unregister_tasks) == 1
6344
6345 await unregister_tasks[0]
6346
6347 assert "up_remove" not in controller._players
6348 assert dlna_live.protocol_parent_id == "up_keep"
6349 assert "dlna_cached" in keep._protocol_player_ids
6350 assert config_store["players/dlna_cached/values/protocol_parent_id"] == "up_keep"
6351
6352 def test_no_merge_same_domain_protocols_on_same_ip(self, mock_mass: MagicMock) -> None:
6353 """
6354 Test that UPs with same-domain protocols on the same IP are NOT merged.
6355
6356 Scenario: 3 squeezelite instances on the same VM (same IP, different real MACs).
6357 Each gets its own universal player. The merge check must NOT merge them,
6358 because merging would combine protocols from the same domain.
6359
6360 Reproduces: https://github.com/music-assistant/support/issues/5266
6361 """
6362 controller = PlayerController(mock_mass)
6363 up_provider = create_mock_universal_provider(mock_mass)
6364
6365 # Create 3 universal players, each with one squeezelite protocol
6366 squeeze_provider = MockProvider("squeezelite", mass=mock_mass)
6367 ups = []
6368 protocols = []
6369 for i in range(1, 4):
6370 mac = f"00:00:00:00:00:{i:02X}"
6371 up = UniversalPlayer(
6372 provider=up_provider,
6373 player_id=f"up00000000000{i}",
6374 name=f"Zone {i}",
6375 device_info=DeviceInfo(model="Squeezelite", manufacturer="Test"),
6376 protocol_player_ids=[mac],
6377 )
6378 up._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
6379 up._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, "192.168.1.50")
6380 up._cache.clear()
6381 up.update_state(signal_event=False)
6382 up.set_initialized()
6383 ups.append(up)
6384
6385 proto = MockPlayer(
6386 squeeze_provider,
6387 mac,
6388 f"squeezeplay: {mac}",
6389 player_type=PlayerType.PROTOCOL,
6390 identifiers={
6391 IdentifierType.MAC_ADDRESS: mac,
6392 IdentifierType.IP_ADDRESS: "192.168.1.50",
6393 },
6394 )
6395 proto.set_initialized()
6396 protocols.append(proto)
6397
6398 controller._players = {}
6399 for up in ups:
6400 controller._players[up.player_id] = up
6401 for proto in protocols:
6402 controller._players[proto.player_id] = proto
6403
6404 # Link each protocol to its UP
6405 for up, proto in zip(ups, protocols, strict=True):
6406 controller._add_protocol_link(up, proto, "squeezelite")
6407
6408 # Verify initial state: each protocol linked to its own UP
6409 for up, proto in zip(ups, protocols, strict=True):
6410 assert proto.protocol_parent_id == up.player_id
6411
6412 # Now run merge check on each UP - none should merge
6413 with patch.object(controller, "_save_universal_player_data"):
6414 for up in ups:
6415 controller._check_merge_universal_players(up)
6416
6417 # All 3 UPs must still exist with their original protocol links
6418 for up, proto in zip(ups, protocols, strict=True):
6419 assert proto.protocol_parent_id == up.player_id, (
6420 f"Protocol {proto.player_id} should still be linked to {up.player_id}, "
6421 f"but is linked to {proto.protocol_parent_id}"
6422 )
6423 assert len(up.linked_output_protocols) == 1, (
6424 f"UP {up.player_id} should have exactly 1 protocol, "
6425 f"but has {len(up.linked_output_protocols)}"
6426 )
6427
6428 @staticmethod
6429 def _setup_merge_scenario(
6430 mock_mass: MagicMock, config_store: dict[str, Any]
6431 ) -> tuple[PlayerController, UniversalPlayer, list[Awaitable[object]]]:
6432 """
6433 Wire a merge scenario for the config carry-over tests.
6434
6435 Universal players 'up_keep' and 'up_remove' share a MAC; with equal
6436 link counts the deterministic tiebreaker keeps 'up_keep' and removes
6437 'up_remove', so 'up_remove' is the loser whose config is carried over.
6438 """
6439 controller = PlayerController(mock_mass)
6440 up_provider = create_mock_universal_provider(mock_mass)
6441
6442 def config_get(key: str, default: object = None) -> object:
6443 return config_store.get(key, default)
6444
6445 def config_set(key: str, value: object) -> None:
6446 config_store[key] = value
6447
6448 def config_remove(key: str) -> None:
6449 config_store.pop(key, None)
6450
6451 scheduled_tasks: list[Awaitable[object]] = []
6452
6453 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6454 scheduled_tasks.append(task)
6455
6456 mock_mass.config.get = MagicMock(side_effect=config_get)
6457 mock_mass.config.set = MagicMock(side_effect=config_set)
6458 mock_mass.config.remove = MagicMock(side_effect=config_remove)
6459 mock_mass.players = controller
6460 mock_mass.player_queues = MagicMock()
6461 mock_mass.call_later = MagicMock()
6462 mock_mass.loop = MagicMock()
6463 mock_mass.create_task = MagicMock(side_effect=capture_task)
6464
6465 keep = UniversalPlayer(
6466 provider=up_provider,
6467 player_id="up_keep",
6468 name="Keep (Universal)",
6469 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6470 protocol_player_ids=["ap_keep"],
6471 )
6472 keep._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6473 keep._cache.clear()
6474 keep.update_state(signal_event=False)
6475 keep.set_initialized()
6476
6477 remove = UniversalPlayer(
6478 provider=up_provider,
6479 player_id="up_remove",
6480 name="Remove (Universal)",
6481 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6482 protocol_player_ids=["dlna_live"],
6483 )
6484 remove._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6485 remove._cache.clear()
6486 remove.update_state(signal_event=False)
6487 remove.set_initialized()
6488
6489 ap_keep = MockPlayer(
6490 MockProvider("airplay", mass=mock_mass),
6491 "ap_keep",
6492 "AirPlay",
6493 player_type=PlayerType.PROTOCOL,
6494 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6495 )
6496 ap_keep.set_initialized()
6497
6498 dlna_live = MockPlayer(
6499 MockProvider("dlna", mass=mock_mass),
6500 "dlna_live",
6501 "DLNA",
6502 player_type=PlayerType.PROTOCOL,
6503 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6504 )
6505 dlna_live.set_initialized()
6506
6507 controller._players = {
6508 "up_keep": keep,
6509 "up_remove": remove,
6510 "ap_keep": ap_keep,
6511 "dlna_live": dlna_live,
6512 }
6513 controller._add_protocol_link(keep, ap_keep, "airplay")
6514 controller._add_protocol_link(remove, dlna_live, "dlna")
6515 return controller, keep, scheduled_tasks
6516
6517 async def test_merge_carries_over_universal_config(self, mock_mass: MagicMock) -> None:
6518 """Merge migrates name, values, DSP and queue settings from loser to keeper."""
6519 config_store: dict[str, Any] = {
6520 "players/up_keep": {
6521 "enabled": True,
6522 "name": "Keep (Universal)",
6523 "default_name": "Keep (Universal)",
6524 "values": {"volume_control": "keep_ctrl"},
6525 },
6526 "players/up_remove": {
6527 "enabled": True,
6528 "name": "Living Room",
6529 "default_name": "Remove (Universal)",
6530 "values": {
6531 "hide_in_ui": True,
6532 "volume_control": "remove_ctrl",
6533 "device_info": {"model": "Test", "manufacturer": "Test"},
6534 },
6535 },
6536 "players/ap_keep": {"enabled": True},
6537 "players/dlna_live": {"enabled": True},
6538 "player_dsp/up_remove": {"enabled": True, "filters": []},
6539 "player_queues/up_remove": {
6540 "queue_id": "up_remove",
6541 "values": {"crossfade_duration": 5},
6542 },
6543 }
6544 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6545
6546 with patch.object(controller, "_save_universal_player_data"):
6547 controller._check_merge_universal_players(keep)
6548
6549 # the custom display name and non-conflicting values moved onto the keeper
6550 assert config_store["players/up_keep/name"] == "Living Room"
6551 assert config_store["players/up_keep/values/hide_in_ui"] is True
6552 # values the keeper has explicitly set are not overwritten
6553 assert "players/up_keep/values/volume_control" not in config_store
6554 # wrapper bookkeeping is not carried over
6555 assert "players/up_keep/values/device_info" not in config_store
6556 # DSP moved wholesale and queue settings moved and re-keyed onto the keeper
6557 assert config_store["player_dsp/up_keep"] == {"enabled": True, "filters": []}
6558 assert config_store["player_queues/up_keep"] == {
6559 "queue_id": "up_keep",
6560 "values": {"crossfade_duration": 5},
6561 }
6562 assert "player_queues/up_remove" not in config_store
6563 # the keeper's in-place config gets reloaded to apply the migrated values
6564 assert any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
6565
6566 # and the losing universal player is still permanently removed
6567 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6568 assert len(unregister_tasks) == 1
6569 await unregister_tasks[0]
6570 assert "up_remove" not in controller._players
6571
6572 def test_merge_keeps_keeper_explicit_values(self, mock_mass: MagicMock) -> None:
6573 """Values explicitly set on the keeper win over the losing player's."""
6574 config_store: dict[str, Any] = {
6575 "players/up_keep": {
6576 "enabled": True,
6577 "name": "Kitchen",
6578 "default_name": "Keep (Universal)",
6579 "values": {"hide_in_ui": False},
6580 },
6581 "players/up_remove": {
6582 "enabled": True,
6583 "name": "Living Room",
6584 "default_name": "Remove (Universal)",
6585 "values": {"hide_in_ui": True, "flow_mode": True},
6586 },
6587 "players/ap_keep": {"enabled": True},
6588 "players/dlna_live": {"enabled": True},
6589 "player_dsp/up_keep": {"enabled": False, "filters": ["keep"]},
6590 "player_dsp/up_remove": {"enabled": True, "filters": ["remove"]},
6591 }
6592 controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6593
6594 with patch.object(controller, "_save_universal_player_data"):
6595 controller._check_merge_universal_players(keep)
6596
6597 # the keeper's own custom name and DSP config win
6598 assert "players/up_keep/name" not in config_store
6599 assert config_store["players/up_keep"]["name"] == "Kitchen"
6600 assert config_store["player_dsp/up_keep"] == {"enabled": False, "filters": ["keep"]}
6601 # the keeper's explicit value is not overwritten
6602 assert "players/up_keep/values/hide_in_ui" not in config_store
6603 # non-conflicting values still migrate
6604 assert config_store["players/up_keep/values/flow_mode"] is True
6605
6606 def test_merge_does_not_carry_over_the_creation_moment(self, mock_mass: MagicMock) -> None:
6607 """
6608 The absorbed player's creation moment stays behind.
6609
6610 It is bookkeeping of that wrapper alone; copying it onto the keeper would let a
6611 later merge discard the keeper's older - and therefore established - player id.
6612 """
6613 config_store: dict[str, Any] = {
6614 "players/up_keep": {
6615 "enabled": True,
6616 "name": "Keep (Universal)",
6617 "default_name": "Keep (Universal)",
6618 "values": {},
6619 },
6620 "players/up_remove": {
6621 "enabled": True,
6622 "name": "Remove (Universal)",
6623 "default_name": "Remove (Universal)",
6624 "values": {CONF_CREATED_AT: 5000},
6625 },
6626 "players/ap_keep": {"enabled": True},
6627 "players/dlna_live": {"enabled": True},
6628 }
6629 controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6630
6631 with patch.object(controller, "_save_universal_player_data"):
6632 controller._check_merge_universal_players(keep)
6633
6634 assert f"players/up_keep/values/{CONF_CREATED_AT}" not in config_store
6635 assert CONF_CREATED_AT not in config_store["players/up_keep"]["values"]
6636
6637 def test_merge_repoints_group_memberships(self, mock_mass: MagicMock) -> None:
6638 """Merge re-points group memberships from the removed wrapper to the keeper."""
6639 config_store: dict[str, Any] = {
6640 "players/up_keep": {
6641 "enabled": True,
6642 "name": "Keep (Universal)",
6643 "default_name": "Keep (Universal)",
6644 "values": {},
6645 },
6646 "players/up_remove": {
6647 "enabled": True,
6648 "name": "Remove (Universal)",
6649 "default_name": "Remove (Universal)",
6650 "values": {},
6651 },
6652 "players/ap_keep": {"enabled": True},
6653 "players/dlna_live": {"enabled": True},
6654 # nested players dict scanned by _update_group_memberships
6655 "players": {
6656 "group_1": {
6657 "enabled": True,
6658 "values": {
6659 "group_members": ["up_remove", "other"],
6660 "allowed_members": ["up_remove"],
6661 },
6662 },
6663 "group_2": {
6664 "enabled": True,
6665 # keeper already listed -> no duplicate after re-point
6666 "values": {"group_members": ["up_keep", "up_remove"]},
6667 },
6668 },
6669 }
6670 controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6671
6672 with patch.object(controller, "_save_universal_player_data"):
6673 controller._check_merge_universal_players(keep)
6674
6675 assert config_store["players/group_1/values/group_members"] == ["up_keep", "other"]
6676 assert config_store["players/group_1/values/allowed_members"] == ["up_keep"]
6677 # no duplicate when the keeper is already a member
6678 assert config_store["players/group_2/values/group_members"] == ["up_keep"]
6679
6680 async def test_merge_stops_playing_loser_before_unregister(self, mock_mass: MagicMock) -> None:
6681 """A playing losing wrapper is stopped before it is permanently removed."""
6682 config_store: dict[str, Any] = {
6683 "players/up_keep": {"enabled": True},
6684 "players/up_remove": {"enabled": True},
6685 "players/ap_keep": {"enabled": True},
6686 "players/dlna_live": {"enabled": True},
6687 }
6688 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6689 controller._players["up_remove"]._attr_playback_state = PlaybackState.PLAYING
6690
6691 call_order: list[str] = []
6692 mock_mass.player_queues.stop = AsyncMock(
6693 side_effect=lambda *_a, **_k: call_order.append("stop")
6694 )
6695
6696 with patch.object(controller, "_save_universal_player_data"):
6697 controller._check_merge_universal_players(keep)
6698
6699 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6700
6701 def _record_unregister(*_a: Any, **_k: Any) -> None:
6702 call_order.append("unregister")
6703
6704 with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
6705 await task
6706
6707 assert call_order == ["stop", "unregister"]
6708 mock_mass.player_queues.stop.assert_awaited_once_with("up_remove")
6709
6710 async def test_merge_hands_the_user_filters_to_the_keeper(self, mock_mass: MagicMock) -> None:
6711 """The absorbed wrapper hands its access filter entries to the keeper."""
6712 config_store: dict[str, Any] = {
6713 "players/up_keep": {"enabled": True},
6714 "players/up_remove": {"enabled": True},
6715 "players/ap_keep": {"enabled": True},
6716 "players/dlna_live": {"enabled": True},
6717 }
6718 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6719
6720 with patch.object(controller, "_save_universal_player_data"):
6721 controller._check_merge_universal_players(keep)
6722
6723 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6724 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
6725 await task
6726
6727 mock_unregister.assert_awaited_once_with(
6728 "up_remove", permanent=True, replacement_player_id="up_keep"
6729 )
6730
6731 async def test_merge_idle_loser_not_stopped(self, mock_mass: MagicMock) -> None:
6732 """An idle losing wrapper is removed without a stop command."""
6733 config_store: dict[str, Any] = {
6734 "players/up_keep": {"enabled": True},
6735 "players/up_remove": {"enabled": True},
6736 "players/ap_keep": {"enabled": True},
6737 "players/dlna_live": {"enabled": True},
6738 }
6739 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6740 mock_mass.player_queues.stop = AsyncMock()
6741
6742 with patch.object(controller, "_save_universal_player_data"):
6743 controller._check_merge_universal_players(keep)
6744
6745 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6746 await task
6747
6748 mock_mass.player_queues.stop.assert_not_awaited()
6749 assert "up_remove" not in controller._players
6750
6751
6752class TestUniversalPlayerReplacement:
6753 """Tests for replacing a universal player with a native player."""
6754
6755 @pytest.mark.parametrize(
6756 ("airplay_registered", "missing_protocol_id", "missing_domain", "missing_enabled"),
6757 [
6758 (True, "dlna_cached", "dlna", True),
6759 (True, "dlna_cached", "dlna", False),
6760 (False, "spb_cached", "sendspin", False),
6761 ],
6762 )
6763 async def test_replace_migrates_missing_cached_protocol_during_parent_cleanup(
6764 self,
6765 mock_mass: MagicMock,
6766 airplay_registered: bool,
6767 missing_protocol_id: str,
6768 missing_domain: str,
6769 missing_enabled: bool,
6770 ) -> None:
6771 """Missing cached protocol ownership must survive replacement and cleanup."""
6772 controller = PlayerController(mock_mass)
6773 up_provider = create_mock_universal_provider(mock_mass)
6774
6775 missing_values = {"protocol_parent_id": "up_old"}
6776 if missing_domain == "sendspin":
6777 missing_values[CONF_UNDERLYING_PLAYER_ID] = "ap_live"
6778 config_store: dict[str, Any] = {
6779 CONF_PLAYERS: {
6780 "sonos_1": {"enabled": True, "values": {}},
6781 "up_old": {"enabled": airplay_registered, "values": {}},
6782 "ap_live": {
6783 "enabled": airplay_registered,
6784 "provider": "airplay--test",
6785 "player_type": "protocol",
6786 "values": {"protocol_parent_id": "up_old"},
6787 },
6788 missing_protocol_id: {
6789 "enabled": missing_enabled,
6790 "provider": f"{missing_domain}--test",
6791 "player_type": "protocol",
6792 "values": missing_values,
6793 },
6794 }
6795 }
6796 _wire_nested_config(mock_mass, config_store)
6797
6798 scheduled_tasks: list[Awaitable[object]] = []
6799
6800 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6801 scheduled_tasks.append(task)
6802
6803 mock_mass.players = controller
6804 mock_mass.player_queues = MagicMock()
6805 mock_mass.call_later = MagicMock()
6806 mock_mass.loop = MagicMock()
6807 mock_mass.create_task = MagicMock(side_effect=capture_task)
6808
6809 universal = UniversalPlayer(
6810 provider=up_provider,
6811 player_id="up_old",
6812 name="Old Universal",
6813 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6814 protocol_player_ids=["ap_live", missing_protocol_id],
6815 )
6816 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6817 universal._cache.clear()
6818 universal.update_state(signal_event=False)
6819
6820 native_provider = MockProvider("sonos", mass=mock_mass)
6821 native = MockPlayer(
6822 native_provider,
6823 "sonos_1",
6824 "Sonos",
6825 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6826 )
6827 native.set_initialized()
6828
6829 airplay_provider = MockProvider("airplay", mass=mock_mass)
6830 ap_live = MockPlayer(
6831 airplay_provider,
6832 "ap_live",
6833 "AirPlay",
6834 player_type=PlayerType.PROTOCOL,
6835 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6836 )
6837 ap_live.set_initialized()
6838
6839 controller._players = {"up_old": universal, "sonos_1": native}
6840 if airplay_registered:
6841 controller._players["ap_live"] = ap_live
6842 controller._add_protocol_link(universal, ap_live, "airplay")
6843 else:
6844 universal.set_linked_output_protocols(
6845 [
6846 LinkedOutputProtocol(
6847 output_protocol_id="ap_live",
6848 protocol_domain="airplay",
6849 priority=10,
6850 )
6851 ]
6852 )
6853 universal.set_linked_output_protocols(
6854 [
6855 *universal.linked_output_protocols,
6856 LinkedOutputProtocol(
6857 output_protocol_id=missing_protocol_id,
6858 protocol_domain=missing_domain,
6859 priority=20,
6860 derived_from="ap_live" if missing_domain == "sendspin" else None,
6861 ),
6862 ]
6863 )
6864
6865 controller._check_replace_universal_player(native)
6866
6867 if airplay_registered:
6868 assert ap_live.protocol_parent_id == "sonos_1"
6869 else:
6870 assert (
6871 config_store[CONF_PLAYERS]["ap_live"]["values"][CONF_PROTOCOL_PARENT_ID]
6872 == "sonos_1"
6873 )
6874 assert set(config_store[CONF_PLAYERS]["sonos_1"]["values"]["linked_protocol_ids"]) == {
6875 "ap_live",
6876 missing_protocol_id,
6877 }
6878 assert (
6879 config_store[CONF_PLAYERS][missing_protocol_id]["values"][CONF_PROTOCOL_PARENT_ID]
6880 == "sonos_1"
6881 )
6882 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6883 assert len(unregister_tasks) == 1
6884
6885 await unregister_tasks[0]
6886
6887 assert "up_old" not in controller._players
6888 if airplay_registered:
6889 assert ap_live.protocol_parent_id == "sonos_1"
6890 else:
6891 assert (
6892 config_store[CONF_PLAYERS]["ap_live"]["values"][CONF_PROTOCOL_PARENT_ID]
6893 == "sonos_1"
6894 )
6895 assert (
6896 config_store[CONF_PLAYERS][missing_protocol_id]["values"][CONF_PROTOCOL_PARENT_ID]
6897 == "sonos_1"
6898 )
6899 assert set(config_store[CONF_PLAYERS]["sonos_1"]["values"]["linked_protocol_ids"]) == {
6900 "ap_live",
6901 missing_protocol_id,
6902 }
6903
6904 def test_replace_keeps_universal_when_all_links_refused(self, mock_mass: MagicMock) -> None:
6905 """
6906 Keep the universal player when no link could migrate.
6907
6908 If the native player already has an active link from the same protocol
6909 domain as every protocol on the universal player, each `_add_protocol_link`
6910 call refuses and leaves the protocol's parent unchanged. The universal player
6911 must NOT be permanently unregistered in that case, otherwise it disappears
6912 from the UI on restart.
6913 """
6914 controller = PlayerController(mock_mass)
6915 up_provider = create_mock_universal_provider(mock_mass)
6916
6917 config_store: dict[str, object] = {
6918 "players/cast_1": {"enabled": True},
6919 "players/up_old": {"enabled": True},
6920 "players/spb_old": {"enabled": True},
6921 "players/spb_new": {"enabled": True},
6922 }
6923
6924 def config_get(key: str, default: object = None) -> object:
6925 return config_store.get(key, default)
6926
6927 def config_set(key: str, value: object) -> None:
6928 config_store[key] = value
6929
6930 def config_remove(key: str) -> None:
6931 config_store.pop(key, None)
6932
6933 scheduled_tasks: list[Awaitable[object]] = []
6934
6935 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6936 scheduled_tasks.append(task)
6937
6938 mock_mass.config.get = MagicMock(side_effect=config_get)
6939 mock_mass.config.set = MagicMock(side_effect=config_set)
6940 mock_mass.config.remove = MagicMock(side_effect=config_remove)
6941 mock_mass.players = controller
6942 mock_mass.player_queues = MagicMock()
6943 mock_mass.call_later = MagicMock()
6944 mock_mass.loop = MagicMock()
6945 mock_mass.create_task = MagicMock(side_effect=capture_task)
6946
6947 universal = UniversalPlayer(
6948 provider=up_provider,
6949 player_id="up_old",
6950 name="Soundbar (Universal)",
6951 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6952 protocol_player_ids=["spb_old"],
6953 )
6954 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6955 universal._cache.clear()
6956 universal.update_state(signal_event=False)
6957 universal.set_initialized()
6958
6959 cast_provider = MockProvider("cast", mass=mock_mass)
6960 native = MockPlayer(
6961 cast_provider,
6962 "cast_1",
6963 "Soundbar",
6964 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6965 )
6966 native.set_initialized()
6967
6968 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
6969 spb_old = MockPlayer(
6970 sendspin_provider,
6971 "spb_old",
6972 "Soundbar (Sendspin)",
6973 player_type=PlayerType.PROTOCOL,
6974 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6975 )
6976 spb_old.set_initialized()
6977 spb_new = MockPlayer(
6978 sendspin_provider,
6979 "spb_new",
6980 "Soundbar (Sendspin v2)",
6981 player_type=PlayerType.PROTOCOL,
6982 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6983 )
6984 spb_new.set_initialized()
6985
6986 controller._players = {
6987 "up_old": universal,
6988 "cast_1": native,
6989 "spb_old": spb_old,
6990 "spb_new": spb_new,
6991 }
6992
6993 controller._add_protocol_link(universal, spb_old, "sendspin")
6994 controller._add_protocol_link(native, spb_new, "sendspin")
6995
6996 controller._check_replace_universal_player(native)
6997
6998 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6999 assert unregister_tasks == []
7000 assert "up_old" in controller._players
7001 assert spb_old.protocol_parent_id == "up_old"
7002 assert any(
7003 link.output_protocol_id == "spb_old" for link in universal.linked_output_protocols
7004 )
7005
7006 def test_replace_keeps_universal_when_some_links_refused(self, mock_mass: MagicMock) -> None:
7007 """
7008 Keep the universal player when only some of its links could migrate.
7009
7010 The native player already owns the sendspin domain, so the universal player's
7011 sendspin link is refused while its airplay link migrates. The universal player
7012 must be kept (holding the refused link) instead of being unregistered, which
7013 would orphan the refused protocol during cleanup.
7014 """
7015 controller = PlayerController(mock_mass)
7016 up_provider = create_mock_universal_provider(mock_mass)
7017
7018 config_store: dict[str, object] = {
7019 "players/cast_1": {"enabled": True},
7020 "players/up_old": {"enabled": True},
7021 "players/spb_old": {"enabled": True},
7022 "players/spb_new": {"enabled": True},
7023 "players/ap_old": {"enabled": True},
7024 }
7025
7026 def config_get(key: str, default: object = None) -> object:
7027 return config_store.get(key, default)
7028
7029 def config_set(key: str, value: object) -> None:
7030 config_store[key] = value
7031
7032 def config_remove(key: str) -> None:
7033 config_store.pop(key, None)
7034
7035 scheduled_tasks: list[Awaitable[object]] = []
7036
7037 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7038 scheduled_tasks.append(task)
7039
7040 mock_mass.config.get = MagicMock(side_effect=config_get)
7041 mock_mass.config.set = MagicMock(side_effect=config_set)
7042 mock_mass.config.remove = MagicMock(side_effect=config_remove)
7043 mock_mass.players = controller
7044 mock_mass.player_queues = MagicMock()
7045 mock_mass.call_later = MagicMock()
7046 mock_mass.loop = MagicMock()
7047 mock_mass.create_task = MagicMock(side_effect=capture_task)
7048
7049 universal = UniversalPlayer(
7050 provider=up_provider,
7051 player_id="up_old",
7052 name="Soundbar (Universal)",
7053 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7054 protocol_player_ids=["spb_old", "ap_old"],
7055 )
7056 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7057 universal._cache.clear()
7058 universal.update_state(signal_event=False)
7059 universal.set_initialized()
7060
7061 cast_provider = MockProvider("cast", mass=mock_mass)
7062 native = MockPlayer(
7063 cast_provider,
7064 "cast_1",
7065 "Soundbar",
7066 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7067 )
7068 native.set_initialized()
7069
7070 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7071 spb_old = MockPlayer(
7072 sendspin_provider,
7073 "spb_old",
7074 "Soundbar (Sendspin)",
7075 player_type=PlayerType.PROTOCOL,
7076 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7077 )
7078 spb_old.set_initialized()
7079 spb_new = MockPlayer(
7080 sendspin_provider,
7081 "spb_new",
7082 "Soundbar (Sendspin v2)",
7083 player_type=PlayerType.PROTOCOL,
7084 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7085 )
7086 spb_new.set_initialized()
7087
7088 airplay_provider = MockProvider("airplay", mass=mock_mass)
7089 ap_old = MockPlayer(
7090 airplay_provider,
7091 "ap_old",
7092 "Soundbar (AirPlay)",
7093 player_type=PlayerType.PROTOCOL,
7094 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7095 )
7096 ap_old.set_initialized()
7097
7098 controller._players = {
7099 "up_old": universal,
7100 "cast_1": native,
7101 "spb_old": spb_old,
7102 "spb_new": spb_new,
7103 "ap_old": ap_old,
7104 }
7105
7106 controller._add_protocol_link(universal, spb_old, "sendspin")
7107 controller._add_protocol_link(universal, ap_old, "airplay")
7108 controller._add_protocol_link(native, spb_new, "sendspin")
7109
7110 controller._check_replace_universal_player(native)
7111
7112 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7113 assert unregister_tasks == []
7114 assert "up_old" in controller._players
7115 # The refused sendspin link stays owned by the universal player.
7116 assert spb_old.protocol_parent_id == "up_old"
7117 assert any(
7118 link.output_protocol_id == "spb_old" for link in universal.linked_output_protocols
7119 )
7120 # The airplay link migrated to the native player and left the universal player.
7121 assert ap_old.protocol_parent_id == "cast_1"
7122 assert any(link.output_protocol_id == "ap_old" for link in native.linked_output_protocols)
7123 assert all(
7124 link.output_protocol_id != "ap_old" for link in universal.linked_output_protocols
7125 )
7126
7127 def test_replace_keeps_cached_protocols_when_a_link_is_refused(
7128 self, mock_mass: MagicMock
7129 ) -> None:
7130 """
7131 Keep cached-only protocol ownership on a universal player that survives.
7132
7133 The native player refuses the sendspin link, so the universal player stays in
7134 charge. Ownership that only exists in config must not be handed over, so a
7135 protocol derived from a refused one keeps pointing at the parent it will link to.
7136 """
7137 controller = PlayerController(mock_mass)
7138 up_provider = create_mock_universal_provider(mock_mass)
7139
7140 config_store: dict[str, Any] = {
7141 "players/cast_1": {"enabled": True},
7142 "players/up_old": {"enabled": True},
7143 "players/spb_old": {"enabled": True},
7144 "players/spb_new": {"enabled": True},
7145 "players/ap_old": {"enabled": True},
7146 "players/dlna_cached": {"enabled": False},
7147 }
7148 mock_mass.config.get = MagicMock(side_effect=config_store.get)
7149 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
7150 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
7151 mock_mass.players = controller
7152 mock_mass.player_queues = MagicMock()
7153 mock_mass.call_later = MagicMock()
7154 mock_mass.loop = MagicMock()
7155 mock_mass.create_task = MagicMock()
7156
7157 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7158 universal = UniversalPlayer(
7159 provider=up_provider,
7160 player_id="up_old",
7161 name="Soundbar (Universal)",
7162 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7163 protocol_player_ids=["spb_old", "ap_old", "dlna_cached"],
7164 )
7165 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7166 universal._cache.clear()
7167 universal.update_state(signal_event=False)
7168 universal.set_initialized()
7169
7170 native = MockPlayer(
7171 MockProvider("cast", mass=mock_mass), "cast_1", "Soundbar", identifiers=identifiers
7172 )
7173 native.set_initialized()
7174 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7175 spb_old = MockPlayer(
7176 sendspin_provider,
7177 "spb_old",
7178 "Soundbar (Sendspin)",
7179 player_type=PlayerType.PROTOCOL,
7180 identifiers=identifiers,
7181 )
7182 spb_old.set_initialized()
7183 spb_new = MockPlayer(
7184 sendspin_provider,
7185 "spb_new",
7186 "Soundbar (Sendspin v2)",
7187 player_type=PlayerType.PROTOCOL,
7188 identifiers=identifiers,
7189 )
7190 spb_new.set_initialized()
7191 ap_old = MockPlayer(
7192 MockProvider("airplay", mass=mock_mass),
7193 "ap_old",
7194 "Soundbar (AirPlay)",
7195 player_type=PlayerType.PROTOCOL,
7196 identifiers=identifiers,
7197 )
7198 ap_old.set_initialized()
7199
7200 controller._players = {
7201 "up_old": universal,
7202 "cast_1": native,
7203 "spb_old": spb_old,
7204 "spb_new": spb_new,
7205 "ap_old": ap_old,
7206 }
7207 controller._add_protocol_link(universal, spb_old, "sendspin")
7208 controller._add_protocol_link(universal, ap_old, "airplay")
7209 controller._add_protocol_link(native, spb_new, "sendspin")
7210
7211 controller._check_replace_universal_player(native)
7212
7213 # the registered airplay link moved, the refused sendspin link did not
7214 assert ap_old.protocol_parent_id == "cast_1"
7215 assert spb_old.protocol_parent_id == "up_old"
7216 # the cached-only protocol stays with the universal player that remains in charge
7217 assert "dlna_cached" in universal._protocol_player_ids
7218 assert "dlna_cached" not in (
7219 config_store.get("players/cast_1/values/linked_protocol_ids") or []
7220 )
7221 assert config_store.get("players/dlna_cached/values/protocol_parent_id") != "cast_1"
7222
7223 @staticmethod
7224 def _setup_carry_over_scenario(
7225 mock_mass: MagicMock, config_store: dict[str, Any]
7226 ) -> tuple[PlayerController, MockPlayer, list[Awaitable[object]]]:
7227 """
7228 Wire a replacement scenario for the config carry-over tests.
7229
7230 Universal player 'up_old' (holding protocol player 'ap_live') is
7231 replaceable by native player 'cast_1'.
7232 """
7233 controller = PlayerController(mock_mass)
7234 up_provider = create_mock_universal_provider(mock_mass)
7235
7236 def config_get(key: str, default: object = None) -> object:
7237 return config_store.get(key, default)
7238
7239 def config_set(key: str, value: object) -> None:
7240 config_store[key] = value
7241
7242 def config_remove(key: str) -> None:
7243 config_store.pop(key, None)
7244
7245 scheduled_tasks: list[Awaitable[object]] = []
7246
7247 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7248 scheduled_tasks.append(task)
7249
7250 mock_mass.config.get = MagicMock(side_effect=config_get)
7251 mock_mass.config.set = MagicMock(side_effect=config_set)
7252 mock_mass.config.remove = MagicMock(side_effect=config_remove)
7253 mock_mass.players = controller
7254 mock_mass.player_queues = MagicMock()
7255 mock_mass.player_queues.get.return_value = None
7256 mock_mass.call_later = MagicMock()
7257 mock_mass.loop = MagicMock()
7258 mock_mass.create_task = MagicMock(side_effect=capture_task)
7259
7260 universal = UniversalPlayer(
7261 provider=up_provider,
7262 player_id="up_old",
7263 name="Soundbar (Universal)",
7264 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7265 protocol_player_ids=["ap_live"],
7266 )
7267 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7268 universal._cache.clear()
7269 universal.update_state(signal_event=False)
7270 universal.set_initialized()
7271
7272 cast_provider = MockProvider("cast", mass=mock_mass)
7273 native = MockPlayer(
7274 cast_provider,
7275 "cast_1",
7276 "Soundbar",
7277 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7278 )
7279 native.set_initialized()
7280
7281 airplay_provider = MockProvider("airplay", mass=mock_mass)
7282 ap_live = MockPlayer(
7283 airplay_provider,
7284 "ap_live",
7285 "Soundbar (AirPlay)",
7286 player_type=PlayerType.PROTOCOL,
7287 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7288 )
7289 ap_live.set_initialized()
7290
7291 controller._players = {"up_old": universal, "cast_1": native, "ap_live": ap_live}
7292 controller._add_protocol_link(universal, ap_live, "airplay")
7293 return controller, native, scheduled_tasks
7294
7295 async def test_replace_carries_over_universal_config(self, mock_mass: MagicMock) -> None:
7296 """Replacement migrates name, values, DSP and queue settings to the native player."""
7297 config_store: dict[str, Any] = {
7298 # fresh native configs store name == default_name; that must not
7299 # count as a user override blocking the name carry-over
7300 "players/cast_1": {
7301 "enabled": True,
7302 "name": "Soundbar",
7303 "default_name": "Soundbar",
7304 "values": {"volume_control": "native"},
7305 },
7306 "players/up_old": {
7307 "enabled": True,
7308 "name": "Living Room",
7309 "default_name": "Soundbar (Universal)",
7310 "values": {
7311 "hide_in_ui": True,
7312 "volume_control": "up_volume_control",
7313 "linked_protocol_ids": ["ap_live", "ghost_proto"],
7314 "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
7315 "device_info": {"model": "Test", "manufacturer": "Test"},
7316 "ap_live||protocol||output_codec": "flac",
7317 },
7318 },
7319 "players/ap_live": {"enabled": True},
7320 "player_dsp/up_old": {"enabled": True, "filters": []},
7321 "player_queues/up_old": {"queue_id": "up_old", "values": {"crossfade_duration": 5}},
7322 }
7323 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7324 mock_mass, config_store
7325 )
7326
7327 controller._check_replace_universal_player(native)
7328
7329 # the custom display name and user-set values moved onto the native player
7330 assert config_store["players/cast_1/name"] == "Living Room"
7331 assert config_store["players/cast_1/values/hide_in_ui"] is True
7332 # values the native player has explicitly set are not overwritten
7333 assert "players/cast_1/values/volume_control" not in config_store
7334 # wrapper bookkeeping and protocol-mirror values are not carried over
7335 assert "players/cast_1/values/device_identifiers" not in config_store
7336 assert "players/cast_1/values/device_info" not in config_store
7337 assert "players/cast_1/values/ap_live||protocol||output_codec" not in config_store
7338 # linked ids come from the protocol move machinery, not the universal's raw list
7339 assert config_store["players/cast_1/values/linked_protocol_ids"] == ["ap_live"]
7340 # DSP settings moved wholesale
7341 assert config_store["player_dsp/cast_1"] == {"enabled": True, "filters": []}
7342 # queue settings moved and re-keyed onto the native player's queue
7343 assert config_store["player_queues/cast_1"] == {
7344 "queue_id": "cast_1",
7345 "values": {"crossfade_duration": 5},
7346 }
7347 assert "player_queues/up_old" not in config_store
7348 # the native player's in-place config gets reloaded to apply the new values
7349 assert any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
7350
7351 # and the universal player is still permanently removed
7352 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7353 assert len(unregister_tasks) == 1
7354 await unregister_tasks[0]
7355 assert "up_old" not in controller._players
7356 # migrated values survive the permanent cleanup of the universal player
7357 assert config_store["players/cast_1/name"] == "Living Room"
7358 assert "players/up_old" not in config_store
7359 assert "player_dsp/up_old" not in config_store
7360
7361 def test_replace_carries_over_nothing_without_user_config(self, mock_mass: MagicMock) -> None:
7362 """A non-customized universal player migrates nothing (no default-name carry)."""
7363 config_store: dict[str, Any] = {
7364 "players/cast_1": {"enabled": True},
7365 "players/up_old": {
7366 "enabled": True,
7367 "name": "Soundbar (Universal)",
7368 "default_name": "Soundbar (Universal)",
7369 "values": {"linked_protocol_ids": ["ap_live"]},
7370 },
7371 "players/ap_live": {"enabled": True},
7372 }
7373 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7374 mock_mass, config_store
7375 )
7376
7377 controller._check_replace_universal_player(native)
7378
7379 assert "players/cast_1/name" not in config_store
7380 assert "player_dsp/cast_1" not in config_store
7381 assert "player_queues/cast_1" not in config_store
7382 # nothing migrated, so no config reload is scheduled
7383 assert not any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
7384 assert any("unregister" in repr(t) for t in scheduled_tasks)
7385
7386 def test_replace_keeps_native_name_dsp_and_queue_values(self, mock_mass: MagicMock) -> None:
7387 """Values explicitly set on the native player win over the universal player's."""
7388 config_store: dict[str, Any] = {
7389 "players/cast_1": {"enabled": True, "name": "Kitchen", "default_name": "Soundbar"},
7390 "players/up_old": {
7391 "enabled": True,
7392 "name": "Living Room",
7393 "default_name": "Soundbar (Universal)",
7394 "values": {"hide_in_ui": True},
7395 },
7396 "players/ap_live": {"enabled": True},
7397 "player_dsp/cast_1": {"enabled": False, "filters": ["native"]},
7398 "player_dsp/up_old": {"enabled": True, "filters": ["universal"]},
7399 "player_queues/cast_1": {"queue_id": "cast_1", "values": {"crossfade_duration": 2}},
7400 "player_queues/up_old": {
7401 "queue_id": "up_old",
7402 "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
7403 },
7404 }
7405 controller, native, _ = self._setup_carry_over_scenario(mock_mass, config_store)
7406
7407 controller._check_replace_universal_player(native)
7408
7409 # the native player's own custom name and DSP config win
7410 assert "players/cast_1/name" not in config_store
7411 assert config_store["players/cast_1"]["name"] == "Kitchen"
7412 assert config_store["player_dsp/cast_1"] == {"enabled": False, "filters": ["native"]}
7413 # queue values merge without overwriting the native queue's own values
7414 assert config_store["player_queues/cast_1"] == {
7415 "queue_id": "cast_1",
7416 "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
7417 }
7418 assert "player_queues/up_old" not in config_store
7419 # non-conflicting values still migrate
7420 assert config_store["players/cast_1/values/hide_in_ui"] is True
7421
7422 async def test_reapply_player_config_refreshes_native_player(
7423 self, mock_mass: MagicMock
7424 ) -> None:
7425 """Reloading the config applies a migrated custom name to the live player."""
7426 controller = PlayerController(mock_mass)
7427 mock_mass.players = controller
7428 mock_mass.player_queues = MagicMock()
7429 mock_mass.call_later = MagicMock()
7430 provider = MockProvider("cast", mass=mock_mass)
7431 native = MockPlayer(provider, "cast_1", "Soundbar")
7432 native.set_initialized()
7433 controller._players = {"cast_1": native}
7434
7435 new_config = PlayerConfig.parse(
7436 [],
7437 {
7438 "player_id": "cast_1",
7439 "provider": "cast",
7440 "name": "Living Room",
7441 "default_name": "Soundbar",
7442 },
7443 )
7444 mock_mass.config.get_player_config = AsyncMock(return_value=new_config)
7445
7446 await controller._reapply_player_config("cast_1")
7447
7448 assert native.config is new_config
7449 assert native.display_name == "Living Room"
7450 signaled_events = [call.args[0] for call in mock_mass.signal_event.call_args_list]
7451 assert EventType.PLAYER_CONFIG_UPDATED in signaled_events
7452
7453 async def test_replace_repoints_group_memberships(self, mock_mass: MagicMock) -> None:
7454 """Replacement re-points group memberships from the wrapper to the native player."""
7455 config_store: dict[str, Any] = {
7456 "players/cast_1": {"enabled": True},
7457 "players/up_old": {
7458 "enabled": True,
7459 "name": "Soundbar (Universal)",
7460 "default_name": "Soundbar (Universal)",
7461 "values": {"linked_protocol_ids": ["ap_live"]},
7462 },
7463 "players/ap_live": {"enabled": True},
7464 # nested players dict scanned by _update_group_memberships
7465 "players": {
7466 "group_1": {
7467 "enabled": True,
7468 "values": {
7469 "group_members": ["up_old", "other"],
7470 "allowed_members": ["up_old"],
7471 },
7472 },
7473 "group_2": {
7474 "enabled": True,
7475 # native id already listed -> no duplicate after re-point
7476 "values": {"group_members": ["up_old", "cast_1", "extra"]},
7477 },
7478 },
7479 }
7480 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7481 mock_mass, config_store
7482 )
7483 group_1 = MockPlayer(MockProvider("player_group", mass=mock_mass), "group_1", "Group")
7484 group_1.set_initialized()
7485 controller._players["group_1"] = group_1
7486
7487 with patch.object(group_1, "refresh_state") as mock_refresh:
7488 controller._check_replace_universal_player(native)
7489 for task in [t for t in scheduled_tasks if "_reload_group_members" in repr(t)]:
7490 await task
7491
7492 # the wrapper id is replaced by the native id on both membership keys
7493 assert config_store["players/group_1/values/group_members"] == ["cast_1", "other"]
7494 assert config_store["players/group_1/values/allowed_members"] == ["cast_1"]
7495 # no duplicate when the native id is already a member
7496 assert config_store["players/group_2/values/group_members"] == ["cast_1", "extra"]
7497 # a registered player whose membership changed re-reads it and is refreshed
7498 mock_refresh.assert_called()
7499
7500 async def test_replace_stops_playing_universal_before_unregister(
7501 self, mock_mass: MagicMock
7502 ) -> None:
7503 """A playing universal player is stopped before it is permanently removed."""
7504 config_store: dict[str, Any] = {
7505 "players/cast_1": {"enabled": True},
7506 "players/up_old": {
7507 "enabled": True,
7508 "name": "Soundbar (Universal)",
7509 "default_name": "Soundbar (Universal)",
7510 "values": {"linked_protocol_ids": ["ap_live"]},
7511 },
7512 "players/ap_live": {"enabled": True},
7513 }
7514 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7515 mock_mass, config_store
7516 )
7517 controller._players["up_old"]._attr_playback_state = PlaybackState.PLAYING
7518
7519 call_order: list[str] = []
7520 mock_mass.player_queues.stop = AsyncMock(
7521 side_effect=lambda *_a, **_k: call_order.append("stop")
7522 )
7523
7524 controller._check_replace_universal_player(native)
7525 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7526
7527 def _record_unregister(*_a: Any, **_k: Any) -> None:
7528 call_order.append("unregister")
7529
7530 with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
7531 await task
7532
7533 assert call_order == ["stop", "unregister"]
7534 mock_mass.player_queues.stop.assert_awaited_once_with("up_old")
7535
7536 async def test_replace_hands_the_user_filters_to_the_native_player(
7537 self, mock_mass: MagicMock
7538 ) -> None:
7539 """The replaced wrapper hands its access filter entries to the native player."""
7540 config_store: dict[str, Any] = {
7541 "players/cast_1": {"enabled": True},
7542 "players/up_old": {
7543 "enabled": True,
7544 "name": "Soundbar (Universal)",
7545 "default_name": "Soundbar (Universal)",
7546 "values": {"linked_protocol_ids": ["ap_live"]},
7547 },
7548 "players/ap_live": {"enabled": True},
7549 }
7550 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7551 mock_mass, config_store
7552 )
7553
7554 controller._check_replace_universal_player(native)
7555
7556 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7557 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7558 await task
7559
7560 mock_unregister.assert_awaited_once_with(
7561 "up_old", permanent=True, replacement_player_id="cast_1"
7562 )
7563
7564 async def test_replace_idle_universal_not_stopped(self, mock_mass: MagicMock) -> None:
7565 """An idle universal player is removed without a stop command."""
7566 config_store: dict[str, Any] = {
7567 "players/cast_1": {"enabled": True},
7568 "players/up_old": {
7569 "enabled": True,
7570 "name": "Soundbar (Universal)",
7571 "default_name": "Soundbar (Universal)",
7572 "values": {"linked_protocol_ids": ["ap_live"]},
7573 },
7574 "players/ap_live": {"enabled": True},
7575 }
7576 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7577 mock_mass, config_store
7578 )
7579 mock_mass.player_queues.stop = AsyncMock()
7580
7581 controller._check_replace_universal_player(native)
7582 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7583 await task
7584
7585 mock_mass.player_queues.stop.assert_not_awaited()
7586 assert "up_old" not in controller._players
7587
7588 async def test_provider_discovery_rechecks_registered_native_players(
7589 self, mock_mass: MagicMock
7590 ) -> None:
7591 """A native player that registered before its wrapper was restored is promoted."""
7592 controller = PlayerController(mock_mass)
7593 config_store: dict[str, Any] = {
7594 CONF_PLAYERS: {
7595 "sonos_1": {"enabled": True, "values": {}},
7596 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7597 "ap_live": {
7598 "enabled": True,
7599 "provider": "airplay--test",
7600 "player_type": "protocol",
7601 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7602 },
7603 }
7604 }
7605 _wire_nested_config(mock_mass, config_store)
7606 mock_mass.players = controller
7607 mock_mass.player_queues = MagicMock()
7608 mock_mass.create_task = MagicMock()
7609 mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7610
7611 provider = create_mock_universal_provider(mock_mass)
7612 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7613 universal = _create_universal_player(
7614 mock_mass, "up_old", "Old Universal", ["ap_live"], identifiers=identifiers
7615 )
7616 native = MockPlayer(
7617 MockProvider("sonos", mass=mock_mass), "sonos_1", "Sonos", identifiers=identifiers
7618 )
7619 native.set_initialized()
7620 ap_live = MockPlayer(
7621 MockProvider("airplay", mass=mock_mass),
7622 "ap_live",
7623 "AirPlay",
7624 player_type=PlayerType.PROTOCOL,
7625 identifiers=identifiers,
7626 )
7627 ap_live.set_initialized()
7628 controller._players = {
7629 "up_old": universal,
7630 "sonos_1": native,
7631 "ap_live": ap_live,
7632 }
7633 controller._add_protocol_link(universal, ap_live, "airplay")
7634
7635 await provider.discover_players()
7636
7637 assert ap_live.protocol_parent_id == "sonos_1"
7638 assert native.protocol_parent_id is None
7639
7640 async def test_provider_discovery_does_not_replace_wrapper_with_group(
7641 self, mock_mass: MagicMock
7642 ) -> None:
7643 """A matching group player must never take over a wrapper's protocols."""
7644 controller = PlayerController(mock_mass)
7645 config_store: dict[str, Any] = {
7646 CONF_PLAYERS: {
7647 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7648 "ap_owned": {
7649 "enabled": True,
7650 "provider": "airplay--test",
7651 "player_type": "protocol",
7652 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7653 },
7654 }
7655 }
7656 _wire_nested_config(mock_mass, config_store)
7657 mock_mass.players = controller
7658 mock_mass.player_queues = MagicMock()
7659 mock_mass.create_task = MagicMock()
7660 mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7661
7662 provider = create_mock_universal_provider(mock_mass)
7663 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7664 universal = _create_universal_player(
7665 mock_mass, "up_old", "Old Universal", ["ap_owned"], identifiers=identifiers
7666 )
7667 group = MockPlayer(
7668 MockProvider("sync_group", mass=mock_mass),
7669 "group_1",
7670 "Group",
7671 player_type=PlayerType.GROUP,
7672 identifiers=identifiers,
7673 )
7674 group.set_initialized()
7675 ap_owned = MockPlayer(
7676 MockProvider("airplay", mass=mock_mass),
7677 "ap_owned",
7678 "AirPlay",
7679 player_type=PlayerType.PROTOCOL,
7680 identifiers=identifiers,
7681 )
7682 ap_owned.set_initialized()
7683 controller._players = {"up_old": universal, "group_1": group, "ap_owned": ap_owned}
7684 controller._add_protocol_link(universal, ap_owned, "airplay")
7685
7686 await provider.discover_players()
7687
7688 assert ap_owned.protocol_parent_id == "up_old"
7689 assert not group.linked_output_protocols
7690
7691
7692class TestEndToEndDuplicateProtocol:
7693 """
7694 End-to-end integration tests for duplicate protocol → separate universal player flow.
7695
7696 These tests exercise the full async flow through _delayed_protocol_evaluation,
7697 ensure_universal_players_for_protocols, and _create_separate_universal_player,
7698 verifying that a second instance of the same protocol domain on the same host
7699 results in a separate universal player.
7700 """
7701
7702 @staticmethod
7703 def _setup_e2e_controller(
7704 mock_mass: MagicMock,
7705 ) -> tuple[PlayerController, UniversalPlayerProvider]:
7706 """
7707 Set up a PlayerController and UniversalPlayerProvider wired for E2E testing.
7708
7709 The mock_mass is configured so that:
7710 - get_providers(ProviderType.PLAYER) returns the universal provider
7711 - register_or_update adds the player to the controller's _players dict
7712 - config.create_default_player_config does not fail
7713 - config.set does not fail
7714
7715 :return: Tuple of (controller, universal_provider).
7716 """
7717 controller = PlayerController(mock_mass)
7718 up_provider = create_mock_universal_provider(mock_mass)
7719
7720 # Wire get_providers to return our universal provider
7721 mock_mass.get_providers = MagicMock(return_value=[up_provider])
7722
7723 # Wire register_or_update to add the player to controller._players
7724 async def fake_register_or_update(player: Player) -> None:
7725 controller._players[player.player_id] = player
7726 player.set_initialized()
7727
7728 mock_mass.players = MagicMock()
7729 mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
7730 mock_mass.players.get_player = lambda pid: controller._players.get(pid)
7731
7732 # Wire config methods
7733 mock_mass.config.create_default_player_config = MagicMock()
7734 mock_mass.config.set = MagicMock()
7735 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
7736
7737 return controller, up_provider
7738
7739 @pytest.mark.asyncio
7740 async def test_path_a_delayed_eval_creates_separate_universal_for_duplicate(
7741 self, mock_mass: MagicMock
7742 ) -> None:
7743 """
7744 Path A: _delayed_protocol_evaluation creates separate universal player for duplicate.
7745
7746 Scenario: Two AirPlay instances register on the same host.
7747 1. First AirPlay links to existing universal player
7748 via _add_protocol_to_existing_universal
7749 2. Second AirPlay arrives at _delayed_protocol_evaluation
7750 3. It finds the existing universal player but is refused (domain duplicate)
7751 4. Falls through to _find_matching_protocol_players (which skips same-domain)
7752 5. Creates a NEW separate universal player via _create_or_update_universal_player
7753 """
7754 controller, _ = self._setup_e2e_controller(mock_mass)
7755
7756 # Create existing universal player with AirPlay already linked
7757 universal = _create_universal_player(
7758 mock_mass,
7759 "up_aabbccddeeff",
7760 "Living Room Speaker",
7761 protocol_player_ids=["ap_1"],
7762 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7763 )
7764
7765 # Create first AirPlay (already linked to universal)
7766 ap_provider = MockProvider("airplay", mass=mock_mass)
7767 ap1 = MockPlayer(
7768 ap_provider,
7769 "ap_1",
7770 "AirPlay Instance 1",
7771 player_type=PlayerType.PROTOCOL,
7772 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7773 )
7774 ap1.set_initialized()
7775 controller._add_protocol_link(universal, ap1, "airplay")
7776 assert ap1.protocol_parent_id == "up_aabbccddeeff"
7777
7778 # Create second AirPlay (same MAC, not yet linked)
7779 ap2 = MockPlayer(
7780 ap_provider,
7781 "ap_2",
7782 "AirPlay Instance 2",
7783 player_type=PlayerType.PROTOCOL,
7784 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7785 )
7786 ap2.set_initialized()
7787
7788 controller._players = {
7789 "up_aabbccddeeff": universal,
7790 "ap_1": ap1,
7791 "ap_2": ap2,
7792 }
7793
7794 # Run delayed evaluation for ap2 - full async flow
7795 await controller._delayed_protocol_evaluation("ap_2")
7796
7797 # ap2 should NOT be linked to the existing universal player
7798 assert ap2.protocol_parent_id != "up_aabbccddeeff"
7799
7800 # ap2 should have its own (freshly minted) universal player
7801 assert ap2.protocol_parent_id is not None
7802 separate_up_id = ap2.protocol_parent_id
7803 assert separate_up_id in controller._players
7804 separate_up = controller._players[separate_up_id]
7805 assert isinstance(separate_up, UniversalPlayer)
7806
7807 # The original universal player should still have only ap_1
7808 original_ap_links = [
7809 link for link in universal.linked_output_protocols if link.protocol_domain == "airplay"
7810 ]
7811 assert len(original_ap_links) == 1
7812 assert original_ap_links[0].output_protocol_id == "ap_1"
7813
7814 @pytest.mark.asyncio
7815 async def test_path_a_delayed_eval_joins_existing_universal_different_domain(
7816 self, mock_mass: MagicMock
7817 ) -> None:
7818 """
7819 Path A: _delayed_protocol_evaluation correctly joins when domain is different.
7820
7821 Scenario: AirPlay universal player exists, DLNA arrives via delayed eval.
7822 DLNA should successfully join the existing universal player (different domain).
7823 """
7824 controller, _ = self._setup_e2e_controller(mock_mass)
7825
7826 # Create existing universal player with AirPlay linked
7827 universal = _create_universal_player(
7828 mock_mass,
7829 "up_aabbccddeeff",
7830 "Living Room Speaker",
7831 protocol_player_ids=["ap_1"],
7832 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7833 )
7834
7835 ap_provider = MockProvider("airplay", mass=mock_mass)
7836 ap1 = MockPlayer(
7837 ap_provider,
7838 "ap_1",
7839 "AirPlay",
7840 player_type=PlayerType.PROTOCOL,
7841 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7842 )
7843 ap1.set_initialized()
7844 controller._add_protocol_link(universal, ap1, "airplay")
7845
7846 # Create DLNA player (same device, different protocol)
7847 dlna_provider = MockProvider("dlna", mass=mock_mass)
7848 dlna1 = MockPlayer(
7849 dlna_provider,
7850 "dlna_1",
7851 "DLNA",
7852 player_type=PlayerType.PROTOCOL,
7853 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7854 )
7855 dlna1.set_initialized()
7856
7857 controller._players = {
7858 "up_aabbccddeeff": universal,
7859 "ap_1": ap1,
7860 "dlna_1": dlna1,
7861 }
7862
7863 # Run delayed evaluation for DLNA
7864 await controller._delayed_protocol_evaluation("dlna_1")
7865
7866 # DLNA should join the existing universal player
7867 assert dlna1.protocol_parent_id == "up_aabbccddeeff"
7868
7869 # Universal player should now have both AirPlay and DLNA
7870 domains = {link.protocol_domain for link in universal.linked_output_protocols}
7871 assert "airplay" in domains
7872 assert "dlna" in domains
7873
7874 @pytest.mark.asyncio
7875 async def test_path_b_ensure_universal_separates_duplicate_domain(
7876 self, mock_mass: MagicMock
7877 ) -> None:
7878 """
7879 Path B: ensure_universal_players_for_protocols separates can_join vs rejected.
7880
7881 Scenario: Two AirPlay players arrive simultaneously for delayed eval.
7882 Both are unlinked, both pass to _create_or_update_universal_player.
7883 The first creates a universal player. The second finds the existing one
7884 and is rejected (duplicate domain), getting its own separate universal player.
7885 """
7886 controller, _ = self._setup_e2e_controller(mock_mass)
7887
7888 ap_provider = MockProvider("airplay", mass=mock_mass)
7889 ap1 = MockPlayer(
7890 ap_provider,
7891 "ap_1",
7892 "AirPlay Instance 1",
7893 player_type=PlayerType.PROTOCOL,
7894 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7895 )
7896 ap1.set_initialized()
7897
7898 ap2 = MockPlayer(
7899 ap_provider,
7900 "ap_2",
7901 "AirPlay Instance 2",
7902 player_type=PlayerType.PROTOCOL,
7903 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7904 )
7905 ap2.set_initialized()
7906
7907 controller._players = {
7908 "ap_1": ap1,
7909 "ap_2": ap2,
7910 }
7911
7912 # First: ap1 goes through delayed evaluation, creates a universal player
7913 await controller._delayed_protocol_evaluation("ap_1")
7914
7915 # ap1 should have a universal player parent
7916 assert ap1.protocol_parent_id is not None
7917 first_up_id = ap1.protocol_parent_id
7918 assert first_up_id in controller._players
7919
7920 # Second: ap2 goes through delayed evaluation
7921 # _find_matching_universal_player will find the universal player by MAC
7922 # _add_protocol_to_existing_universal will refuse (duplicate domain)
7923 # _find_matching_protocol_players will skip ap1 (same domain)
7924 # _create_or_update_universal_player → ensure_universal_players_for_protocols
7925 # finds existing universal player, separates ap2 as rejected
7926 await controller._delayed_protocol_evaluation("ap_2")
7927
7928 # ap2 should have its own separate universal player
7929 assert ap2.protocol_parent_id is not None
7930 second_up_id = ap2.protocol_parent_id
7931 assert second_up_id != first_up_id
7932 assert second_up_id in controller._players
7933
7934 # Both universal players should exist
7935 first_up = controller._players[first_up_id]
7936 second_up = controller._players[second_up_id]
7937 assert isinstance(first_up, UniversalPlayer)
7938 assert isinstance(second_up, UniversalPlayer)
7939
7940 @pytest.mark.asyncio
7941 async def test_path_b_first_creates_then_second_gets_separate(
7942 self, mock_mass: MagicMock
7943 ) -> None:
7944 """
7945 Path B: First protocol player creates universal, second gets separate.
7946
7947 Scenario: AirPlay and DLNA create a universal player together.
7948 Then a second AirPlay arrives and gets its own separate universal player.
7949 """
7950 controller, _ = self._setup_e2e_controller(mock_mass)
7951
7952 ap_provider = MockProvider("airplay", mass=mock_mass)
7953 dlna_provider = MockProvider("dlna", mass=mock_mass)
7954
7955 ap1 = MockPlayer(
7956 ap_provider,
7957 "ap_1",
7958 "AirPlay",
7959 player_type=PlayerType.PROTOCOL,
7960 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7961 )
7962 ap1.set_initialized()
7963
7964 dlna1 = MockPlayer(
7965 dlna_provider,
7966 "dlna_1",
7967 "DLNA",
7968 player_type=PlayerType.PROTOCOL,
7969 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7970 )
7971 dlna1.set_initialized()
7972
7973 ap2 = MockPlayer(
7974 ap_provider,
7975 "ap_2",
7976 "AirPlay 2",
7977 player_type=PlayerType.PROTOCOL,
7978 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7979 )
7980 ap2.set_initialized()
7981
7982 controller._players = {
7983 "ap_1": ap1,
7984 "dlna_1": dlna1,
7985 "ap_2": ap2,
7986 }
7987
7988 # First: ap1 arrives at delayed eval, finds dlna1 as matching (different domain)
7989 # Together they create a universal player
7990 await controller._delayed_protocol_evaluation("ap_1")
7991
7992 assert ap1.protocol_parent_id is not None
7993 # dlna1 should also be linked (found by _find_matching_protocol_players)
7994 assert dlna1.protocol_parent_id == ap1.protocol_parent_id
7995 shared_up_id = ap1.protocol_parent_id
7996
7997 # Second: ap2 arrives at delayed eval
7998 await controller._delayed_protocol_evaluation("ap_2")
7999
8000 # ap2 should get its own universal player
8001 assert ap2.protocol_parent_id is not None
8002 assert ap2.protocol_parent_id != shared_up_id
8003 assert ap2.protocol_parent_id in controller._players
8004
8005 # Shared universal player should have AirPlay + DLNA
8006 shared_up = controller._players[shared_up_id]
8007 shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8008 assert "airplay" in shared_domains
8009 assert "dlna" in shared_domains
8010
8011 @pytest.mark.asyncio
8012 async def test_path_c_sendspin_duplicate_gets_separate_universal(
8013 self, mock_mass: MagicMock
8014 ) -> None:
8015 """
8016 Path C: Two Sendspin bridge instances on same host get separate universal players.
8017
8018 Sendspin bridges match via CAST_UUID / AIRPLAY_ID, not MAC. Two bridges
8019 on the same host share the same MAC (from ARP) but have different CAST_UUIDs.
8020 Each should get its own universal player.
8021 """
8022 controller, _ = self._setup_e2e_controller(mock_mass)
8023
8024 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
8025
8026 # First Sendspin bridge
8027 spb1 = MockPlayer(
8028 sendspin_provider,
8029 "spb_1",
8030 "Sendspin Bridge 1",
8031 player_type=PlayerType.PROTOCOL,
8032 identifiers={
8033 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
8034 IdentifierType.CAST_UUID: "cast-uuid-111",
8035 },
8036 )
8037 spb1.set_initialized()
8038
8039 # Second Sendspin bridge (same MAC, different CAST_UUID)
8040 spb2 = MockPlayer(
8041 sendspin_provider,
8042 "spb_2",
8043 "Sendspin Bridge 2",
8044 player_type=PlayerType.PROTOCOL,
8045 identifiers={
8046 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
8047 IdentifierType.CAST_UUID: "cast-uuid-222",
8048 },
8049 )
8050 spb2.set_initialized()
8051
8052 controller._players = {
8053 "spb_1": spb1,
8054 "spb_2": spb2,
8055 }
8056
8057 # First: spb1 goes through delayed evaluation
8058 await controller._delayed_protocol_evaluation("spb_1")
8059
8060 assert spb1.protocol_parent_id is not None
8061 first_up_id = spb1.protocol_parent_id
8062
8063 # Second: spb2 goes through delayed evaluation
8064 # _find_matching_protocol_players skips spb1 (same domain: "sendspin")
8065 # _find_matching_universal_player may find the first universal player by MAC
8066 # _add_protocol_to_existing_universal refuses (duplicate domain: "sendspin")
8067 # Falls through to create separate universal player
8068 await controller._delayed_protocol_evaluation("spb_2")
8069
8070 assert spb2.protocol_parent_id is not None
8071 second_up_id = spb2.protocol_parent_id
8072
8073 # Each should have its own universal player
8074 assert first_up_id != second_up_id
8075 assert first_up_id in controller._players
8076 assert second_up_id in controller._players
8077
8078 first_up = controller._players[first_up_id]
8079 second_up = controller._players[second_up_id]
8080 assert isinstance(first_up, UniversalPlayer)
8081 assert isinstance(second_up, UniversalPlayer)
8082
8083 @pytest.mark.asyncio
8084 async def test_three_airplay_instances_get_separate_universals(
8085 self, mock_mass: MagicMock
8086 ) -> None:
8087 """
8088 Test that three AirPlay instances each get their own universal player.
8089
8090 Exercises the flow when there are 3+ instances of the same protocol.
8091 """
8092 controller, _ = self._setup_e2e_controller(mock_mass)
8093
8094 ap_provider = MockProvider("airplay", mass=mock_mass)
8095 players = []
8096 for i in range(1, 4):
8097 p = MockPlayer(
8098 ap_provider,
8099 f"ap_{i}",
8100 f"AirPlay Instance {i}",
8101 player_type=PlayerType.PROTOCOL,
8102 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8103 )
8104 p.set_initialized()
8105 players.append(p)
8106
8107 controller._players = {p.player_id: p for p in players}
8108
8109 # Run delayed evaluation for each in sequence
8110 for p in players:
8111 await controller._delayed_protocol_evaluation(p.player_id)
8112
8113 # Each should have its own universal player
8114 parent_ids = {p.protocol_parent_id for p in players}
8115 assert None not in parent_ids
8116 # All three should have distinct parents
8117 assert len(parent_ids) == 3
8118
8119 # All three universal players should exist
8120 for pid in parent_ids:
8121 assert pid in controller._players
8122 assert isinstance(controller._players[pid], UniversalPlayer)
8123
8124 @pytest.mark.asyncio
8125 async def test_same_domain_same_ip_get_separate_universals(self, mock_mass: MagicMock) -> None:
8126 """
8127 Test that multiple instances of the same protocol on the same host stay separate.
8128
8129 Scenario: 3 shairport-sync AirPlay instances on the same Raspberry Pi.
8130 All share the same IP and have locally-administered MACs.
8131 The IP fallback must NOT merge them — domain dedup takes precedence.
8132 """
8133 controller, _ = self._setup_e2e_controller(mock_mass)
8134
8135 ap_provider = MockProvider("airplay", mass=mock_mass)
8136 players = []
8137 for i in range(1, 4):
8138 p = MockPlayer(
8139 ap_provider,
8140 f"ap_{i}",
8141 f"Shairport Zone {i}",
8142 player_type=PlayerType.PROTOCOL,
8143 identifiers={
8144 # Each has a distinct LA MAC (as shairport-sync generates)
8145 IdentifierType.MAC_ADDRESS: f"02:AA:BB:CC:DD:{i:02X}",
8146 IdentifierType.IP_ADDRESS: "192.168.1.10",
8147 },
8148 )
8149 p.set_initialized()
8150 players.append(p)
8151
8152 controller._players = {p.player_id: p for p in players}
8153
8154 for p in players:
8155 await controller._delayed_protocol_evaluation(p.player_id)
8156
8157 # Each should have its own universal player (NOT merged)
8158 parent_ids = {p.protocol_parent_id for p in players}
8159 assert None not in parent_ids
8160 assert len(parent_ids) == 3
8161
8162 @pytest.mark.asyncio
8163 async def test_same_domain_same_mac_get_separate_universals(self, mock_mass: MagicMock) -> None:
8164 """
8165 Test that two snapcast instances with the same ARP-resolved MAC stay separate.
8166
8167 Scenario: Two snapcast clients on the same Mac mini. Both have distinct
8168 locally-administered MACs but ARP resolves both to the same real hardware MAC.
8169 They should each get their own universal player, not be merged.
8170 """
8171 controller, _ = self._setup_e2e_controller(mock_mass)
8172
8173 snap_provider = MockProvider("snapcast", mass=mock_mass)
8174 snap1 = MockPlayer(
8175 snap_provider,
8176 "ma_cea0b4ed2221",
8177 "Mac-mini-15269.local",
8178 player_type=PlayerType.PROTOCOL,
8179 identifiers={
8180 # ARP-enriched: both resolve to same real MAC
8181 IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
8182 IdentifierType.IP_ADDRESS: "192.168.1.42",
8183 },
8184 )
8185 snap2 = MockPlayer(
8186 snap_provider,
8187 "ma_cea0b4ed2220",
8188 "Mac-mini-63197.local",
8189 player_type=PlayerType.PROTOCOL,
8190 identifiers={
8191 IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
8192 IdentifierType.IP_ADDRESS: "192.168.1.42",
8193 },
8194 )
8195 snap1._attr_available = False
8196 snap1._cache.clear()
8197 snap1.update_state(signal_event=False)
8198 snap2._attr_available = False
8199 snap2._cache.clear()
8200 snap2.update_state(signal_event=False)
8201 snap1.set_initialized()
8202 snap2.set_initialized()
8203
8204 controller._players = {"ma_cea0b4ed2221": snap1, "ma_cea0b4ed2220": snap2}
8205
8206 await controller._delayed_protocol_evaluation("ma_cea0b4ed2221")
8207 await controller._delayed_protocol_evaluation("ma_cea0b4ed2220")
8208
8209 # Each should have its own universal player
8210 assert snap1.protocol_parent_id is not None
8211 assert snap2.protocol_parent_id is not None
8212 assert snap1.protocol_parent_id != snap2.protocol_parent_id
8213
8214 @pytest.mark.asyncio
8215 async def test_mixed_protocols_plus_duplicate_creates_correct_topology(
8216 self, mock_mass: MagicMock
8217 ) -> None:
8218 """
8219 Test that mixed protocols + duplicate create correct player topology.
8220
8221 Scenario:
8222 - Device has AirPlay, DLNA, and Chromecast protocols
8223 - Plus a second AirPlay instance (duplicate)
8224 Result:
8225 - One universal player with AirPlay + DLNA + Chromecast
8226 - One separate universal player for the second AirPlay
8227 """
8228 controller, _ = self._setup_e2e_controller(mock_mass)
8229
8230 ap_provider = MockProvider("airplay", mass=mock_mass)
8231 dlna_provider = MockProvider("dlna", mass=mock_mass)
8232 cc_provider = MockProvider("chromecast", mass=mock_mass)
8233
8234 ap1 = MockPlayer(
8235 ap_provider,
8236 "ap_1",
8237 "AirPlay",
8238 player_type=PlayerType.PROTOCOL,
8239 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8240 )
8241 ap1.set_initialized()
8242
8243 dlna1 = MockPlayer(
8244 dlna_provider,
8245 "dlna_1",
8246 "DLNA",
8247 player_type=PlayerType.PROTOCOL,
8248 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8249 )
8250 dlna1.set_initialized()
8251
8252 cc1 = MockPlayer(
8253 cc_provider,
8254 "cc_1",
8255 "Chromecast",
8256 player_type=PlayerType.PROTOCOL,
8257 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8258 )
8259 cc1.set_initialized()
8260
8261 ap2 = MockPlayer(
8262 ap_provider,
8263 "ap_2",
8264 "AirPlay Duplicate",
8265 player_type=PlayerType.PROTOCOL,
8266 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8267 )
8268 ap2.set_initialized()
8269
8270 controller._players = {
8271 "ap_1": ap1,
8272 "dlna_1": dlna1,
8273 "cc_1": cc1,
8274 "ap_2": ap2,
8275 }
8276
8277 # ap1 goes through delayed eval - finds dlna1 and cc1 as matching
8278 # (different domains), creates universal player with all three
8279 await controller._delayed_protocol_evaluation("ap_1")
8280
8281 assert ap1.protocol_parent_id is not None
8282 assert dlna1.protocol_parent_id == ap1.protocol_parent_id
8283 assert cc1.protocol_parent_id == ap1.protocol_parent_id
8284 shared_up_id = ap1.protocol_parent_id
8285
8286 # ap2 goes through delayed eval - finds universal player but is rejected
8287 await controller._delayed_protocol_evaluation("ap_2")
8288
8289 assert ap2.protocol_parent_id is not None
8290 assert ap2.protocol_parent_id != shared_up_id
8291
8292 # Shared universal player should have 3 protocols
8293 shared_up = controller._players[shared_up_id]
8294 shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8295 assert shared_domains == {"airplay", "dlna", "chromecast"}
8296
8297
8298class TestEndToEndUniversalPlayerId:
8299 """
8300 End-to-end tests for the minted player id of a universal player.
8301
8302 Regression tests for music-assistant/support#5888: the id used to be derived from
8303 whichever device identifiers happened to be available when the wrapper was created,
8304 so a re-created wrapper could silently change id and orphan the entities API
8305 consumers (such as the Home Assistant integration) bind to it. These tests drive
8306 the real evaluation path against a config store that persists what the flow writes.
8307 """
8308
8309 @staticmethod
8310 def _setup_controller(
8311 mock_mass: MagicMock,
8312 ) -> tuple[PlayerController, dict[str, Any]]:
8313 """
8314 Wire a controller, the universal player provider and a persisting config store.
8315
8316 :param mock_mass: The mocked MusicAssistant instance to wire.
8317 :return: Tuple of (controller, backing config store).
8318 """
8319 controller = PlayerController(mock_mass)
8320 up_provider = create_mock_universal_provider(mock_mass)
8321 mock_mass.get_providers = MagicMock(return_value=[up_provider])
8322
8323 store: dict[str, Any] = {CONF_PLAYERS: {}}
8324 _wire_nested_config(mock_mass, store)
8325 mock_mass.config.create_default_player_config = MagicMock(
8326 side_effect=lambda *args, **kwargs: _fake_create_default_player_config(
8327 store, *args, **kwargs
8328 )
8329 )
8330 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
8331
8332 async def fake_register_or_update(player: Player) -> None:
8333 controller._players[player.player_id] = player
8334 player.set_initialized()
8335
8336 mock_mass.players = MagicMock()
8337 mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
8338 mock_mass.players.get_player = lambda pid, *_args, **_kwargs: controller._players.get(pid)
8339 return controller, store
8340
8341 @staticmethod
8342 def _make_airplay_player(
8343 mock_mass: MagicMock, player_id: str, name: str, identifiers: dict[IdentifierType, str]
8344 ) -> MockPlayer:
8345 """Return an unlinked AirPlay protocol player."""
8346 player = MockPlayer(
8347 MockProvider("airplay", mass=mock_mass),
8348 player_id,
8349 name,
8350 player_type=PlayerType.PROTOCOL,
8351 identifiers=identifiers,
8352 )
8353 player.set_initialized()
8354 return player
8355
8356 async def test_id_survives_recreation_with_other_identifiers(
8357 self, mock_mass: MagicMock
8358 ) -> None:
8359 """
8360 A re-created wrapper keeps its id when a different set of identifiers is known.
8361
8362 The first evaluation only knows the device's MAC, the second only its UUID.
8363 The derived key changed along with that, taking the player id with it.
8364 """
8365 controller, store = self._setup_controller(mock_mass)
8366 first = self._make_airplay_player(
8367 mock_mass, "ap_1", "Living Room", {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8368 )
8369 controller._players = {"ap_1": first}
8370
8371 await controller._delayed_protocol_evaluation("ap_1")
8372
8373 universal_id = first.protocol_parent_id
8374 assert universal_id is not None
8375 assert re.fullmatch(r"up[0-9a-f]{8}", universal_id)
8376
8377 # tear the wrapper down (its config is kept, as a non-permanent unregister does)
8378 # and bring the device back with only a UUID to identify it by
8379 del controller._players[universal_id]
8380 second = self._make_airplay_player(
8381 mock_mass,
8382 "ap_1",
8383 "Living Room",
8384 {IdentifierType.UUID: "12345678-1234-1234-1234-123456789abc"},
8385 )
8386 controller._players = {"ap_1": second}
8387
8388 await controller._delayed_protocol_evaluation("ap_1")
8389
8390 assert second.protocol_parent_id == universal_id
8391 assert universal_id in controller._players
8392 assert store[CONF_PLAYERS]["ap_1"]["values"][CONF_PROTOCOL_PARENT_ID] == universal_id
8393
8394 async def test_wrappers_created_in_one_second_still_order(self, mock_mass: MagicMock) -> None:
8395 """
8396 Two wrappers created back to back get distinct creation timestamps.
8397
8398 A merge between them must be able to keep the older one, which it cannot do
8399 when both carry the same timestamp and the tiebreak falls back to the id.
8400 """
8401 controller, store = self._setup_controller(mock_mass)
8402 players = [
8403 self._make_airplay_player(
8404 mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8405 )
8406 for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8407 ]
8408 controller._players = {player.player_id: player for player in players}
8409
8410 for player in players:
8411 await controller._delayed_protocol_evaluation(player.player_id)
8412
8413 created = [
8414 store[CONF_PLAYERS][player.protocol_parent_id]["values"][CONF_CREATED_AT]
8415 for player in players
8416 ]
8417 assert all(isinstance(value, int) for value in created)
8418 assert created[0] < created[1]
8419
8420 async def test_new_devices_get_distinct_minted_ids(self, mock_mass: MagicMock) -> None:
8421 """Devices that were never wrapped before each get their own minted id."""
8422 controller, _ = self._setup_controller(mock_mass)
8423 players = [
8424 self._make_airplay_player(
8425 mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8426 )
8427 for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8428 ]
8429 controller._players = {player.player_id: player for player in players}
8430
8431 for player in players:
8432 await controller._delayed_protocol_evaluation(player.player_id)
8433
8434 universal_ids = [player.protocol_parent_id for player in players]
8435 assert all(pid and re.fullmatch(r"up[0-9a-f]{8}", pid) for pid in universal_ids)
8436 assert len(set(universal_ids)) == len(players)
8437
8438 async def test_duplicate_domain_gets_a_second_wrapper_with_a_parent_link(
8439 self, mock_mass: MagicMock
8440 ) -> None:
8441 """
8442 Two players of one protocol domain end up on two wrappers, both linked.
8443
8444 The handoff must persist a parent link for the second player as well,
8445 otherwise it silently stays unwrapped.
8446 """
8447 controller, store = self._setup_controller(mock_mass)
8448 identifiers = {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8449 ap1 = self._make_airplay_player(mock_mass, "ap_1", "Zone 1", identifiers)
8450 ap2 = self._make_airplay_player(mock_mass, "ap_2", "Zone 2", identifiers)
8451 controller._players = {"ap_1": ap1, "ap_2": ap2}
8452
8453 await controller._create_or_update_universal_player([ap1, ap2])
8454
8455 assert ap1.protocol_parent_id is not None
8456 assert ap2.protocol_parent_id is not None
8457 assert ap1.protocol_parent_id != ap2.protocol_parent_id
8458 for player in (ap1, ap2):
8459 stored = store[CONF_PLAYERS][player.player_id]["values"][CONF_PROTOCOL_PARENT_ID]
8460 assert stored == player.protocol_parent_id
8461
8462
8463class TestSiblingProtocolMatching:
8464 """
8465 Tests for sibling protocol matching via _match_via_linked_protocols.
8466
8467 When a native player (e.g., HEOS) doesn't have its own MAC/serial identifiers
8468 but has protocol players (e.g., AirPlay) already linked, a new protocol player
8469 (e.g., Sendspin bridge) should be matched to the native player through the
8470 shared identifiers of sibling protocols.
8471 """
8472
8473 def test_sendspin_matches_heos_via_airplay_sibling(self, mock_mass: MagicMock) -> None:
8474 """
8475 Test Sendspin links to HEOS through AirPlay's shared AIRPLAY_ID.
8476
8477 HEOS has no MAC/UUID identifiers. AirPlay is already linked to HEOS.
8478 Sendspin has AIRPLAY_ID matching AirPlay. Sibling matching should link
8479 Sendspin to HEOS.
8480 """
8481 controller = PlayerController(mock_mass)
8482
8483 # HEOS native player - no MAC, no serial, no UUID (just IP from discovery)
8484 heos_provider = MockProvider("heos", mass=mock_mass)
8485 heos_player = MockPlayer(
8486 heos_provider,
8487 "2140037800",
8488 "Denon AVR-X2700H",
8489 player_type=PlayerType.PLAYER,
8490 )
8491 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8492 heos_player._cache.clear()
8493 heos_player.set_initialized()
8494
8495 # AirPlay protocol player already linked to HEOS
8496 ap_provider = MockProvider("airplay", mass=mock_mass)
8497 ap_player = MockPlayer(
8498 ap_provider,
8499 "ap000678747b16",
8500 "Denon AVR-X2700H (AirPlay)",
8501 player_type=PlayerType.PROTOCOL,
8502 identifiers={
8503 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8504 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8505 },
8506 )
8507 ap_player.set_initialized()
8508
8509 # Link AirPlay to HEOS
8510 controller._players = {
8511 "2140037800": heos_player,
8512 "ap000678747b16": ap_player,
8513 }
8514 controller._add_protocol_link(heos_player, ap_player, "airplay")
8515 assert ap_player.protocol_parent_id == "2140037800"
8516
8517 # Sendspin bridge with AIRPLAY_ID matching AirPlay
8518 spb_provider = MockProvider("sendspin", mass=mock_mass)
8519 spb_player = MockPlayer(
8520 spb_provider,
8521 "spb_000678747b16",
8522 "Denon AVR-X2700H (Sendspin)",
8523 player_type=PlayerType.PROTOCOL,
8524 identifiers={
8525 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8526 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8527 },
8528 )
8529 spb_player.set_initialized()
8530 controller._players["spb_000678747b16"] = spb_player
8531
8532 # Direct identifier match would fail (HEOS has no identifiers)
8533 assert controller._identifiers_match(heos_player, spb_player, "sendspin") is False
8534
8535 # Sibling matching should succeed through AirPlay's identifiers
8536 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is True
8537 assert spb_player.protocol_parent_id == "2140037800"
8538
8539 def test_sibling_match_no_linked_protocols(self, mock_mass: MagicMock) -> None:
8540 """Test sibling matching returns False when native player has no linked protocols."""
8541 controller = PlayerController(mock_mass)
8542
8543 heos_provider = MockProvider("heos", mass=mock_mass)
8544 heos_player = MockPlayer(
8545 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
8546 )
8547 heos_player.set_initialized()
8548
8549 spb_provider = MockProvider("sendspin", mass=mock_mass)
8550 spb_player = MockPlayer(
8551 spb_provider,
8552 "spb_1",
8553 "Sendspin",
8554 player_type=PlayerType.PROTOCOL,
8555 identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
8556 )
8557 spb_player.set_initialized()
8558
8559 controller._players = {"heos_1": heos_player, "spb_1": spb_player}
8560
8561 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
8562
8563 def test_sibling_match_no_identifier_overlap(self, mock_mass: MagicMock) -> None:
8564 """Test sibling matching returns False when identifiers don't overlap."""
8565 controller = PlayerController(mock_mass)
8566
8567 heos_provider = MockProvider("heos", mass=mock_mass)
8568 heos_player = MockPlayer(
8569 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
8570 )
8571 heos_player.set_initialized()
8572
8573 # AirPlay with one MAC
8574 ap_provider = MockProvider("airplay", mass=mock_mass)
8575 ap_player = MockPlayer(
8576 ap_provider,
8577 "ap_1",
8578 "AirPlay",
8579 player_type=PlayerType.PROTOCOL,
8580 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8581 )
8582 ap_player.set_initialized()
8583
8584 controller._players = {"heos_1": heos_player, "ap_1": ap_player}
8585 controller._add_protocol_link(heos_player, ap_player, "airplay")
8586
8587 # Sendspin with different MAC, no shared identifiers
8588 spb_provider = MockProvider("sendspin", mass=mock_mass)
8589 spb_player = MockPlayer(
8590 spb_provider,
8591 "spb_1",
8592 "Sendspin",
8593 player_type=PlayerType.PROTOCOL,
8594 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
8595 )
8596 spb_player.set_initialized()
8597 controller._players["spb_1"] = spb_player
8598
8599 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
8600 assert spb_player.protocol_parent_id is None
8601
8602 def test_sibling_match_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
8603 """Test sibling matching refuses link when domain is already active on native player."""
8604 controller = PlayerController(mock_mass)
8605
8606 heos_provider = MockProvider("heos", mass=mock_mass)
8607 heos_player = MockPlayer(
8608 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
8609 )
8610 heos_player.set_initialized()
8611
8612 # AirPlay already linked
8613 ap_provider = MockProvider("airplay", mass=mock_mass)
8614 ap_player = MockPlayer(
8615 ap_provider,
8616 "ap_1",
8617 "AirPlay",
8618 player_type=PlayerType.PROTOCOL,
8619 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8620 )
8621 ap_player.set_initialized()
8622
8623 controller._players = {"heos_1": heos_player, "ap_1": ap_player}
8624 controller._add_protocol_link(heos_player, ap_player, "airplay")
8625
8626 # Second AirPlay instance with same MAC - should be refused (duplicate domain)
8627 ap2 = MockPlayer(
8628 ap_provider,
8629 "ap_2",
8630 "AirPlay 2",
8631 player_type=PlayerType.PROTOCOL,
8632 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8633 )
8634 ap2.set_initialized()
8635 controller._players["ap_2"] = ap2
8636
8637 assert controller._match_via_linked_protocols(heos_player, ap2, "airplay") is False
8638 assert ap2.protocol_parent_id is None
8639
8640 def test_try_link_to_existing_uses_sibling_matching(self, mock_mass: MagicMock) -> None:
8641 """Test _try_link_to_existing_player falls back to sibling matching."""
8642 controller = PlayerController(mock_mass)
8643
8644 # No cached protocol IDs for HEOS
8645 mock_mass.config.get = MagicMock(return_value=[])
8646
8647 heos_provider = MockProvider("heos", mass=mock_mass)
8648 heos_player = MockPlayer(
8649 heos_provider,
8650 "2140037800",
8651 "Denon AVR-X2700H",
8652 player_type=PlayerType.PLAYER,
8653 )
8654 heos_player.set_initialized()
8655
8656 ap_provider = MockProvider("airplay", mass=mock_mass)
8657 ap_player = MockPlayer(
8658 ap_provider,
8659 "ap000678747b16",
8660 "Denon AirPlay",
8661 player_type=PlayerType.PROTOCOL,
8662 identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
8663 )
8664 ap_player.set_initialized()
8665
8666 controller._players = {
8667 "2140037800": heos_player,
8668 "ap000678747b16": ap_player,
8669 }
8670 controller._add_protocol_link(heos_player, ap_player, "airplay")
8671
8672 # Sendspin with matching AIRPLAY_ID
8673 spb_provider = MockProvider("sendspin", mass=mock_mass)
8674 spb_player = MockPlayer(
8675 spb_provider,
8676 "spb_000678747b16",
8677 "Denon Sendspin",
8678 player_type=PlayerType.PROTOCOL,
8679 identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
8680 )
8681 spb_player.set_initialized()
8682 controller._players["spb_000678747b16"] = spb_player
8683
8684 # _try_link_to_existing_player should succeed via sibling matching
8685 result = controller._try_link_to_existing_player(spb_player, "sendspin")
8686 assert result is True
8687 assert spb_player.protocol_parent_id == "2140037800"
8688
8689
8690class TestDelayedEvalRetry:
8691 """
8692 Tests for the retry of _try_link_to_existing_player in _delayed_protocol_evaluation.
8693
8694 When a protocol player's delayed evaluation fires, native players may have
8695 registered during the delay period. The retry ensures they are checked before
8696 falling through to universal player creation.
8697 """
8698
8699 @pytest.mark.asyncio
8700 async def test_delayed_eval_links_to_native_registered_during_delay(
8701 self, mock_mass: MagicMock
8702 ) -> None:
8703 """
8704 Test delayed eval links to a native player that registered during the delay.
8705
8706 Scenario: Sendspin registers before HEOS. The delayed evaluation fires
8707 after HEOS has registered and has AirPlay linked. The retry should find
8708 HEOS via sibling matching and link Sendspin.
8709 """
8710 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
8711
8712 # No cached protocol IDs
8713 mock_mass.config.get = MagicMock(return_value=[])
8714
8715 # HEOS native player (no device identifiers)
8716 heos_provider = MockProvider("heos", mass=mock_mass)
8717 heos_player = MockPlayer(
8718 heos_provider,
8719 "2140037800",
8720 "Denon AVR-X2700H",
8721 player_type=PlayerType.PLAYER,
8722 )
8723 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8724 heos_player._cache.clear()
8725 heos_player.set_initialized()
8726
8727 # AirPlay already linked to HEOS
8728 ap_provider = MockProvider("airplay", mass=mock_mass)
8729 ap_player = MockPlayer(
8730 ap_provider,
8731 "ap000678747b16",
8732 "Denon AirPlay",
8733 player_type=PlayerType.PROTOCOL,
8734 identifiers={
8735 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8736 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8737 },
8738 )
8739 ap_player.set_initialized()
8740 ap_player.set_protocol_parent_id("2140037800")
8741
8742 # Sendspin (not yet linked)
8743 spb_provider = MockProvider("sendspin", mass=mock_mass)
8744 spb_player = MockPlayer(
8745 spb_provider,
8746 "spb_000678747b16",
8747 "Denon Sendspin",
8748 player_type=PlayerType.PROTOCOL,
8749 identifiers={
8750 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8751 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8752 },
8753 )
8754 spb_player.set_initialized()
8755
8756 # Set up players dict (simulating: HEOS registered during the delay)
8757 controller._players = {
8758 "2140037800": heos_player,
8759 "ap000678747b16": ap_player,
8760 "spb_000678747b16": spb_player,
8761 }
8762
8763 # Link AirPlay to HEOS
8764 controller._add_protocol_link(heos_player, ap_player, "airplay")
8765
8766 # Run delayed evaluation for Sendspin
8767 await controller._delayed_protocol_evaluation("spb_000678747b16")
8768
8769 # Sendspin should be linked to HEOS, not a new universal player
8770 assert spb_player.protocol_parent_id == "2140037800"
8771
8772 @pytest.mark.asyncio
8773 async def test_delayed_eval_skips_already_linked(self, mock_mass: MagicMock) -> None:
8774 """Test delayed eval returns early if protocol player was linked during the delay."""
8775 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
8776
8777 spb_provider = MockProvider("sendspin", mass=mock_mass)
8778 spb_player = MockPlayer(
8779 spb_provider,
8780 "spb_1",
8781 "Sendspin",
8782 player_type=PlayerType.PROTOCOL,
8783 identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
8784 )
8785 spb_player.set_initialized()
8786 # Simulate it was linked during the delay
8787 spb_player.set_protocol_parent_id("some_parent")
8788
8789 controller._players = {"spb_1": spb_player}
8790
8791 # Should return early without creating universal player
8792 await controller._delayed_protocol_evaluation("spb_1")
8793 assert spb_player.protocol_parent_id == "some_parent"
8794
8795
8796class TestNativePlayerSiblingLinking:
8797 """
8798 Tests for the second pass in _try_link_protocols_to_native using sibling matching.
8799
8800 After a native player registers and recovers cached protocol links, the second
8801 pass should match remaining unlinked protocol players via sibling identifiers.
8802 """
8803
8804 def test_native_registration_links_sendspin_via_sibling(self, mock_mass: MagicMock) -> None:
8805 """
8806 Test HEOS registration links Sendspin via AirPlay sibling identifiers.
8807
8808 Scenario: Sendspin registered before HEOS. When HEOS registers, the first
8809 pass can't match Sendspin (no direct identifiers). After recovering cached
8810 AirPlay link, the second pass matches Sendspin via AirPlay's AIRPLAY_ID.
8811 """
8812 controller = PlayerController(mock_mass)
8813
8814 # Set up config to return cached AirPlay link for HEOS
8815 def config_get_side_effect(key: str, default: object = None) -> object:
8816 if key == "players/2140037800/values/linked_protocol_ids":
8817 return ["ap000678747b16"]
8818 if key == "players/ap000678747b16":
8819 return {
8820 "provider": "airplay--test",
8821 "player_type": "protocol",
8822 "values": {"protocol_parent_id": "2140037800"},
8823 }
8824 if key == "players":
8825 return {
8826 "ap000678747b16": {
8827 "provider": "airplay--test",
8828 "player_type": "protocol",
8829 "values": {"protocol_parent_id": "2140037800"},
8830 },
8831 "spb_000678747b16": {
8832 "provider": "sendspin--test",
8833 "player_type": "protocol",
8834 "values": {},
8835 },
8836 }
8837 return default
8838
8839 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
8840 mock_mass.get_provider = MagicMock(return_value=None)
8841
8842 # AirPlay protocol player (already registered, waiting for parent)
8843 ap_provider = MockProvider("airplay", mass=mock_mass)
8844 ap_player = MockPlayer(
8845 ap_provider,
8846 "ap000678747b16",
8847 "Denon AirPlay",
8848 player_type=PlayerType.PROTOCOL,
8849 identifiers={
8850 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8851 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8852 },
8853 )
8854 ap_player.set_initialized()
8855 # AirPlay has cached parent set but parent hasn't registered yet
8856 ap_player.set_protocol_parent_id("2140037800")
8857
8858 # Sendspin (registered, not linked)
8859 spb_provider = MockProvider("sendspin", mass=mock_mass)
8860 spb_player = MockPlayer(
8861 spb_provider,
8862 "spb_000678747b16",
8863 "Denon Sendspin",
8864 player_type=PlayerType.PROTOCOL,
8865 identifiers={
8866 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8867 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8868 },
8869 )
8870 spb_player.set_initialized()
8871
8872 # HEOS native player registers now
8873 heos_provider = MockProvider("heos", mass=mock_mass)
8874 heos_player = MockPlayer(
8875 heos_provider,
8876 "2140037800",
8877 "Denon AVR-X2700H",
8878 player_type=PlayerType.PLAYER,
8879 )
8880 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8881 heos_player._cache.clear()
8882 heos_player.set_initialized()
8883
8884 controller._players = {
8885 "ap000678747b16": ap_player,
8886 "spb_000678747b16": spb_player,
8887 "2140037800": heos_player,
8888 }
8889
8890 # Simulate what happens when HEOS registers
8891 controller._try_link_protocols_to_native(heos_player)
8892
8893 # AirPlay should be recovered from cache (linked_output_protocols)
8894 airplay_linked = any(
8895 link.output_protocol_id == "ap000678747b16"
8896 for link in heos_player.linked_output_protocols
8897 )
8898 assert airplay_linked, "AirPlay should be recovered from cached protocol links"
8899
8900 # Sendspin should be linked via sibling matching (second pass)
8901 assert spb_player.protocol_parent_id == "2140037800"
8902 sendspin_linked = any(
8903 link.output_protocol_id == "spb_000678747b16"
8904 for link in heos_player.linked_output_protocols
8905 )
8906 assert sendspin_linked, "Sendspin should be linked via sibling protocol matching"
8907
8908
8909class TestCachedProtocolLinkRecovery:
8910 """Tests for recovering protocol links from config."""
8911
8912 def test_recovered_links_are_applied_through_the_setter(self, mock_mass: MagicMock) -> None:
8913 """Recovery applies a single link per protocol domain through the setter."""
8914 controller = PlayerController(mock_mass)
8915
8916 protocol_configs = {
8917 "ap_first": {
8918 "provider": "airplay--first",
8919 "player_type": "protocol",
8920 "values": {"protocol_parent_id": "heos_player"},
8921 },
8922 "ap_second": {
8923 "provider": "airplay--second",
8924 "player_type": "protocol",
8925 "values": {"protocol_parent_id": "heos_player"},
8926 },
8927 }
8928
8929 def config_get_side_effect(key: str, default: object = None) -> object:
8930 if key == "players/heos_player/values/linked_protocol_ids":
8931 return ["ap_first", "ap_second"]
8932 if key == "players":
8933 return protocol_configs
8934 if key.startswith("players/"):
8935 return protocol_configs.get(key.split("/", maxsplit=1)[1])
8936 return default
8937
8938 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
8939
8940 heos_player = MockPlayer(
8941 MockProvider("heos", mass=mock_mass),
8942 "heos_player",
8943 "Denon AVR-X2700H",
8944 )
8945
8946 controller._recover_cached_protocol_links(heos_player)
8947
8948 recovered = [link.output_protocol_id for link in heos_player.linked_output_protocols]
8949 assert recovered == ["ap_first"]
8950 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
8951
8952 # a repeat pass has nothing left to recover, so no update is triggered
8953 controller._recover_cached_protocol_links(heos_player)
8954 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
8955
8956 def test_protocol_owned_by_another_parent_is_not_recovered(self, mock_mass: MagicMock) -> None:
8957 """A cached protocol that another parent owns stays out of this parent's outputs."""
8958 controller = PlayerController(mock_mass)
8959
8960 store: dict[str, Any] = {
8961 CONF_PLAYERS: {
8962 "ap_shared": {
8963 "provider": "airplay--x",
8964 "player_type": "protocol",
8965 "values": {CONF_PROTOCOL_PARENT_ID: "cast_owner"},
8966 },
8967 "heos_player": {
8968 "provider": "heos--y",
8969 "player_type": "player",
8970 "values": {"linked_protocol_ids": ["ap_shared"]},
8971 },
8972 "cast_owner": {
8973 "provider": "chromecast--z",
8974 "player_type": "player",
8975 "values": {"linked_protocol_ids": ["ap_shared"]},
8976 },
8977 }
8978 }
8979 _wire_nested_config(mock_mass, store)
8980
8981 airplay = MockPlayer(
8982 MockProvider("airplay", mass=mock_mass),
8983 "ap_shared",
8984 "AVR (AirPlay)",
8985 player_type=PlayerType.PROTOCOL,
8986 )
8987 airplay.set_protocol_parent_id("cast_owner")
8988 owner = MockPlayer(MockProvider("chromecast", mass=mock_mass), "cast_owner", "AVR (Cast)")
8989 owner.set_linked_output_protocols(
8990 [LinkedOutputProtocol(output_protocol_id="ap_shared", protocol_domain="airplay")]
8991 )
8992 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
8993 controller._players = {
8994 "ap_shared": airplay,
8995 "cast_owner": owner,
8996 "heos_player": heos_player,
8997 }
8998
8999 controller._recover_cached_protocol_links(heos_player)
9000
9001 assert heos_player.linked_output_protocols == []
9002 # the domain slot stays free for this parent's own AirPlay endpoint
9003 assert not controller._parent_has_active_protocol_from_domain(heos_player, "airplay")
9004 assert airplay.protocol_parent_id == "cast_owner"
9005
9006 def test_unowned_and_own_cached_protocols_are_still_recovered(
9007 self, mock_mass: MagicMock
9008 ) -> None:
9009 """A disabled protocol without a live parent is still recovered for its parent."""
9010 controller = PlayerController(mock_mass)
9011
9012 store: dict[str, Any] = {
9013 CONF_PLAYERS: {
9014 "ap_disabled": {
9015 "provider": "airplay--x",
9016 "player_type": "protocol",
9017 "values": {CONF_PROTOCOL_PARENT_ID: "heos_player"},
9018 },
9019 "dlna_own": {
9020 "provider": "dlna--x",
9021 "player_type": "protocol",
9022 "values": {CONF_PROTOCOL_PARENT_ID: "heos_player"},
9023 },
9024 "heos_player": {
9025 "provider": "heos--y",
9026 "player_type": "player",
9027 "values": {"linked_protocol_ids": ["ap_disabled", "dlna_own"]},
9028 },
9029 }
9030 }
9031 _wire_nested_config(mock_mass, store)
9032
9033 # registered but not owned by anyone (e.g. freshly detached)
9034 dlna = MockPlayer(
9035 MockProvider("dlna", mass=mock_mass),
9036 "dlna_own",
9037 "AVR (DLNA)",
9038 player_type=PlayerType.PROTOCOL,
9039 )
9040 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9041 controller._players = {"dlna_own": dlna, "heos_player": heos_player}
9042
9043 controller._recover_cached_protocol_links(heos_player)
9044
9045 recovered = {link.output_protocol_id for link in heos_player.linked_output_protocols}
9046 # ap_disabled is not registered at all, dlna_own is registered without a parent
9047 assert recovered == {"ap_disabled", "dlna_own"}
9048
9049 def test_protocol_waiting_for_its_owner_is_not_recovered(self, mock_mass: MagicMock) -> None:
9050 """A protocol whose owner has not registered yet is left to that owner."""
9051 controller = PlayerController(mock_mass)
9052
9053 store: dict[str, Any] = {
9054 CONF_PLAYERS: {
9055 "ap_shared": {
9056 "provider": "airplay--x",
9057 "player_type": "protocol",
9058 "values": {CONF_PROTOCOL_PARENT_ID: "cast_owner"},
9059 },
9060 "heos_player": {
9061 "provider": "heos--y",
9062 "player_type": "player",
9063 "values": {"linked_protocol_ids": ["ap_shared"]},
9064 },
9065 "cast_owner": {
9066 "provider": "chromecast--z",
9067 "player_type": "player",
9068 "values": {"linked_protocol_ids": ["ap_shared"]},
9069 },
9070 }
9071 }
9072 _wire_nested_config(mock_mass, store)
9073
9074 # the protocol is registered but still unparented: its owner is not registered yet
9075 airplay = MockPlayer(
9076 MockProvider("airplay", mass=mock_mass),
9077 "ap_shared",
9078 "AVR (AirPlay)",
9079 player_type=PlayerType.PROTOCOL,
9080 )
9081 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9082 controller._players = {"ap_shared": airplay, "heos_player": heos_player}
9083
9084 controller._recover_cached_protocol_links(heos_player)
9085
9086 assert airplay.protocol_parent_id is None
9087 assert heos_player.linked_output_protocols == []
9088 assert not controller._parent_has_active_protocol_from_domain(heos_player, "airplay")
9089
9090 def test_bridge_owned_by_wrapper_leaves_native_domain_slot_free(
9091 self, mock_mass: MagicMock
9092 ) -> None:
9093 """
9094 A bridge that stayed with its wrapper is not recovered onto a native player.
9095
9096 A native player may carry a stale cached id for a bridge whose underlying
9097 connection it could not claim. The bridge follows that connection, so the
9098 native player must leave the domain slot free for its own bridge.
9099 """
9100 controller = PlayerController(mock_mass)
9101
9102 store: dict[str, Any] = {
9103 CONF_PLAYERS: {
9104 "cast_base": {
9105 "provider": "chromecast--x",
9106 "player_type": "protocol",
9107 "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9108 },
9109 "spb_bridge": {
9110 "provider": "sendspin--y",
9111 "player_type": "protocol",
9112 "values": {
9113 CONF_PROTOCOL_PARENT_ID: "heos_player",
9114 CONF_UNDERLYING_PLAYER_ID: "cast_base",
9115 },
9116 },
9117 STORED_UNIVERSAL_ID: {
9118 "provider": "universal_player",
9119 "player_type": "player",
9120 "values": {"linked_protocol_ids": ["cast_base"]},
9121 },
9122 "heos_player": {
9123 "provider": "heos--z",
9124 "player_type": "player",
9125 "values": {"linked_protocol_ids": ["spb_bridge"]},
9126 },
9127 }
9128 }
9129 _wire_nested_config(mock_mass, store)
9130
9131 wrapper = _create_universal_player(
9132 mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["cast_base"]
9133 )
9134 base = MockPlayer(
9135 MockProvider("chromecast", mass=mock_mass),
9136 "cast_base",
9137 "AVR (Cast 2)",
9138 player_type=PlayerType.PROTOCOL,
9139 )
9140 base.set_protocol_parent_id(STORED_UNIVERSAL_ID)
9141 wrapper.set_linked_output_protocols(
9142 [LinkedOutputProtocol(output_protocol_id="cast_base", protocol_domain="chromecast")]
9143 )
9144 bridge = MockPlayer(
9145 MockProvider("sendspin", mass=mock_mass),
9146 "spb_bridge",
9147 "AVR (Bridge)",
9148 player_type=PlayerType.PROTOCOL,
9149 )
9150 bridge._attr_underlying_player_id = "cast_base"
9151 bridge.set_initialized()
9152 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9153 controller._players = {
9154 STORED_UNIVERSAL_ID: wrapper,
9155 "cast_base": base,
9156 "spb_bridge": bridge,
9157 "heos_player": heos_player,
9158 }
9159
9160 # the bridge resolves via its underlying connection, so it joins the wrapper
9161 controller._try_link_protocol_to_native(bridge)
9162 assert bridge.protocol_parent_id == STORED_UNIVERSAL_ID
9163
9164 controller._recover_cached_protocol_links(heos_player)
9165
9166 assert heos_player.linked_output_protocols == []
9167 assert not controller._parent_has_active_protocol_from_domain(heos_player, "sendspin")
9168
9169 def test_claiming_a_protocol_clears_it_from_its_previous_parent(
9170 self, mock_mass: MagicMock
9171 ) -> None:
9172 """
9173 A parent loses its output entry for a protocol that another parent claims.
9174
9175 A bridge that registers before the connection it runs on has no owner to
9176 compare against yet, so a stale cached id still recovers it onto a native
9177 player. Once the connection registers and takes the bridge along, the native
9178 player must give up its entry instead of listing the bridge as well.
9179 """
9180 controller = PlayerController(mock_mass)
9181
9182 store: dict[str, Any] = {
9183 CONF_PLAYERS: {
9184 "cast_base": {
9185 "provider": "chromecast--x",
9186 "player_type": "protocol",
9187 "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9188 },
9189 "spb_bridge": {
9190 "provider": "sendspin--y",
9191 "player_type": "protocol",
9192 "values": {
9193 CONF_PROTOCOL_PARENT_ID: "heos_player",
9194 CONF_UNDERLYING_PLAYER_ID: "cast_base",
9195 },
9196 },
9197 STORED_UNIVERSAL_ID: {
9198 "provider": "universal_player",
9199 "player_type": "player",
9200 "values": {"linked_protocol_ids": ["cast_base"]},
9201 },
9202 "heos_player": {
9203 "provider": "heos--z",
9204 "player_type": "player",
9205 "values": {"linked_protocol_ids": ["spb_bridge"]},
9206 },
9207 }
9208 }
9209 _wire_nested_config(mock_mass, store)
9210
9211 wrapper = _create_universal_player(
9212 mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["cast_base"]
9213 )
9214 bridge = MockPlayer(
9215 MockProvider("sendspin", mass=mock_mass),
9216 "spb_bridge",
9217 "AVR (Bridge)",
9218 player_type=PlayerType.PROTOCOL,
9219 )
9220 bridge._attr_underlying_player_id = "cast_base"
9221 bridge.set_initialized()
9222 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9223 controller._players = {
9224 STORED_UNIVERSAL_ID: wrapper,
9225 "spb_bridge": bridge,
9226 "heos_player": heos_player,
9227 }
9228
9229 # the bridge has no owner yet, so the cached id recovers it onto the native player
9230 controller._recover_cached_protocol_links(heos_player)
9231 assert [link.output_protocol_id for link in heos_player.linked_output_protocols] == [
9232 "spb_bridge"
9233 ]
9234
9235 # the connection registers and joins the wrapper, taking the bridge with it
9236 base = MockPlayer(
9237 MockProvider("chromecast", mass=mock_mass),
9238 "cast_base",
9239 "AVR (Cast 2)",
9240 player_type=PlayerType.PROTOCOL,
9241 )
9242 controller._players["cast_base"] = base
9243 controller._try_link_protocol_to_native(base)
9244
9245 assert bridge.protocol_parent_id == STORED_UNIVERSAL_ID
9246 assert heos_player.linked_output_protocols == []
9247 assert not controller._parent_has_active_protocol_from_domain(heos_player, "sendspin")
9248 # the stale cached id is gone too, so the bridge is not offered here as disabled
9249 assert store[CONF_PLAYERS]["heos_player"]["values"]["linked_protocol_ids"] == []
9250
9251 def test_claiming_a_protocol_clears_universal_membership(self, mock_mass: MagicMock) -> None:
9252 """A wrapper that loses a protocol also gives up its stored membership."""
9253 controller = PlayerController(mock_mass)
9254
9255 store: dict[str, Any] = {
9256 CONF_PLAYERS: {
9257 "ap_shared": {
9258 "provider": "airplay--x",
9259 "player_type": "protocol",
9260 "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9261 },
9262 STORED_UNIVERSAL_ID: {
9263 "provider": "universal_player",
9264 "player_type": "player",
9265 "values": {"linked_protocol_ids": ["ap_shared"]},
9266 },
9267 "heos_player": {
9268 "provider": "heos--z",
9269 "player_type": "player",
9270 "values": {},
9271 },
9272 }
9273 }
9274 _wire_nested_config(mock_mass, store)
9275
9276 wrapper = _create_universal_player(
9277 mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["ap_shared"]
9278 )
9279 wrapper.set_linked_output_protocols(
9280 [LinkedOutputProtocol(output_protocol_id="ap_shared", protocol_domain="airplay")]
9281 )
9282 airplay = MockPlayer(
9283 MockProvider("airplay", mass=mock_mass),
9284 "ap_shared",
9285 "AVR (AirPlay)",
9286 player_type=PlayerType.PROTOCOL,
9287 )
9288 airplay.set_protocol_parent_id(STORED_UNIVERSAL_ID)
9289 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9290 controller._players = {
9291 STORED_UNIVERSAL_ID: wrapper,
9292 "ap_shared": airplay,
9293 "heos_player": heos_player,
9294 }
9295
9296 controller._add_protocol_link(heos_player, airplay, "airplay")
9297
9298 assert airplay.protocol_parent_id == "heos_player"
9299 assert wrapper.linked_output_protocols == []
9300 # the wrapper's stored membership is authoritative and gets written back on save,
9301 # so it has to give up the protocol there as well
9302 assert wrapper._protocol_player_ids == []
9303 assert store[CONF_PLAYERS][STORED_UNIVERSAL_ID]["values"]["linked_protocol_ids"] == []
9304
9305
9306class TestMultiMACMatching:
9307 """Tests for multi-MAC matching (reported MAC vs ARP MAC)."""
9308
9309 def test_match_on_reported_mac_when_arp_mac_differs(self, mock_mass: MagicMock) -> None:
9310 """Two players match when one's reported MAC equals the other's ARP-resolved MAC."""
9311 controller = PlayerController(mock_mass)
9312
9313 provider = MockProvider("test")
9314 # Player A has ARP-resolved MAC in identifiers, reported MAC saved in extra_data
9315 player_a = MockPlayer(
9316 provider,
9317 "player_a",
9318 "Player A",
9319 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9320 )
9321 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9322
9323 # Player B has a MAC that matches player_a's reported MAC
9324 player_b = MockPlayer(
9325 provider,
9326 "player_b",
9327 "Player B",
9328 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
9329 )
9330
9331 assert controller._identifiers_match(player_a, player_b) is True
9332
9333 def test_match_on_reported_mac_reverse(self, mock_mass: MagicMock) -> None:
9334 """Match when player_b's reported MAC matches player_a's ARP MAC."""
9335 controller = PlayerController(mock_mass)
9336
9337 provider = MockProvider("test")
9338 player_a = MockPlayer(
9339 provider,
9340 "player_a",
9341 "Player A",
9342 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
9343 )
9344
9345 player_b = MockPlayer(
9346 provider,
9347 "player_b",
9348 "Player B",
9349 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9350 )
9351 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9352
9353 assert controller._identifiers_match(player_a, player_b) is True
9354
9355 def test_no_match_when_both_macs_differ(self, mock_mass: MagicMock) -> None:
9356 """No match when all MACs differ between players."""
9357 controller = PlayerController(mock_mass)
9358
9359 provider = MockProvider("test")
9360 player_a = MockPlayer(
9361 provider,
9362 "player_a",
9363 "Player A",
9364 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9365 )
9366 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9367
9368 player_b = MockPlayer(
9369 provider,
9370 "player_b",
9371 "Player B",
9372 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
9373 )
9374 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:04"
9375
9376 assert controller._identifiers_match(player_a, player_b) is False
9377
9378 def test_match_reported_mac_with_la_bit_normalization(self, mock_mass: MagicMock) -> None:
9379 """Reported MAC matching also applies LA bit normalization."""
9380 controller = PlayerController(mock_mass)
9381
9382 provider = MockProvider("test")
9383 # Player A has ARP MAC, reported MAC has LA bit set
9384 player_a = MockPlayer(
9385 provider,
9386 "player_a",
9387 "Player A",
9388 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9389 )
9390 player_a.extra_data["reported_mac"] = "56:78:C9:E6:0D:A0" # LA variant
9391
9392 # Player B has the hardware MAC
9393 player_b = MockPlayer(
9394 provider,
9395 "player_b",
9396 "Player B",
9397 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}, # real MAC
9398 )
9399
9400 assert controller._identifiers_match(player_a, player_b) is True
9401
9402
9403class TestProtocolParentIdPersistence:
9404 """Tests for protocol_parent_id persistence for all parent types."""
9405
9406 def test_parent_id_saved_for_universal_parent(self, mock_mass: MagicMock) -> None:
9407 """protocol_parent_id should be saved when linking to a universal player."""
9408 controller = PlayerController(mock_mass)
9409
9410 def _config_get(key: str, default: object = None) -> object:
9411 # Protocol player config must exist for _save_protocol_parent_id to write
9412 if key == "players/airplay_test":
9413 return {"enabled": True}
9414 return default if default is not None else []
9415
9416 mock_mass.config.get = MagicMock(side_effect=_config_get)
9417
9418 universal_provider = create_mock_universal_provider(mock_mass)
9419 parent = UniversalPlayer(
9420 provider=universal_provider,
9421 player_id="up_test",
9422 name="Test Universal",
9423 device_info=DeviceInfo(),
9424 protocol_player_ids=[],
9425 )
9426 parent.update_state(signal_event=False)
9427
9428 protocol_provider = MockProvider("airplay")
9429 protocol_player = MockPlayer(
9430 protocol_provider,
9431 "airplay_test",
9432 "AirPlay Test",
9433 player_type=PlayerType.PROTOCOL,
9434 )
9435
9436 controller._players = {
9437 "up_test": parent,
9438 "airplay_test": protocol_player,
9439 }
9440
9441 controller._add_protocol_link(parent, protocol_player, "airplay")
9442
9443 # Verify _save_protocol_parent_id was called (config.set with parent ID)
9444 config_set_calls = [
9445 call
9446 for call in mock_mass.config.set.call_args_list
9447 if "protocol_parent_id" in str(call)
9448 ]
9449 assert len(config_set_calls) >= 1, (
9450 "protocol_parent_id should be saved for universal player parents"
9451 )
9452
9453 def test_parent_id_cleared_for_universal_parent(self, mock_mass: MagicMock) -> None:
9454 """protocol_parent_id should be cleared when unlinking from a universal player."""
9455 controller = PlayerController(mock_mass)
9456 mock_mass.config.get = MagicMock(return_value={"enabled": True})
9457
9458 universal_provider = create_mock_universal_provider(mock_mass)
9459 parent = UniversalPlayer(
9460 provider=universal_provider,
9461 player_id="up_test",
9462 name="Test Universal",
9463 device_info=DeviceInfo(),
9464 protocol_player_ids=["airplay_test"],
9465 )
9466 parent.update_state(signal_event=False)
9467
9468 protocol_provider = MockProvider("airplay")
9469 protocol_player = MockPlayer(
9470 protocol_provider,
9471 "airplay_test",
9472 "AirPlay Test",
9473 player_type=PlayerType.PROTOCOL,
9474 )
9475 protocol_player.set_protocol_parent_id("up_test")
9476
9477 # Set up linked protocols
9478 parent.set_linked_output_protocols(
9479 [
9480 LinkedOutputProtocol(
9481 output_protocol_id="airplay_test",
9482 protocol_domain="airplay",
9483 priority=10,
9484 )
9485 ]
9486 )
9487
9488 controller._players = {
9489 "up_test": parent,
9490 "airplay_test": protocol_player,
9491 }
9492
9493 controller._remove_protocol_link(parent, "airplay_test")
9494
9495 # Verify protocol_parent_id was cleared in config
9496 config_set_calls = [
9497 call
9498 for call in mock_mass.config.set.call_args_list
9499 if "protocol_parent_id" in str(call) and call.args[1] is None
9500 ]
9501 assert len(config_set_calls) >= 1, (
9502 "protocol_parent_id should be cleared for universal player parents"
9503 )
9504
9505
9506class TestCleanupProtocolLinks:
9507 """Tests for protocol link cleanup on player removal."""
9508
9509 def test_cleanup_clears_config_parent_id(self, mock_mass: MagicMock) -> None:
9510 """When a universal player is removed, protocol parent_ids are cleared in config."""
9511 controller = PlayerController(mock_mass)
9512 mock_mass.config.get = MagicMock(return_value={"enabled": True})
9513
9514 universal_provider = create_mock_universal_provider(mock_mass)
9515 parent = UniversalPlayer(
9516 provider=universal_provider,
9517 player_id="up_test",
9518 name="Test Universal",
9519 device_info=DeviceInfo(),
9520 protocol_player_ids=["airplay_test"],
9521 )
9522 parent.update_state(signal_event=False)
9523
9524 protocol_provider = MockProvider("airplay")
9525 protocol_player = MockPlayer(
9526 protocol_provider,
9527 "airplay_test",
9528 "AirPlay Test",
9529 player_type=PlayerType.PROTOCOL,
9530 )
9531 protocol_player.set_protocol_parent_id("up_test")
9532
9533 parent.set_linked_output_protocols(
9534 [
9535 LinkedOutputProtocol(
9536 output_protocol_id="airplay_test",
9537 protocol_domain="airplay",
9538 priority=10,
9539 )
9540 ]
9541 )
9542
9543 controller._players = {
9544 "up_test": parent,
9545 "airplay_test": protocol_player,
9546 }
9547
9548 controller._cleanup_protocol_links(parent)
9549
9550 # Verify protocol_parent_id was cleared in config
9551 config_set_calls = [
9552 call
9553 for call in mock_mass.config.set.call_args_list
9554 if "airplay_test" in str(call) and "protocol_parent_id" in str(call)
9555 ]
9556 assert len(config_set_calls) >= 1, (
9557 "protocol_parent_id should be cleared in config on parent removal"
9558 )
9559 # Verify in-memory parent cleared
9560 assert protocol_player.protocol_parent_id is None
9561
9562 def test_cleanup_native_parent_schedules_protocol_re_evaluation(
9563 self, mock_mass: MagicMock
9564 ) -> None:
9565 """Permanent native parent removal should reset links and reschedule protocols."""
9566 controller = PlayerController(mock_mass)
9567
9568 config_store: dict[str, object] = {
9569 "players/sonos_1": {"enabled": True},
9570 "players/sonos_1/values/linked_protocol_ids": ["airplay_live", "dlna_cached"],
9571 "players/airplay_live": {"enabled": True},
9572 "players/dlna_cached": {"enabled": True},
9573 }
9574
9575 def config_get(key: str, default: object = None) -> object:
9576 return config_store.get(key, default)
9577
9578 def config_set(key: str, value: object) -> None:
9579 config_store[key] = value
9580
9581 mock_mass.config.get = MagicMock(side_effect=config_get)
9582 mock_mass.config.set = MagicMock(side_effect=config_set)
9583
9584 native_provider = MockProvider("sonos", mass=mock_mass)
9585 parent = MockPlayer(
9586 native_provider,
9587 "sonos_1",
9588 "Sonos",
9589 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9590 )
9591
9592 protocol_provider = MockProvider("airplay", mass=mock_mass)
9593 protocol_player = MockPlayer(
9594 protocol_provider,
9595 "airplay_live",
9596 "AirPlay Test",
9597 player_type=PlayerType.PROTOCOL,
9598 )
9599 protocol_player.set_protocol_parent_id("sonos_1")
9600
9601 parent.set_linked_output_protocols(
9602 [
9603 LinkedOutputProtocol(
9604 output_protocol_id="airplay_live",
9605 protocol_domain="airplay",
9606 priority=10,
9607 )
9608 ]
9609 )
9610
9611 controller._players = {
9612 "sonos_1": parent,
9613 "airplay_live": protocol_player,
9614 }
9615
9616 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9617 controller._cleanup_protocol_links(parent)
9618
9619 assert protocol_player.protocol_parent_id is None
9620 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9621 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
9622 mock_schedule.assert_called_once_with(protocol_player)
9623
9624 def test_cleanup_universal_parent_clears_cached_only_protocol_ids(
9625 self, mock_mass: MagicMock
9626 ) -> None:
9627 """Universal cleanup should also clear cached protocol ids that are not active."""
9628 controller = PlayerController(mock_mass)
9629
9630 config_store: dict[str, object] = {
9631 "players/up_test": {"enabled": True},
9632 "players/airplay_live": {"enabled": True},
9633 "players/dlna_cached": {"enabled": True},
9634 }
9635
9636 def config_get(key: str, default: object = None) -> object:
9637 return config_store.get(key, default)
9638
9639 def config_set(key: str, value: object) -> None:
9640 config_store[key] = value
9641
9642 mock_mass.config.get = MagicMock(side_effect=config_get)
9643 mock_mass.config.set = MagicMock(side_effect=config_set)
9644
9645 universal_provider = create_mock_universal_provider(mock_mass)
9646 parent = UniversalPlayer(
9647 provider=universal_provider,
9648 player_id="up_test",
9649 name="Test Universal",
9650 device_info=DeviceInfo(),
9651 protocol_player_ids=["airplay_live", "dlna_cached"],
9652 )
9653 parent.update_state(signal_event=False)
9654
9655 protocol_provider = MockProvider("airplay", mass=mock_mass)
9656 protocol_player = MockPlayer(
9657 protocol_provider,
9658 "airplay_live",
9659 "AirPlay Test",
9660 player_type=PlayerType.PROTOCOL,
9661 )
9662 protocol_player.set_protocol_parent_id("up_test")
9663
9664 parent.set_linked_output_protocols(
9665 [
9666 LinkedOutputProtocol(
9667 output_protocol_id="airplay_live",
9668 protocol_domain="airplay",
9669 priority=10,
9670 )
9671 ]
9672 )
9673
9674 controller._players = {
9675 "up_test": parent,
9676 "airplay_live": protocol_player,
9677 }
9678
9679 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9680 controller._cleanup_protocol_links(parent)
9681
9682 assert protocol_player.protocol_parent_id is None
9683 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9684 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
9685 mock_schedule.assert_called_once_with(protocol_player)
9686
9687
9688class TestDetachProtocolChildren:
9689 """Tests for detaching protocol players of a removed player that is not registered."""
9690
9691 @staticmethod
9692 def _setup(
9693 mock_mass: MagicMock, live_parent_id: str | None
9694 ) -> tuple[PlayerController, MockPlayer, dict[str, object]]:
9695 """Set up a registered protocol player whose (unregistered) parent is sonos_1."""
9696 controller = PlayerController(mock_mass)
9697
9698 config_store: dict[str, object] = {
9699 "players": {"sonos_1": {}, "airplay_live": {}},
9700 "players/sonos_1": {"enabled": True},
9701 "players/airplay_live": {"enabled": True},
9702 "players/airplay_live/values/protocol_parent_id": "sonos_1",
9703 }
9704
9705 mock_mass.config.get = MagicMock(
9706 side_effect=lambda key, default=None: config_store.get(key, default)
9707 )
9708 mock_mass.config.set = MagicMock(
9709 side_effect=lambda key, value: config_store.__setitem__(key, value)
9710 )
9711
9712 protocol_player = MockPlayer(
9713 MockProvider("airplay", mass=mock_mass),
9714 "airplay_live",
9715 "AirPlay Test",
9716 player_type=PlayerType.PROTOCOL,
9717 )
9718 protocol_player.set_protocol_parent_id(live_parent_id)
9719 controller._players = {"airplay_live": protocol_player}
9720 return controller, protocol_player, config_store
9721
9722 def test_detach_on_removal_of_an_unregistered_parent(self, mock_mass: MagicMock) -> None:
9723 """Removing a player that is no longer registered detaches its protocol player."""
9724 controller, protocol_player, config_store = self._setup(mock_mass, "sonos_1")
9725
9726 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9727 controller.delete_player_config("sonos_1")
9728
9729 assert protocol_player.protocol_parent_id is None
9730 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9731 mock_schedule.assert_called_once_with(protocol_player)
9732
9733 def test_detach_of_a_protocol_player_waiting_for_its_parent(self, mock_mass: MagicMock) -> None:
9734 """A protocol player that only has the parent link in its config is detached too."""
9735 controller, protocol_player, config_store = self._setup(mock_mass, None)
9736
9737 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9738 controller.delete_player_config("sonos_1")
9739
9740 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9741 mock_schedule.assert_called_once_with(protocol_player)
9742
9743 def test_detach_skips_a_protocol_player_of_another_parent(self, mock_mass: MagicMock) -> None:
9744 """A protocol player that already moved to another parent keeps its link."""
9745 controller, protocol_player, config_store = self._setup(mock_mass, "cast_1")
9746
9747 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9748 controller.delete_player_config("sonos_1")
9749
9750 assert protocol_player.protocol_parent_id == "cast_1"
9751 assert config_store["players/airplay_live/values/protocol_parent_id"] == "sonos_1"
9752 mock_schedule.assert_not_called()
9753
9754
9755class TestStaleConfigMigration:
9756 """Tests for protocol parent link repair on startup."""
9757
9758 def test_clears_stale_parent_ids(self, mock_mass: MagicMock) -> None:
9759 """Stale parent_ids pointing to deleted players are cleared on startup."""
9760 controller = PlayerController(mock_mass)
9761
9762 # Set up config with a protocol player pointing to a deleted universal player
9763 mock_mass.config.get = MagicMock(
9764 return_value={
9765 "airplay_test": {
9766 "player_type": "protocol",
9767 "values": {
9768 "protocol_parent_id": "up_deleted_player",
9769 },
9770 },
9771 "heos_player": {
9772 "player_type": "player",
9773 "values": {},
9774 },
9775 }
9776 )
9777
9778 controller._repair_protocol_parent_links()
9779
9780 # Verify the stale parent_id was cleared
9781 mock_mass.config.set.assert_any_call(
9782 "players/airplay_test/values/protocol_parent_id",
9783 None,
9784 )
9785 mock_mass.config.set_player_type.assert_not_called()
9786
9787 def test_keeps_valid_parent_ids(self, mock_mass: MagicMock) -> None:
9788 """Valid parent_ids pointing to existing players are preserved."""
9789 controller = PlayerController(mock_mass)
9790
9791 mock_mass.config.get = MagicMock(
9792 return_value={
9793 "airplay_test": {
9794 "player_type": "protocol",
9795 "values": {
9796 "protocol_parent_id": "up_valid_player",
9797 },
9798 },
9799 "up_valid_player": {
9800 "player_type": "group",
9801 "provider": "universal_player",
9802 "values": {},
9803 },
9804 }
9805 )
9806
9807 controller._repair_protocol_parent_links()
9808
9809 # Verify no set calls were made to clear the parent_id
9810 for call in mock_mass.config.set.call_args_list:
9811 assert "protocol_parent_id" not in str(call), "Valid parent_id should not be cleared"
9812 mock_mass.config.set_player_type.assert_not_called()
9813
9814 def test_heals_stale_player_type_of_linked_child(self, mock_mass: MagicMock) -> None:
9815 """A parented child whose player_type was corrupted is healed back to protocol."""
9816 controller = PlayerController(mock_mass)
9817
9818 mock_mass.config.get = MagicMock(
9819 return_value={
9820 "squeezelite_child": {
9821 "player_type": "player",
9822 "values": {
9823 "protocol_parent_id": "up_valid_player",
9824 },
9825 },
9826 "up_valid_player": {
9827 "player_type": "group",
9828 "provider": "universal_player",
9829 "values": {},
9830 },
9831 }
9832 )
9833
9834 controller._repair_protocol_parent_links()
9835
9836 mock_mass.config.set_player_type.assert_called_once_with(
9837 "squeezelite_child", PlayerType.PROTOCOL
9838 )
9839
9840 def test_native_registration_clears_stale_parent_link(self, mock_mass: MagicMock) -> None:
9841 """A player registering with a non-protocol type drops its leftover parent link."""
9842 controller = PlayerController(mock_mass)
9843 provider = MockProvider("sendspin", mass=mock_mass)
9844 player = MockPlayer(provider, "web_player", "Web Player", player_type=PlayerType.PLAYER)
9845
9846 config_values = {
9847 f"{CONF_PLAYERS}/web_player": {"player_type": "player"},
9848 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id": "up_old_parent",
9849 }
9850 mock_mass.config.get = MagicMock(
9851 side_effect=lambda key, default=None: config_values.get(key, default)
9852 )
9853
9854 with patch.object(controller, "_try_link_protocols_to_native"):
9855 controller._evaluate_protocol_links(player)
9856
9857 mock_mass.config.set.assert_any_call(
9858 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id",
9859 None,
9860 )
9861
9862
9863class TestUniversalPlayerRestoreOrphanCleanup:
9864 """Tests for UniversalPlayerProvider._restore_player membership repair behavior."""
9865
9866 @staticmethod
9867 def _setup_config_get(
9868 mock_mass: MagicMock,
9869 universal_id: str,
9870 linked_protocol_ids: list[str],
9871 protocol_configs: dict[str, dict[str, object]],
9872 ) -> None:
9873 """Wire mass.config.get to return universal/protocol configs by key."""
9874 universal_conf = {
9875 "values": {
9876 "linked_protocol_ids": list(linked_protocol_ids),
9877 "device_identifiers": {},
9878 "device_info": {},
9879 },
9880 "name": "Test Universal",
9881 }
9882 all_configs: dict[str, object] = {universal_id: universal_conf, **protocol_configs}
9883
9884 def _get(key: str, default: object = None) -> object:
9885 if key == CONF_PLAYERS:
9886 return all_configs
9887 if key.startswith(f"{CONF_PLAYERS}/"):
9888 pid = key.split("/", 1)[1]
9889 return all_configs.get(pid, default)
9890 return default
9891
9892 mock_mass.config.get.side_effect = _get
9893
9894 @pytest.mark.asyncio
9895 async def test_orphan_protocol_dropped_but_configs_kept(self, mock_mass: MagicMock) -> None:
9896 """Orphan protocol is dropped from membership without deleting any config."""
9897 provider = create_mock_universal_provider(mock_mass)
9898 universal_id = "up_test"
9899 orphan_id = "spb_orphan"
9900
9901 self._setup_config_get(
9902 mock_mass,
9903 universal_id,
9904 [orphan_id],
9905 {
9906 orphan_id: {
9907 "player_type": "protocol",
9908 "values": {"protocol_parent_id": None},
9909 },
9910 },
9911 )
9912 mock_mass.players = MagicMock()
9913 mock_mass.players.get_player = MagicMock(return_value=None)
9914 mock_mass.players.delete_player_config = MagicMock()
9915 mock_mass.players.unregister = AsyncMock()
9916 mock_mass.players.register_or_update = AsyncMock()
9917 mock_mass.config.remove_player_config = AsyncMock()
9918
9919 await provider._restore_player(universal_id)
9920
9921 # Neither the orphan's config nor the universal's config is deleted
9922 mock_mass.players.delete_player_config.assert_not_called()
9923 mock_mass.players.unregister.assert_not_called()
9924 mock_mass.config.remove_player_config.assert_not_called()
9925 # With no valid protocols left, the universal is simply not restored
9926 mock_mass.players.register_or_update.assert_not_called()
9927
9928 @pytest.mark.asyncio
9929 async def test_valid_protocol_kept_and_no_cleanup(self, mock_mass: MagicMock) -> None:
9930 """Protocol with a valid protocol_parent_id is kept and no cleanup runs."""
9931 provider = create_mock_universal_provider(mock_mass)
9932 universal_id = "up_test"
9933 protocol_id = "spb_valid"
9934
9935 self._setup_config_get(
9936 mock_mass,
9937 universal_id,
9938 [protocol_id],
9939 {
9940 protocol_id: {
9941 "player_type": "protocol",
9942 "values": {"protocol_parent_id": universal_id},
9943 },
9944 },
9945 )
9946 mock_mass.players = MagicMock()
9947 mock_mass.players.get_player = MagicMock(return_value=None)
9948 mock_mass.players.delete_player_config = MagicMock()
9949 mock_mass.players.unregister = AsyncMock()
9950 mock_mass.players.register_or_update = AsyncMock()
9951 mock_mass.config.remove_player_config = AsyncMock()
9952 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
9953
9954 await provider._restore_player(universal_id)
9955
9956 mock_mass.players.delete_player_config.assert_not_called()
9957 mock_mass.players.unregister.assert_not_called()
9958 mock_mass.config.remove_player_config.assert_not_called()
9959 # Universal is constructed and registered with the valid protocol kept
9960 mock_mass.players.register_or_update.assert_awaited_once()
9961 registered = mock_mass.players.register_or_update.call_args.args[0]
9962 assert protocol_id in registered._protocol_player_ids
9963
9964 @pytest.mark.asyncio
9965 async def test_restore_keeps_linked_protocol_after_startup_type_rewrite(
9966 self, mock_mass: MagicMock
9967 ) -> None:
9968 """A linked protocol whose type was rewritten must not delete its universal config."""
9969 provider = create_mock_universal_provider(mock_mass)
9970 universal_id = "upe45f0170ef67"
9971 protocol_id = "e4:5f:01:70:ef:67"
9972 universal_config: dict[str, Any] = {
9973 "player_id": universal_id,
9974 "provider": "universal_player",
9975 "player_type": "player",
9976 "enabled": True,
9977 "name": "WC-Player",
9978 "default_name": "solarium-bath-sl",
9979 "values": {
9980 "hide_in_ui": True,
9981 "announce_volume_min": 55,
9982 "announce_volume_max": 98,
9983 "play_media_overrides_group": False,
9984 "linked_protocol_ids": [protocol_id],
9985 "device_identifiers": {},
9986 "device_info": {},
9987 },
9988 }
9989 configs: dict[str, dict[str, Any]] = {
9990 universal_id: universal_config,
9991 protocol_id: {
9992 "player_id": protocol_id,
9993 "provider": "squeezelite",
9994 "player_type": "player",
9995 "enabled": True,
9996 "values": {"protocol_parent_id": universal_id},
9997 },
9998 }
9999
10000 def _config_get(key: str, default: object = None) -> object:
10001 if key == CONF_PLAYERS:
10002 return configs
10003 if key.startswith(f"{CONF_PLAYERS}/"):
10004 pid = key.split("/", 1)[1]
10005 return configs.get(pid, default)
10006 return default
10007
10008 mock_mass.config.get.side_effect = _config_get
10009 mock_mass.config.remove_player_config = AsyncMock()
10010 mock_mass.config.get_base_player_config.return_value = create_mock_config("WC-Player")
10011 mock_mass.players = MagicMock()
10012 mock_mass.players.get_player = MagicMock(return_value=None)
10013 mock_mass.players.delete_player_config = MagicMock()
10014 mock_mass.players.unregister = AsyncMock()
10015 mock_mass.players.register_or_update = AsyncMock()
10016
10017 await provider._restore_player(universal_id)
10018
10019 mock_mass.config.remove_player_config.assert_not_called()
10020 mock_mass.players.delete_player_config.assert_not_called()
10021 mock_mass.players.unregister.assert_not_called()
10022 mock_mass.players.register_or_update.assert_awaited_once()
10023 registered = mock_mass.players.register_or_update.call_args.args[0]
10024 assert protocol_id in registered._protocol_player_ids
10025 assert configs[universal_id]["values"]["hide_in_ui"] is True
10026 assert configs[universal_id]["values"]["announce_volume_min"] == 55
10027
10028 @pytest.mark.asyncio
10029 async def test_membership_augmented_from_child_parent_link(self, mock_mass: MagicMock) -> None:
10030 """A child whose parent link points at the universal is re-added to membership."""
10031 provider = create_mock_universal_provider(mock_mass)
10032 universal_id = "up_test"
10033 stored_id = "spb_stored"
10034 missing_id = "spb_missing"
10035
10036 self._setup_config_get(
10037 mock_mass,
10038 universal_id,
10039 [stored_id],
10040 {
10041 stored_id: {
10042 "player_type": "protocol",
10043 "values": {"protocol_parent_id": universal_id},
10044 },
10045 missing_id: {
10046 "player_type": "protocol",
10047 "values": {"protocol_parent_id": universal_id},
10048 },
10049 },
10050 )
10051 mock_mass.players = MagicMock()
10052 mock_mass.players.get_player = MagicMock(return_value=None)
10053 mock_mass.players.register_or_update = AsyncMock()
10054 mock_mass.config.remove_player_config = AsyncMock()
10055 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
10056
10057 await provider._restore_player(universal_id)
10058
10059 # The missing child is restored to membership and persisted
10060 mock_mass.config.set.assert_any_call(
10061 f"{CONF_PLAYERS}/{universal_id}/values/linked_protocol_ids",
10062 [stored_id, missing_id],
10063 )
10064 registered = mock_mass.players.register_or_update.call_args.args[0]
10065 assert registered._protocol_player_ids == [stored_id, missing_id]
10066
10067 @pytest.mark.asyncio
10068 async def test_universal_config_kept_when_protocol_moved_to_other_parent(
10069 self, mock_mass: MagicMock
10070 ) -> None:
10071 """A protocol that moved to another parent is dropped, universal config is kept."""
10072 provider = create_mock_universal_provider(mock_mass)
10073 universal_id = "up_test"
10074 protocol_id = "spb_moved"
10075
10076 self._setup_config_get(
10077 mock_mass,
10078 universal_id,
10079 [protocol_id],
10080 {
10081 protocol_id: {
10082 "player_type": "protocol",
10083 "values": {"protocol_parent_id": "up_other"},
10084 },
10085 },
10086 )
10087 mock_mass.players = MagicMock()
10088 mock_mass.players.get_player = MagicMock(return_value=None)
10089 mock_mass.players.register_or_update = AsyncMock()
10090 mock_mass.config.remove_player_config = AsyncMock()
10091
10092 await provider._restore_player(universal_id)
10093
10094 mock_mass.config.remove_player_config.assert_not_called()
10095 mock_mass.players.register_or_update.assert_not_called()
10096
10097 @pytest.mark.asyncio
10098 async def test_disabled_parent_reparents_and_disables_orphaned_protocols(
10099 self, mock_mass: MagicMock
10100 ) -> None:
10101 """
10102 Self-heal: stale UP whose protocols belong to a disabled native parent.
10103
10104 Restoring such a UP should skip the wrapper, restore each protocol's
10105 parent_id back to the rightful (disabled) native parent, cascade-disable
10106 each protocol so the next registration cycle doesn't rebuild the wrapper,
10107 and hand the wrapper's config over to the native parent before deleting it.
10108 """
10109 provider = create_mock_universal_provider(mock_mass)
10110 universal_id = "up_test"
10111 native_parent_id = "sonos_disabled"
10112 ap_id = "airplay_1"
10113 dlna_id = "dlna_1"
10114
10115 all_configs = {
10116 universal_id: {
10117 "values": {
10118 "linked_protocol_ids": [ap_id, dlna_id],
10119 "device_identifiers": {},
10120 "device_info": {},
10121 },
10122 "name": "Test UP",
10123 },
10124 native_parent_id: {
10125 "enabled": False,
10126 "provider": "sonos",
10127 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
10128 },
10129 ap_id: {
10130 "player_type": "protocol",
10131 "enabled": True,
10132 "values": {"protocol_parent_id": universal_id},
10133 },
10134 dlna_id: {
10135 "player_type": "protocol",
10136 "enabled": True,
10137 "values": {"protocol_parent_id": universal_id},
10138 },
10139 }
10140
10141 def _config_get(key: str, default: object = None) -> object:
10142 if key == CONF_PLAYERS:
10143 return all_configs
10144 if key.startswith(f"{CONF_PLAYERS}/"):
10145 pid = key.split("/", 1)[1]
10146 return all_configs.get(pid, default)
10147 return default
10148
10149 mock_mass.config.get.side_effect = _config_get
10150 mock_mass.config.set = MagicMock()
10151 mock_mass.config.save_player_config = AsyncMock()
10152 mock_mass.config.remove_player_config = AsyncMock()
10153 mock_mass.players = MagicMock()
10154 mock_mass.players.get_player = MagicMock(return_value=None)
10155
10156 await provider._restore_player(universal_id)
10157
10158 # The wrapper is not restored; its settings are handed over to the
10159 # native parent and its now-obsolete config is deleted
10160 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10161 universal_id, native_parent_id
10162 )
10163 mock_mass.players.delete_player_config.assert_called_once_with(
10164 universal_id, replacement_player_id=native_parent_id
10165 )
10166
10167 # Each protocol's parent_id is restored to the disabled native parent
10168 parent_restorations = {
10169 call.args[0]: call.args[1]
10170 for call in mock_mass.config.set.call_args_list
10171 if "protocol_parent_id" in call.args[0]
10172 }
10173 assert parent_restorations == {
10174 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": native_parent_id,
10175 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": native_parent_id,
10176 }
10177
10178 # Each protocol is cascade-disabled
10179 disabled_ids = {
10180 call.args[0] for call in mock_mass.config.save_player_config.await_args_list
10181 }
10182 assert disabled_ids == {ap_id, dlna_id}
10183
10184 @pytest.mark.asyncio
10185 async def test_universal_replaced_by_linkplay_shell(self, mock_mass: MagicMock) -> None:
10186 """A generic LinkPlay shell that owns DLNA+AirPlay children replaces the Universal."""
10187 provider = create_mock_universal_provider(mock_mass)
10188 universal_id = "up_edifier"
10189 shell_id = "wiim_uuid:FF97F002-783E-6505-6579-F15FFF97F002"
10190 ap_id = "airplay_edifier"
10191 dlna_id = "dlna_edifier"
10192
10193 all_configs = {
10194 universal_id: {
10195 "values": {
10196 "linked_protocol_ids": [ap_id, dlna_id],
10197 "device_identifiers": {},
10198 "device_info": {},
10199 },
10200 "name": "Edifier MS50A",
10201 },
10202 shell_id: {
10203 "enabled": True,
10204 "provider": "wiim",
10205 "player_type": "player",
10206 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
10207 },
10208 ap_id: {
10209 "player_type": "protocol",
10210 "enabled": True,
10211 "values": {"protocol_parent_id": universal_id},
10212 },
10213 dlna_id: {
10214 "player_type": "protocol",
10215 "enabled": True,
10216 "values": {"protocol_parent_id": universal_id},
10217 },
10218 }
10219
10220 def _config_get(key: str, default: object = None) -> object:
10221 if key == CONF_PLAYERS:
10222 return all_configs
10223 if key.startswith(f"{CONF_PLAYERS}/"):
10224 pid = key.split("/", 1)[1]
10225 return all_configs.get(pid, default)
10226 return default
10227
10228 mock_mass.config.get.side_effect = _config_get
10229 mock_mass.config.set = MagicMock()
10230 mock_mass.config.save_player_config = AsyncMock()
10231 mock_mass.config.remove_player_config = AsyncMock()
10232 mock_mass.players = MagicMock()
10233 mock_mass.players.get_player = MagicMock(return_value=None)
10234 mock_mass.players.register_or_update = AsyncMock()
10235
10236 await provider._restore_player(universal_id)
10237
10238 # both protocol children are reparented from the Universal to the LinkPlay shell
10239 parent_restorations = {
10240 call.args[0]: call.args[1]
10241 for call in mock_mass.config.set.call_args_list
10242 if "protocol_parent_id" in call.args[0]
10243 }
10244 assert parent_restorations == {
10245 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": shell_id,
10246 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": shell_id,
10247 }
10248 # the Universal's saved config/name/DSP/preference and group refs migrate to the
10249 # shell, and the now-obsolete Universal is removed
10250 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10251 universal_id, shell_id
10252 )
10253 mock_mass.players.delete_player_config.assert_called_once_with(
10254 universal_id, replacement_player_id=shell_id
10255 )
10256
10257 @pytest.mark.asyncio
10258 async def test_protocols_reparented_to_their_own_native_claimer(
10259 self, mock_mass: MagicMock
10260 ) -> None:
10261 """Each protocol is reparented to the native player that claims it, not the first found."""
10262 provider = create_mock_universal_provider(mock_mass)
10263 universal_id = "up_test"
10264 ap_id = "airplay_1"
10265 slimproto_id = "slimproto_1"
10266
10267 all_configs = {
10268 universal_id: {
10269 "values": {
10270 "linked_protocol_ids": [ap_id, slimproto_id],
10271 "device_identifiers": {},
10272 "device_info": {},
10273 },
10274 "name": "Test UP",
10275 },
10276 "native_a": {
10277 "provider": "dlna",
10278 "values": {"linked_protocol_ids": [ap_id]},
10279 },
10280 "native_b": {
10281 "provider": "squeezelite",
10282 "values": {"linked_protocol_ids": [slimproto_id]},
10283 },
10284 ap_id: {
10285 "player_type": "protocol",
10286 "values": {"protocol_parent_id": universal_id},
10287 },
10288 slimproto_id: {
10289 "player_type": "protocol",
10290 "values": {"protocol_parent_id": universal_id},
10291 },
10292 }
10293
10294 def _config_get(key: str, default: object = None) -> object:
10295 if key == CONF_PLAYERS:
10296 return all_configs
10297 if key.startswith(f"{CONF_PLAYERS}/"):
10298 pid = key.split("/", 1)[1]
10299 return all_configs.get(pid, default)
10300 return default
10301
10302 mock_mass.config.get.side_effect = _config_get
10303 mock_mass.config.set = MagicMock()
10304 mock_mass.config.save_player_config = AsyncMock()
10305 mock_mass.config.remove_player_config = AsyncMock()
10306 mock_mass.players = MagicMock()
10307 mock_mass.players.get_player = MagicMock(return_value=None)
10308 mock_mass.players.register_or_update = AsyncMock()
10309
10310 await provider._restore_player(universal_id)
10311
10312 mock_mass.players.register_or_update.assert_not_called()
10313 parent_restorations = {
10314 call.args[0]: call.args[1]
10315 for call in mock_mass.config.set.call_args_list
10316 if "protocol_parent_id" in call.args[0]
10317 }
10318 assert parent_restorations == {
10319 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
10320 f"{CONF_PLAYERS}/{slimproto_id}/values/protocol_parent_id": "native_b",
10321 }
10322 for call in mock_mass.config.save_player_config.await_args_list:
10323 assert call.args[1] == {ATTR_ENABLED: False}
10324 # The wrapper's settings are handed to the first claimer, then its
10325 # config is deleted
10326 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10327 universal_id, "native_a"
10328 )
10329 mock_mass.players.delete_player_config.assert_called_once_with(
10330 universal_id, replacement_player_id="native_a"
10331 )
10332
10333 @pytest.mark.asyncio
10334 async def test_restore_native_claims_carries_config_and_deletes_wrapper(
10335 self, mock_mass: MagicMock
10336 ) -> None:
10337 """The wrapper's user settings move to the native claimer and its config is deleted."""
10338 universal_id = "up_old"
10339 native_id = "cast_1"
10340 ap_id = "ap_1"
10341 store: dict[str, Any] = {
10342 CONF_PLAYERS: {
10343 universal_id: {
10344 "provider": "universal_player",
10345 "enabled": True,
10346 "name": "Living Room",
10347 "default_name": "Soundbar (Universal)",
10348 "values": {
10349 "hide_in_ui": True,
10350 "volume_control": "up_volume_control",
10351 "linked_protocol_ids": [ap_id],
10352 "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
10353 "device_info": {"model": "Test", "manufacturer": "Test"},
10354 },
10355 },
10356 native_id: {
10357 "provider": "cast",
10358 "enabled": True,
10359 "name": "Soundbar",
10360 "default_name": "Soundbar",
10361 "values": {
10362 "volume_control": "native",
10363 "linked_protocol_ids": [ap_id],
10364 },
10365 },
10366 ap_id: {
10367 "player_type": "protocol",
10368 "enabled": True,
10369 "values": {"protocol_parent_id": universal_id},
10370 },
10371 "group_1": {
10372 "provider": "sync_group",
10373 "enabled": True,
10374 "values": {
10375 "group_members": [universal_id, "other"],
10376 "allowed_members": [universal_id],
10377 },
10378 },
10379 },
10380 CONF_PLAYER_DSP: {universal_id: {"enabled": True, "filters": []}},
10381 CONF_PLAYER_QUEUES: {
10382 universal_id: {"queue_id": universal_id, "values": {"crossfade_duration": 5}}
10383 },
10384 }
10385 _wire_nested_config(mock_mass, store)
10386 mock_mass.config.save_player_config = AsyncMock()
10387 scheduled_tasks: list[Awaitable[object]] = []
10388 mock_mass.create_task = MagicMock(
10389 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
10390 )
10391 controller = PlayerController(mock_mass)
10392 controller._players = {}
10393 mock_mass.players = controller
10394 mock_mass.player_queues = MagicMock()
10395 provider = create_mock_universal_provider(mock_mass)
10396
10397 await provider._restore_player(universal_id)
10398
10399 players_tree = store[CONF_PLAYERS]
10400 # the protocol player is reparented to the native claimer
10401 assert players_tree[ap_id]["values"]["protocol_parent_id"] == native_id
10402 # the custom display name and user-set values moved onto the native player
10403 assert players_tree[native_id]["name"] == "Living Room"
10404 assert players_tree[native_id]["values"]["hide_in_ui"] is True
10405 # values the native player has explicitly set are not overwritten
10406 assert players_tree[native_id]["values"]["volume_control"] == "native"
10407 # wrapper bookkeeping values are not carried over
10408 assert "device_identifiers" not in players_tree[native_id]["values"]
10409 assert "device_info" not in players_tree[native_id]["values"]
10410 # DSP and queue settings moved onto the native player
10411 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": True, "filters": []}}
10412 assert store[CONF_PLAYER_QUEUES] == {
10413 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 5}}
10414 }
10415 # group memberships that referenced the wrapper now point at the native player
10416 assert players_tree["group_1"]["values"]["group_members"] == [native_id, "other"]
10417 assert players_tree["group_1"]["values"]["allowed_members"] == [native_id]
10418 # the wrapper's own config entries are all gone
10419 assert universal_id not in players_tree
10420 # cached queue state of the wrapper is purged as well
10421 mock_mass.player_queues.purge_saved_queue.assert_called_once_with(universal_id)
10422 # the wrapper itself is not registered as a player
10423 assert universal_id not in controller._players
10424 for task in scheduled_tasks:
10425 await task
10426
10427 @pytest.mark.asyncio
10428 async def test_restore_native_claims_keeps_native_name_dsp_and_queue_values(
10429 self, mock_mass: MagicMock
10430 ) -> None:
10431 """Values explicitly set on the native player win over the wrapper's on restore."""
10432 universal_id = "up_old"
10433 native_id = "cast_1"
10434 ap_id = "ap_1"
10435 store: dict[str, Any] = {
10436 CONF_PLAYERS: {
10437 universal_id: {
10438 "provider": "universal_player",
10439 "enabled": True,
10440 "name": "Living Room",
10441 "default_name": "Soundbar (Universal)",
10442 "values": {"hide_in_ui": True, "linked_protocol_ids": [ap_id]},
10443 },
10444 native_id: {
10445 "provider": "cast",
10446 "enabled": True,
10447 "name": "Kitchen",
10448 "default_name": "Soundbar",
10449 "values": {"linked_protocol_ids": [ap_id]},
10450 },
10451 ap_id: {
10452 "player_type": "protocol",
10453 "enabled": True,
10454 "values": {"protocol_parent_id": universal_id},
10455 },
10456 },
10457 CONF_PLAYER_DSP: {
10458 universal_id: {"enabled": True, "filters": ["universal"]},
10459 native_id: {"enabled": False, "filters": ["native"]},
10460 },
10461 CONF_PLAYER_QUEUES: {
10462 universal_id: {
10463 "queue_id": universal_id,
10464 "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
10465 },
10466 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 2}},
10467 },
10468 }
10469 _wire_nested_config(mock_mass, store)
10470 mock_mass.config.save_player_config = AsyncMock()
10471 scheduled_tasks: list[Awaitable[object]] = []
10472 mock_mass.create_task = MagicMock(
10473 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
10474 )
10475 controller = PlayerController(mock_mass)
10476 controller._players = {}
10477 mock_mass.players = controller
10478 mock_mass.player_queues = MagicMock()
10479 provider = create_mock_universal_provider(mock_mass)
10480
10481 await provider._restore_player(universal_id)
10482
10483 players_tree = store[CONF_PLAYERS]
10484 # the native player's own custom name and DSP config win
10485 assert players_tree[native_id]["name"] == "Kitchen"
10486 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": False, "filters": ["native"]}}
10487 # queue values merge without overwriting the native queue's own values
10488 assert store[CONF_PLAYER_QUEUES] == {
10489 native_id: {
10490 "queue_id": native_id,
10491 "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
10492 }
10493 }
10494 # non-conflicting values still migrate and the wrapper config is gone
10495 assert players_tree[native_id]["values"]["hide_in_ui"] is True
10496 assert universal_id not in players_tree
10497 for task in scheduled_tasks:
10498 await task
10499
10500 @pytest.mark.asyncio
10501 async def test_restore_native_claims_keeps_config_while_wrapper_registered(
10502 self, mock_mass: MagicMock
10503 ) -> None:
10504 """No config carry-over/deletion while the wrapper is still registered."""
10505 provider = create_mock_universal_provider(mock_mass)
10506 universal_id = "up_test"
10507 ap_id = "airplay_1"
10508
10509 all_configs = {
10510 universal_id: {
10511 "values": {
10512 "linked_protocol_ids": [ap_id],
10513 "device_identifiers": {},
10514 "device_info": {},
10515 },
10516 "name": "Test UP",
10517 },
10518 "native_a": {
10519 "provider": "dlna",
10520 "values": {"linked_protocol_ids": [ap_id]},
10521 },
10522 ap_id: {
10523 "player_type": "protocol",
10524 "values": {"protocol_parent_id": universal_id},
10525 },
10526 }
10527
10528 def _config_get(key: str, default: object = None) -> object:
10529 if key == CONF_PLAYERS:
10530 return all_configs
10531 if key.startswith(f"{CONF_PLAYERS}/"):
10532 pid = key.split("/", 1)[1]
10533 return all_configs.get(pid, default)
10534 return default
10535
10536 mock_mass.config.get.side_effect = _config_get
10537 mock_mass.config.set = MagicMock()
10538 mock_mass.config.save_player_config = AsyncMock()
10539 mock_mass.players = MagicMock()
10540 mock_mass.players.get_player = MagicMock(return_value=MagicMock())
10541
10542 await provider._restore_player(universal_id)
10543
10544 # the protocol link repair still runs
10545 parent_restorations = {
10546 call.args[0]: call.args[1]
10547 for call in mock_mass.config.set.call_args_list
10548 if "protocol_parent_id" in call.args[0]
10549 }
10550 assert parent_restorations == {
10551 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
10552 }
10553 # but the registered wrapper keeps its config and group memberships
10554 mock_mass.players._migrate_universal_player_config.assert_not_called()
10555 mock_mass.players.delete_player_config.assert_not_called()
10556 mock_mass.player_queues.on_player_remove.assert_not_called()
10557
10558
10559class TestParentDisableCascade:
10560 """Tests for cascading enable/disable from a native parent to its protocols."""
10561
10562 @staticmethod
10563 def _make_config(player_id: str, *, enabled: bool, provider: str = "sonos") -> MagicMock:
10564 """Build a minimal PlayerConfig mock for on_player_config_change."""
10565 config = MagicMock()
10566 config.player_id = player_id
10567 config.provider = provider
10568 config.enabled = enabled
10569 config.values = {}
10570 return config
10571
10572 @pytest.mark.asyncio
10573 async def test_disabling_native_parent_cascades_disable_to_linked_protocols(
10574 self, mock_mass: MagicMock
10575 ) -> None:
10576 """Disabling a native parent saves enabled=False for each linked protocol."""
10577 controller = PlayerController(mock_mass)
10578
10579 native_provider = MockProvider("sonos", mass=mock_mass)
10580 native_player = MockPlayer(
10581 native_provider,
10582 "sonos_native",
10583 "Native Speaker",
10584 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10585 )
10586 # Bypass the power-off branch in on_player_config_change
10587 native_player._attr_available = False
10588 native_player.set_linked_output_protocols(
10589 [
10590 LinkedOutputProtocol(
10591 output_protocol_id="ap_1",
10592 protocol_domain="airplay",
10593 priority=10,
10594 ),
10595 LinkedOutputProtocol(
10596 output_protocol_id="dlna_1",
10597 protocol_domain="dlna",
10598 priority=20,
10599 ),
10600 ]
10601 )
10602 controller._players = {native_player.player_id: native_player}
10603
10604 protocol_configs = {
10605 "ap_1": {"enabled": True, "player_type": "protocol"},
10606 "dlna_1": {"enabled": True, "player_type": "protocol"},
10607 }
10608
10609 def _config_get(key: str, default: object = None) -> object:
10610 if key.startswith(f"{CONF_PLAYERS}/"):
10611 pid = key.split("/", 1)[1]
10612 return protocol_configs.get(pid, default)
10613 return default
10614
10615 mock_mass.config.get = MagicMock(side_effect=_config_get)
10616 mock_mass.config.save_player_config = MagicMock()
10617 mock_mass.create_task = MagicMock()
10618 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10619
10620 config = self._make_config(native_player.player_id, enabled=False)
10621
10622 await controller.on_player_config_change(config, {ATTR_ENABLED})
10623
10624 called = mock_mass.config.save_player_config.call_args_list
10625 assert {call.args[0] for call in called} == {"ap_1", "dlna_1"}
10626 for call in called:
10627 assert call.args[1] == {ATTR_ENABLED: False}
10628
10629 @pytest.mark.asyncio
10630 async def test_enabling_native_parent_cascades_enable_to_linked_protocols(
10631 self, mock_mass: MagicMock
10632 ) -> None:
10633 """Re-enabling a native parent saves enabled=True for each (currently disabled) protocol."""
10634 controller = PlayerController(mock_mass)
10635
10636 native_provider = MockProvider("sonos", mass=mock_mass)
10637 native_player = MockPlayer(
10638 native_provider,
10639 "sonos_native",
10640 "Native Speaker",
10641 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10642 )
10643 native_player._attr_available = False
10644 native_player.set_linked_output_protocols(
10645 [
10646 LinkedOutputProtocol(
10647 output_protocol_id="ap_1",
10648 protocol_domain="airplay",
10649 priority=10,
10650 ),
10651 ]
10652 )
10653 controller._players = {native_player.player_id: native_player}
10654
10655 protocol_configs = {
10656 "ap_1": {"enabled": False, "player_type": "protocol"},
10657 }
10658
10659 def _config_get(key: str, default: object = None) -> object:
10660 if key.startswith(f"{CONF_PLAYERS}/"):
10661 pid = key.split("/", 1)[1]
10662 return protocol_configs.get(pid, default)
10663 return default
10664
10665 mock_mass.config.get = MagicMock(side_effect=_config_get)
10666 mock_mass.config.save_player_config = MagicMock()
10667 mock_mass.create_task = MagicMock()
10668 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10669
10670 config = self._make_config(native_player.player_id, enabled=True)
10671
10672 await controller.on_player_config_change(config, {ATTR_ENABLED})
10673
10674 mock_mass.config.save_player_config.assert_called_once_with("ap_1", {ATTR_ENABLED: True})
10675
10676 @pytest.mark.asyncio
10677 async def test_disabling_native_parent_falls_back_to_cached_protocol_ids(
10678 self, mock_mass: MagicMock
10679 ) -> None:
10680 """When the parent isn't registered, cascade uses cached linked_protocol_ids."""
10681 controller = PlayerController(mock_mass)
10682
10683 # Parent is not in _players (e.g. provider didn't register a disabled parent)
10684 controller._players = {}
10685
10686 cached_protocol_ids = ["ap_cached", "dlna_cached"]
10687 protocol_configs = {
10688 "ap_cached": {"enabled": True, "player_type": "protocol"},
10689 "dlna_cached": {"enabled": True, "player_type": "protocol"},
10690 }
10691
10692 def _config_get(key: str, default: object = None) -> object:
10693 if key == f"{CONF_PLAYERS}/unregistered_native/values/linked_protocol_ids":
10694 return list(cached_protocol_ids)
10695 if key.startswith(f"{CONF_PLAYERS}/"):
10696 pid = key.split("/", 1)[1]
10697 return protocol_configs.get(pid, default)
10698 return default
10699
10700 mock_mass.config.get = MagicMock(side_effect=_config_get)
10701 mock_mass.config.save_player_config = MagicMock()
10702 mock_mass.create_task = MagicMock()
10703 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10704
10705 config = self._make_config("unregistered_native", enabled=False)
10706
10707 await controller.on_player_config_change(config, {ATTR_ENABLED})
10708
10709 called_ids = {call.args[0] for call in mock_mass.config.save_player_config.call_args_list}
10710 assert called_ids == set(cached_protocol_ids)
10711
10712 @pytest.mark.asyncio
10713 async def test_toggling_protocol_player_does_not_cascade(self, mock_mass: MagicMock) -> None:
10714 """A PROTOCOL-type player flipping enabled must not trigger any cascade."""
10715 controller = PlayerController(mock_mass)
10716
10717 ap_provider = MockProvider("airplay", mass=mock_mass)
10718 ap_player = MockPlayer(
10719 ap_provider,
10720 "ap_1",
10721 "AirPlay",
10722 player_type=PlayerType.PROTOCOL,
10723 )
10724 ap_player._attr_available = False
10725 controller._players = {ap_player.player_id: ap_player}
10726
10727 mock_mass.config.save_player_config = MagicMock()
10728 mock_mass.create_task = MagicMock()
10729 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10730
10731 config = self._make_config(ap_player.player_id, enabled=False, provider="airplay")
10732
10733 await controller.on_player_config_change(config, {ATTR_ENABLED})
10734
10735 mock_mass.config.save_player_config.assert_not_called()
10736
10737
10738class TestDelayedEvalDisabledParentGuard:
10739 """Tests for the guard in _delayed_protocol_evaluation against disabled parents."""
10740
10741 @pytest.mark.asyncio
10742 async def test_skips_universal_creation_when_cached_parent_is_disabled(
10743 self, mock_mass: MagicMock
10744 ) -> None:
10745 """No universal player is created when the protocol's cached parent is disabled."""
10746 controller = PlayerController(mock_mass)
10747
10748 dlna_provider = MockProvider("dlna", mass=mock_mass)
10749 protocol_player = MockPlayer(
10750 dlna_provider,
10751 "dlna_orphan",
10752 "Orphan DLNA",
10753 player_type=PlayerType.PROTOCOL,
10754 )
10755 protocol_player.set_initialized()
10756 controller._players = {protocol_player.player_id: protocol_player}
10757
10758 cached_parent_id = "sonos_disabled"
10759
10760 def _config_get(key: str, default: object = None) -> object:
10761 if key.endswith("/protocol_parent_id"):
10762 return cached_parent_id
10763 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
10764 return {"enabled": False, "provider": "sonos"}
10765 return default
10766
10767 mock_mass.config.get = MagicMock(side_effect=_config_get)
10768
10769 with (
10770 patch.object(controller, "_try_link_to_existing_player", return_value=False),
10771 patch.object(controller, "_find_matching_universal_player", return_value=None),
10772 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
10773 ):
10774 await controller._delayed_protocol_evaluation(protocol_player.player_id)
10775
10776 mock_create_up.assert_not_called()
10777
10778 @pytest.mark.asyncio
10779 async def test_creates_universal_when_cached_parent_is_enabled_but_offline(
10780 self, mock_mass: MagicMock
10781 ) -> None:
10782 """Sanity check: an enabled-but-unregistered cached parent still falls through."""
10783 controller = PlayerController(mock_mass)
10784
10785 dlna_provider = MockProvider("dlna", mass=mock_mass)
10786 protocol_player = MockPlayer(
10787 dlna_provider,
10788 "dlna_orphan",
10789 "Orphan DLNA",
10790 player_type=PlayerType.PROTOCOL,
10791 )
10792 protocol_player.set_initialized()
10793 controller._players = {protocol_player.player_id: protocol_player}
10794
10795 cached_parent_id = "sonos_offline"
10796
10797 def _config_get(key: str, default: object = None) -> object:
10798 if key.endswith("/protocol_parent_id"):
10799 return cached_parent_id
10800 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
10801 return {"enabled": True, "provider": "sonos"}
10802 return default
10803
10804 mock_mass.config.get = MagicMock(side_effect=_config_get)
10805
10806 with (
10807 patch.object(controller, "_try_link_to_existing_player", return_value=False),
10808 patch.object(controller, "_find_matching_universal_player", return_value=None),
10809 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
10810 patch.object(
10811 controller,
10812 "_find_matching_protocol_players",
10813 return_value=[protocol_player],
10814 ),
10815 ):
10816 await controller._delayed_protocol_evaluation(protocol_player.player_id)
10817
10818 mock_create_up.assert_called_once()
10819
10820
10821class TestUniversalPlayerCurrentMedia:
10822 """Tests for current_media resolution while an output protocol is active."""
10823
10824 def test_current_media_surfaces_active_output_protocol(self, mock_mass: MagicMock) -> None:
10825 """
10826 While outputting via a protocol player, surface that player's raw current_media.
10827
10828 A sync group mirrors its leader's raw current_media to resolve the active queue
10829 item. When the leader is a universal player outputting via AirPlay, returning None
10830 here strands the group with a frozen current item (regression guard for grouped
10831 AirPlay live-metadata never refreshing).
10832 """
10833 protocol_provider = MockProvider("airplay", mass=mock_mass)
10834 protocol_player = MockPlayer(
10835 protocol_provider, "ap_1", "AirPlay", player_type=PlayerType.PROTOCOL
10836 )
10837 media = PlayerMedia(
10838 uri="http://x/flow/sess/queue_1/item_1/ap_1.flac", queue_item_id="item_1"
10839 )
10840 protocol_player._attr_current_media = media
10841
10842 universal = _create_universal_player(mock_mass, "up_1", "Universal", ["ap_1"])
10843 players = {"up_1": universal, "ap_1": protocol_player}
10844 mock_mass.players.get_player = MagicMock(side_effect=lambda pid: players.get(pid))
10845
10846 # without an active output protocol there is nothing to surface
10847 assert universal.current_media is None
10848
10849 universal.set_active_output_protocol("ap_1")
10850 assert universal.current_media is media
10851
10852 def test_current_media_none_for_native_output(self, mock_mass: MagicMock) -> None:
10853 """A native output protocol has no separate protocol player to surface."""
10854 universal = _create_universal_player(mock_mass, "up_1", "Universal", [])
10855 universal.set_active_output_protocol("native")
10856 assert universal.current_media is None
10857
10858
10859class TestSelfReferentialProtocolLinks:
10860 """
10861 Tests for the self-link bug when a Sendspin device changes player type.
10862
10863 A Sendspin device keeps a stable client_id (the MA player_id) across reflashes.
10864 When it first registers as a protocol player it gets wrapped in a universal
10865 player that caches that id as one of its protocols. If the device later
10866 reconnects as a non-protocol player (e.g. its roles changed to display-only)
10867 with the same id, the replace logic could link it to itself, hiding it.
10868 """
10869
10870 def test_add_protocol_link_refuses_self(self, mock_mass: MagicMock) -> None:
10871 """A player must never become its own protocol parent."""
10872 controller = PlayerController(mock_mass)
10873 provider = MockProvider("sendspin", mass=mock_mass)
10874 player = MockPlayer(provider, "esp_client", "ESP", player_type=PlayerType.PROTOCOL)
10875 mock_mass.players = controller
10876 controller._players = {"esp_client": player}
10877
10878 controller._add_protocol_link(player, player, "sendspin")
10879
10880 assert player.protocol_parent_id is None
10881 assert all(
10882 link.output_protocol_id != "esp_client" for link in player.linked_output_protocols
10883 )
10884
10885 def test_type_change_replaces_universal_without_self_link(self, mock_mass: MagicMock) -> None:
10886 """Replacing a universal player with the same-id native player must not self-link."""
10887 universal = _create_universal_player(
10888 mock_mass, "upespclient", "ESP", protocol_player_ids=["esp_client"]
10889 )
10890 display_provider = MockProvider("sendspin", mass=mock_mass)
10891 display_player = MockPlayer(
10892 display_provider, "esp_client", "ESP", player_type=PlayerType.DISPLAY
10893 )
10894
10895 store: dict[str, Any] = {"players/esp_client": {"enabled": True}}
10896 mock_mass.config.get.side_effect = lambda key, default=None: store.get(key, default)
10897 mock_mass.config.set.side_effect = lambda key, value: store.__setitem__(key, value)
10898 mock_mass.create_task = MagicMock()
10899 mock_mass.players = controller = PlayerController(mock_mass)
10900 controller._players = {"upespclient": universal, "esp_client": display_player}
10901
10902 controller._check_replace_universal_player(display_player)
10903
10904 assert display_player.protocol_parent_id is None
10905 assert store.get("players/esp_client/values/protocol_parent_id") != "esp_client"
10906 assert "esp_client" not in store.get("players/esp_client/values/linked_protocol_ids", [])
10907 # the obsolete universal player must not keep listing the native player,
10908 # or its permanent cleanup would re-evaluate it as an orphaned protocol
10909 assert "esp_client" not in universal._protocol_player_ids
10910
10911 async def test_migrate_clears_persisted_self_referential_link(self) -> None:
10912 """Config migration scrubs a self-referential link left by an older version."""
10913 config = ConfigController.__new__(ConfigController)
10914 config._data = {
10915 CONF_PLAYERS: {
10916 "esp_client": {
10917 "provider": "sendspin",
10918 "player_id": "esp_client",
10919 "values": {
10920 "protocol_parent_id": "esp_client",
10921 "linked_protocol_ids": ["esp_client"],
10922 },
10923 }
10924 }
10925 }
10926 await migrate(config._data)
10927
10928 values = config._data[CONF_PLAYERS]["esp_client"]["values"]
10929 assert values["protocol_parent_id"] is None
10930 assert "esp_client" not in values["linked_protocol_ids"]
10931
10932
10933class TestDerivedProtocolLinking:
10934 """
10935 Tests for derived protocol players (players with underlying_player_id set).
10936
10937 Derived protocol players (e.g. Sendspin bridges riding on an AirPlay player)
10938 resolve their parent deterministically via the underlying player instead of
10939 device identifier matching, and never seed universal players themselves.
10940 """
10941
10942 @staticmethod
10943 def _make_derived_player(
10944 mock_mass: MagicMock, player_id: str, underlying_player_id: str
10945 ) -> MockPlayer:
10946 """Create a derived protocol player without any device identifiers."""
10947 provider = MockProvider("sendspin", mass=mock_mass)
10948 player = MockPlayer(
10949 provider,
10950 player_id,
10951 f"Bridge {player_id}",
10952 player_type=PlayerType.PROTOCOL,
10953 )
10954 player._attr_underlying_player_id = underlying_player_id
10955 player.set_initialized()
10956 return player
10957
10958 def test_derived_links_to_parent_of_linked_underlying(self, mock_mass: MagicMock) -> None:
10959 """Test a derived player joins the parent of its (linked) underlying player."""
10960 controller = PlayerController(mock_mass)
10961 mock_mass.config.get = MagicMock(return_value=[])
10962
10963 native = MockPlayer(
10964 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
10965 )
10966 native.set_initialized()
10967 ap_player = MockPlayer(
10968 MockProvider("airplay", mass=mock_mass),
10969 "ap_1",
10970 "AVR AirPlay",
10971 player_type=PlayerType.PROTOCOL,
10972 )
10973 ap_player.set_initialized()
10974 controller._players = {"native_1": native, "ap_1": ap_player}
10975 controller._add_protocol_link(native, ap_player, "airplay")
10976
10977 # The derived player has NO identifiers - the edge alone must resolve it
10978 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
10979 controller._players["spb_1"] = derived
10980 controller._try_link_protocol_to_native(derived)
10981
10982 assert derived.protocol_parent_id == "native_1"
10983 # the derived link carries its base output, the non-derived link does not
10984 links = {link.output_protocol_id: link for link in native.linked_output_protocols}
10985 assert links["spb_1"].derived_from == "ap_1"
10986 assert links["ap_1"].derived_from is None
10987
10988 def test_waiting_derived_follows_when_underlying_links(self, mock_mass: MagicMock) -> None:
10989 """Test a derived player that registered early follows once its underlying links."""
10990 controller = PlayerController(mock_mass)
10991 mock_mass.config.get = MagicMock(return_value=[])
10992
10993 native = MockPlayer(
10994 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
10995 )
10996 native.set_initialized()
10997 ap_player = MockPlayer(
10998 MockProvider("airplay", mass=mock_mass),
10999 "ap_1",
11000 "AVR AirPlay",
11001 player_type=PlayerType.PROTOCOL,
11002 )
11003 ap_player.set_initialized()
11004 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11005 controller._players = {"native_1": native, "ap_1": ap_player, "spb_1": derived}
11006
11007 # Underlying is not linked yet: derived stays unlinked
11008 controller._try_link_protocol_to_native(derived)
11009 assert derived.protocol_parent_id is None
11010
11011 # Linking the underlying player propagates to the waiting derived player
11012 controller._add_protocol_link(native, ap_player, "airplay")
11013 assert derived.protocol_parent_id == "native_1"
11014
11015 def test_derived_links_directly_to_non_protocol_underlying(self, mock_mass: MagicMock) -> None:
11016 """Test a derived player parents to the underlying player itself when not a protocol."""
11017 controller = PlayerController(mock_mass)
11018 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
11019
11020 local_player = MockPlayer(
11021 MockProvider("local_audio", mass=mock_mass),
11022 "local_1",
11023 "Dummy Output",
11024 player_type=PlayerType.PLAYER,
11025 )
11026 local_player.set_initialized()
11027 derived = self._make_derived_player(mock_mass, "spb_1", "local_1")
11028 controller._players = {"spb_1": derived, "local_1": local_player}
11029
11030 # The final derived pass of _try_link_protocols_to_native picks it up
11031 controller._try_link_protocols_to_native(local_player)
11032
11033 assert derived.protocol_parent_id == "local_1"
11034 # riding on the parent player itself normalizes the base output to "native"
11035 links = {link.output_protocol_id: link for link in local_player.linked_output_protocols}
11036 assert links["spb_1"].derived_from == "native"
11037
11038 @pytest.mark.asyncio
11039 async def test_derived_delayed_eval_never_creates_universal_player(
11040 self, mock_mass: MagicMock
11041 ) -> None:
11042 """Test delayed evaluation of a derived player never wraps it in a universal player."""
11043 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
11044 mock_mass.config.get = MagicMock(return_value=[])
11045
11046 derived = self._make_derived_player(mock_mass, "spb_1", "ap_missing")
11047 controller._players = {"spb_1": derived}
11048
11049 await controller._delayed_protocol_evaluation("spb_1")
11050
11051 assert derived.protocol_parent_id is None
11052 assert not any(pid.startswith("up") for pid in controller._players)
11053
11054 def test_derived_link_refused_on_domain_duplicate(self, mock_mass: MagicMock) -> None:
11055 """Test a derived player stays unlinked when the parent already has its domain."""
11056 controller = PlayerController(mock_mass)
11057 mock_mass.config.get = MagicMock(return_value=[])
11058
11059 native = MockPlayer(
11060 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11061 )
11062 native.set_initialized()
11063 ap_player = MockPlayer(
11064 MockProvider("airplay", mass=mock_mass),
11065 "ap_1",
11066 "AVR AirPlay",
11067 player_type=PlayerType.PROTOCOL,
11068 )
11069 ap_player.set_initialized()
11070 other_sendspin = MockPlayer(
11071 MockProvider("sendspin", mass=mock_mass),
11072 "sendspin_native_client",
11073 "AVR Sendspin",
11074 player_type=PlayerType.PROTOCOL,
11075 )
11076 other_sendspin.set_initialized()
11077 controller._players = {
11078 "native_1": native,
11079 "ap_1": ap_player,
11080 "sendspin_native_client": other_sendspin,
11081 }
11082 controller._add_protocol_link(native, ap_player, "airplay")
11083 controller._add_protocol_link(native, other_sendspin, "sendspin")
11084
11085 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11086 controller._players["spb_1"] = derived
11087 controller._try_link_protocol_to_native(derived)
11088
11089 assert derived.protocol_parent_id is None
11090
11091 def test_derived_joins_universal_parent(self, mock_mass: MagicMock) -> None:
11092 """Test a derived player joins the universal player of its underlying player."""
11093 controller = PlayerController(mock_mass)
11094 mock_mass.config.get = MagicMock(return_value=[])
11095
11096 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
11097 if hasattr(coro, "close"):
11098 coro.close()
11099
11100 mock_mass.create_task = MagicMock(side_effect=_close_coro)
11101
11102 universal = _create_universal_player(
11103 mock_mass,
11104 "up_aabbccddeeff",
11105 "Living Room Speaker",
11106 protocol_player_ids=["ap_1"],
11107 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11108 )
11109 ap_player = MockPlayer(
11110 MockProvider("airplay", mass=mock_mass),
11111 "ap_1",
11112 "AirPlay Speaker",
11113 player_type=PlayerType.PROTOCOL,
11114 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11115 )
11116 ap_player.set_initialized()
11117 controller._players = {"up_aabbccddeeff": universal, "ap_1": ap_player}
11118 controller._add_protocol_link(universal, ap_player, "airplay")
11119
11120 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11121 controller._players["spb_1"] = derived
11122 controller._try_link_protocol_to_native(derived)
11123
11124 assert derived.protocol_parent_id == "up_aabbccddeeff"
11125 assert "spb_1" in universal._protocol_player_ids
11126
11127 @pytest.mark.parametrize("derived_first", [False, True])
11128 def test_derived_stays_with_refused_base_on_replacement(
11129 self, mock_mass: MagicMock, derived_first: bool
11130 ) -> None:
11131 """
11132 Keep a derived protocol with its base when the base's link is refused.
11133
11134 The native player absorbs the first universal player's cast protocol, so the
11135 second universal player's cast protocol is refused and stays behind. The
11136 sendspin bridge riding on it must stay behind too instead of moving on its own,
11137 whichever order the two links are listed in.
11138 """
11139 controller = PlayerController(mock_mass)
11140 up_provider = create_mock_universal_provider(mock_mass)
11141
11142 config_store: dict[str, Any] = {
11143 f"players/{player_id}": {"enabled": True}
11144 for player_id in ("native", "up_a", "up_b", "cc_1", "cc_2", "spb_2")
11145 }
11146 mock_mass.config.get = MagicMock(side_effect=config_store.get)
11147 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
11148 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
11149 mock_mass.players = controller
11150 mock_mass.player_queues = MagicMock()
11151 mock_mass.call_later = MagicMock()
11152 mock_mass.loop = MagicMock()
11153 mock_mass.create_task = MagicMock()
11154
11155 mac = "AA:BB:CC:DD:EE:FF"
11156 identifiers = {IdentifierType.MAC_ADDRESS: mac}
11157 universals = {}
11158 for universal_id, protocol_ids in (("up_a", ["cc_1"]), ("up_b", ["cc_2", "spb_2"])):
11159 universal = UniversalPlayer(
11160 provider=up_provider,
11161 player_id=universal_id,
11162 name=f"Soundbar ({universal_id})",
11163 device_info=DeviceInfo(model="Test", manufacturer="Test"),
11164 protocol_player_ids=protocol_ids,
11165 )
11166 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
11167 universal._cache.clear()
11168 universal.update_state(signal_event=False)
11169 universal.set_initialized()
11170 universals[universal_id] = universal
11171
11172 native = MockPlayer(
11173 MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
11174 )
11175 native.set_initialized()
11176
11177 cast_provider = MockProvider("chromecast", mass=mock_mass)
11178 cast_players = {}
11179 for cast_id in ("cc_1", "cc_2"):
11180 cast_player = MockPlayer(
11181 cast_provider,
11182 cast_id,
11183 f"Cast {cast_id}",
11184 player_type=PlayerType.PROTOCOL,
11185 identifiers=identifiers,
11186 )
11187 cast_player.set_initialized()
11188 cast_players[cast_id] = cast_player
11189
11190 derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
11191
11192 controller._players = {
11193 **universals,
11194 **cast_players,
11195 "native": native,
11196 "spb_2": derived,
11197 }
11198 controller._add_protocol_link(universals["up_a"], cast_players["cc_1"], "chromecast")
11199 if derived_first:
11200 controller._add_protocol_link(universals["up_b"], derived, "sendspin")
11201 controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
11202 else:
11203 controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
11204 controller._add_protocol_link(universals["up_b"], derived, "sendspin")
11205
11206 controller._check_replace_universal_player(native)
11207
11208 # the second universal player keeps the refused cast protocol and its bridge
11209 assert cast_players["cc_2"].protocol_parent_id == "up_b"
11210 assert derived.protocol_parent_id == "up_b"
11211 assert {link.output_protocol_id for link in universals["up_b"].linked_output_protocols} == {
11212 "cc_2",
11213 "spb_2",
11214 }
11215 # the native player only took over the protocol it could actually claim
11216 assert {link.output_protocol_id for link in native.linked_output_protocols} == {"cc_1"}
11217 assert "spb_2" not in (config_store.get("players/native/values/linked_protocol_ids") or [])
11218
11219 def test_base_stays_with_refused_derived_on_replacement(self, mock_mass: MagicMock) -> None:
11220 """
11221 Keep a base protocol with its derived protocol when the derived link is refused.
11222
11223 The native player already serves the sendspin domain, so the universal player's
11224 sendspin bridge cannot move. The cast protocol it rides on must stay behind with
11225 it instead of moving on its own.
11226 """
11227 controller = PlayerController(mock_mass)
11228 up_provider = create_mock_universal_provider(mock_mass)
11229
11230 config_store: dict[str, Any] = {
11231 f"players/{player_id}": {"enabled": True}
11232 for player_id in ("native", "up_b", "cc_2", "spb_2", "spb_native")
11233 }
11234 mock_mass.config.get = MagicMock(side_effect=config_store.get)
11235 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
11236 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
11237 mock_mass.players = controller
11238 mock_mass.player_queues = MagicMock()
11239 mock_mass.call_later = MagicMock()
11240 mock_mass.loop = MagicMock()
11241 mock_mass.create_task = MagicMock()
11242
11243 mac = "AA:BB:CC:DD:EE:FF"
11244 identifiers = {IdentifierType.MAC_ADDRESS: mac}
11245 universal = UniversalPlayer(
11246 provider=up_provider,
11247 player_id="up_b",
11248 name="Soundbar (Universal)",
11249 device_info=DeviceInfo(model="Test", manufacturer="Test"),
11250 protocol_player_ids=["cc_2", "spb_2"],
11251 )
11252 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
11253 universal._cache.clear()
11254 universal.update_state(signal_event=False)
11255 universal.set_initialized()
11256
11257 native = MockPlayer(
11258 MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
11259 )
11260 native.set_initialized()
11261 cast_player = MockPlayer(
11262 MockProvider("chromecast", mass=mock_mass),
11263 "cc_2",
11264 "Cast",
11265 player_type=PlayerType.PROTOCOL,
11266 identifiers=identifiers,
11267 )
11268 cast_player.set_initialized()
11269 derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
11270 sendspin_native = MockPlayer(
11271 MockProvider("sendspin", mass=mock_mass),
11272 "spb_native",
11273 "Soundbar (Sendspin)",
11274 player_type=PlayerType.PROTOCOL,
11275 identifiers=identifiers,
11276 )
11277 sendspin_native.set_initialized()
11278
11279 controller._players = {
11280 "up_b": universal,
11281 "native": native,
11282 "cc_2": cast_player,
11283 "spb_2": derived,
11284 "spb_native": sendspin_native,
11285 }
11286 controller._add_protocol_link(universal, cast_player, "chromecast")
11287 controller._add_protocol_link(universal, derived, "sendspin")
11288 controller._add_protocol_link(native, sendspin_native, "sendspin")
11289
11290 controller._check_replace_universal_player(native)
11291
11292 # the bridge could not move, so the cast protocol it rides on stays with it
11293 assert derived.protocol_parent_id == "up_b"
11294 assert cast_player.protocol_parent_id == "up_b"
11295 assert {link.output_protocol_id for link in universal.linked_output_protocols} == {
11296 "cc_2",
11297 "spb_2",
11298 }
11299 assert {link.output_protocol_id for link in native.linked_output_protocols} == {
11300 "spb_native"
11301 }
11302
11303 def test_save_underlying_player_id_persists_and_clears(self, mock_mass: MagicMock) -> None:
11304 """Test the derived edge is persisted, cleared when revoked and skipped otherwise."""
11305 controller = PlayerController(mock_mass)
11306 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11307 conf_key = "players/spb_1/values/underlying_player_id"
11308 stored: dict[str, Any] = {"players/spb_1": {"values": {}}}
11309 mock_mass.config.get = MagicMock(
11310 side_effect=lambda key, default=None: stored.get(key, default)
11311 )
11312 mock_mass.config.set = MagicMock(side_effect=stored.__setitem__)
11313
11314 # derived player: edge gets persisted
11315 controller._save_underlying_player_id(derived)
11316 assert stored[conf_key] == "ap_1"
11317
11318 # edge revoked (e.g. bridge client turned web player): stale value is cleared
11319 derived._attr_underlying_player_id = None
11320 controller._save_underlying_player_id(derived)
11321 assert stored[conf_key] is None
11322
11323 # nothing persisted and no edge: no write at all
11324 mock_mass.config.set.reset_mock()
11325 del stored[conf_key]
11326 controller._save_underlying_player_id(derived)
11327 mock_mass.config.set.assert_not_called()
11328
11329 # player config gone: never create partial entries
11330 derived._attr_underlying_player_id = "ap_1"
11331 del stored["players/spb_1"]
11332 controller._save_underlying_player_id(derived)
11333 mock_mass.config.set.assert_not_called()
11334
11335
11336class TestLocalAudioPlayerPromotion:
11337 """
11338 Tests for the local_audio reshape: attribution stub -> regular native player.
11339
11340 The local_audio device player (bare device-uuid player_id) is a regular,
11341 visible PLAYER that parents its Sendspin bridge directly. On upgrade, the
11342 previously auto-created universal player wrapper (up<uuid-hex>) must be
11343 absorbed by the native player without re-wrapping it.
11344 """
11345
11346 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
11347 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
11348
11349 def test_native_local_player_absorbs_universal_wrapper(self, mock_mass: MagicMock) -> None:
11350 """The native player takes over the bridge protocol and the wrapper is emptied."""
11351 universal = _create_universal_player(
11352 mock_mass,
11353 self.UNIVERSAL_ID,
11354 "Dummy Output",
11355 protocol_player_ids=[self.DEVICE_UUID, "spb_1"],
11356 )
11357 native = MockPlayer(
11358 MockProvider("local_audio", mass=mock_mass),
11359 self.DEVICE_UUID,
11360 "Dummy Output",
11361 player_type=PlayerType.PLAYER,
11362 identifiers={IdentifierType.UUID: self.DEVICE_UUID},
11363 )
11364 native.set_initialized()
11365 spb = MockPlayer(
11366 MockProvider("sendspin", mass=mock_mass),
11367 "spb_1",
11368 "Dummy Output Sendspin",
11369 player_type=PlayerType.PROTOCOL,
11370 )
11371 spb._attr_underlying_player_id = self.DEVICE_UUID
11372 spb.set_initialized()
11373
11374 store: dict[str, Any] = {
11375 f"players/{self.DEVICE_UUID}": {"enabled": True},
11376 "players/spb_1": {"enabled": True},
11377 }
11378 mock_mass.config.get = MagicMock(
11379 side_effect=lambda key, default=None: store.get(key, default)
11380 )
11381 mock_mass.config.set = MagicMock(side_effect=store.__setitem__)
11382
11383 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
11384 if hasattr(coro, "close"):
11385 coro.close()
11386
11387 mock_mass.create_task = MagicMock(side_effect=_close_coro)
11388 mock_mass.players = controller = PlayerController(mock_mass)
11389 controller._players = {
11390 self.UNIVERSAL_ID: universal,
11391 self.DEVICE_UUID: native,
11392 "spb_1": spb,
11393 }
11394 controller._add_protocol_link(universal, spb, "sendspin")
11395
11396 controller._try_link_protocols_to_native(native)
11397
11398 assert spb.protocol_parent_id == self.DEVICE_UUID
11399 assert [link.output_protocol_id for link in native.linked_output_protocols] == ["spb_1"]
11400 # the obsolete wrapper keeps neither the bridge nor the native player itself,
11401 # so its permanent cleanup has no orphaned protocols to re-evaluate
11402 assert universal._protocol_player_ids == []
11403 assert store.get("players/spb_1/values/protocol_parent_id") == self.DEVICE_UUID
11404
11405 async def test_delayed_eval_skips_type_changed_player(self, mock_mass: MagicMock) -> None:
11406 """A pending evaluation for a player that is not (or no longer) a protocol is a no-op."""
11407 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
11408 mock_mass.config.get = MagicMock(return_value=[])
11409
11410 native = MockPlayer(
11411 MockProvider("local_audio", mass=mock_mass),
11412 self.DEVICE_UUID,
11413 "Dummy Output",
11414 player_type=PlayerType.PLAYER,
11415 )
11416 native.set_initialized()
11417 controller._players = {self.DEVICE_UUID: native}
11418
11419 await controller._delayed_protocol_evaluation(self.DEVICE_UUID)
11420
11421 assert native.protocol_parent_id is None
11422 assert not any(pid.startswith("up") for pid in controller._players)
11423
11424
11425class TestLocalAudioStubMigration:
11426 """Tests for the config migration that promotes local_audio stubs to regular players."""
11427
11428 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
11429 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
11430
11431 def _make_data(self) -> dict[str, Any]:
11432 """Build a settings dict as persisted by the previous local_audio setup."""
11433 return {
11434 CONF_PLAYERS: {
11435 self.DEVICE_UUID: {
11436 "player_id": self.DEVICE_UUID,
11437 "provider": "local_audio",
11438 "player_type": "protocol",
11439 "enabled": True,
11440 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
11441 },
11442 self.UNIVERSAL_ID: {
11443 "player_id": self.UNIVERSAL_ID,
11444 "provider": "universal_player",
11445 "player_type": "group",
11446 "enabled": False,
11447 "name": "Kitchen Output",
11448 "values": {
11449 # ghost_spb has no config anymore (device gone) - must not crash
11450 "linked_protocol_ids": [self.DEVICE_UUID, "spb_1", "ghost_spb"],
11451 "device_identifiers": {"uuid": self.DEVICE_UUID},
11452 "device_info": {"model": "Dummy Output", "manufacturer": "Local Audio"},
11453 "expose_to_ha": False,
11454 "hide_in_ui": True,
11455 },
11456 },
11457 "spb_1": {
11458 "player_id": "spb_1",
11459 "provider": "sendspin",
11460 "player_type": "protocol",
11461 "enabled": True,
11462 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
11463 },
11464 "syncgroup_1": {
11465 "player_id": "syncgroup_1",
11466 "provider": "sync_group",
11467 "player_type": "group",
11468 "enabled": True,
11469 "values": {
11470 "group_members": [self.UNIVERSAL_ID, "other_player"],
11471 "allowed_members": [self.UNIVERSAL_ID],
11472 },
11473 },
11474 },
11475 CONF_PLAYER_QUEUES: {
11476 self.UNIVERSAL_ID: {
11477 "queue_id": self.UNIVERSAL_ID,
11478 "values": {"autoplay_mode": "smart"},
11479 },
11480 },
11481 CONF_PLAYER_DSP: {
11482 self.UNIVERSAL_ID: {"enabled": True},
11483 },
11484 }
11485
11486 async def test_stub_promoted_and_universal_absorbed(self) -> None:
11487 """The stub becomes a regular player carrying the universal player's settings."""
11488 data = self._make_data()
11489
11490 assert await migrate(data) is True
11491
11492 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
11493 assert stub_cfg["player_type"] == "player"
11494 # the universal player was the device toggle the user operated
11495 assert stub_cfg["enabled"] is False
11496 assert stub_cfg["name"] == "Kitchen Output"
11497 values = stub_cfg["values"]
11498 assert "protocol_parent_id" not in values
11499 assert values["expose_to_ha"] is False
11500 assert values["hide_in_ui"] is True
11501 assert values["linked_protocol_ids"] == ["spb_1", "ghost_spb"]
11502 assert "device_identifiers" not in values
11503 assert "device_info" not in values
11504
11505 # the wrapper is gone and the bridge protocol re-parented
11506 assert self.UNIVERSAL_ID not in data[CONF_PLAYERS]
11507 assert data[CONF_PLAYERS]["spb_1"]["values"]["protocol_parent_id"] == self.DEVICE_UUID
11508 assert data[CONF_PLAYERS]["spb_1"]["player_type"] == "protocol"
11509
11510 # queue settings, DSP config and group memberships follow the new player_id
11511 queue_cfg = data[CONF_PLAYER_QUEUES][self.DEVICE_UUID]
11512 assert queue_cfg["queue_id"] == self.DEVICE_UUID
11513 assert queue_cfg["values"]["autoplay_mode"] == "smart"
11514 assert self.UNIVERSAL_ID not in data[CONF_PLAYER_QUEUES]
11515 assert data[CONF_PLAYER_DSP] == {self.DEVICE_UUID: {"enabled": True}}
11516 group_values = data[CONF_PLAYERS]["syncgroup_1"]["values"]
11517 assert group_values["group_members"] == [self.DEVICE_UUID, "other_player"]
11518 assert group_values["allowed_members"] == [self.DEVICE_UUID]
11519
11520 async def test_stub_without_universal_wrapper(self) -> None:
11521 """A stub without a universal player wrapper is still promoted."""
11522 data = self._make_data()
11523 del data[CONF_PLAYERS][self.UNIVERSAL_ID]
11524
11525 assert await migrate(data) is True
11526
11527 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
11528 assert stub_cfg["player_type"] == "player"
11529 assert stub_cfg["enabled"] is True
11530 assert "protocol_parent_id" not in stub_cfg["values"]
11531
11532 async def test_migration_is_idempotent(self) -> None:
11533 """A second run finds nothing left to migrate."""
11534 data = self._make_data()
11535
11536 assert await migrate(data) is True
11537 assert await migrate(data) is False
11538
11539
11540class _LockedMockPlayer(MockPlayer):
11541 """A player that keeps its group read-only (e.g. an externally-created mixed group)."""
11542
11543 @property
11544 def grouping_locked(self) -> bool:
11545 """Suppress grouping in the exposed state."""
11546 return True
11547
11548
11549class TestGroupingLockedPolicy:
11550 """A grouping-locked player withholds SET_MEMBERS and group targets in its final state."""
11551
11552 def test_locked_player_withholds_set_members(self, mock_mass: MagicMock) -> None:
11553 """A locked player drops SET_MEMBERS from its exposed features."""
11554 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11555 player = _LockedMockPlayer(provider, "shell_1", "Shell")
11556 player._attr_supported_features = {PlayerFeature.SET_MEMBERS, PlayerFeature.VOLUME_SET}
11557 player._cache.clear()
11558 player.update_state(signal_event=False)
11559 assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
11560 assert PlayerFeature.VOLUME_SET in player.state.supported_features
11561
11562 def test_locked_player_withholds_protocol_derived_set_members(
11563 self, mock_mass: MagicMock
11564 ) -> None:
11565 """Even a linked protocol player's SET_MEMBERS is suppressed while locked."""
11566 controller = PlayerController(mock_mass)
11567 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11568 player = _LockedMockPlayer(provider, "shell_1", "Shell")
11569 player._attr_supported_features = set() # no native grouping of its own
11570 protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
11571 protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
11572 protocol._cache.clear()
11573 protocol.set_protocol_parent_id("shell_1")
11574 player.set_linked_output_protocols(
11575 [
11576 LinkedOutputProtocol(
11577 output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
11578 )
11579 ]
11580 )
11581 mock_mass.players = controller
11582 controller._players = {"shell_1": player, "airplay_p": protocol}
11583 protocol.refresh_state(signal_event=False)
11584 player.refresh_state(signal_event=False)
11585 assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
11586
11587 def test_locked_player_offers_no_group_targets(self, mock_mass: MagicMock) -> None:
11588 """A locked player exposes no group targets, even an otherwise groupable peer."""
11589 controller = PlayerController(mock_mass)
11590 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11591 player = _LockedMockPlayer(provider, "shell_1", "Shell")
11592 player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
11593 player._attr_can_group_with = {"peer_1"}
11594 peer = MockPlayer(provider, "peer_1", "Peer")
11595 mock_mass.players = controller
11596 controller._players = {"shell_1": player, "peer_1": peer}
11597 player._cache.clear()
11598 peer.refresh_state(signal_event=False)
11599 player.refresh_state(signal_event=False)
11600 assert player.state.can_group_with == set()
11601
11602 def test_unlocked_player_keeps_grouping(self, mock_mass: MagicMock) -> None:
11603 """A normal (unlocked) player keeps SET_MEMBERS and its group targets."""
11604 controller = PlayerController(mock_mass)
11605 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11606 player = MockPlayer(provider, "shell_2", "Shell2")
11607 player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
11608 player._attr_can_group_with = {"peer_2"}
11609 peer = MockPlayer(provider, "peer_2", "Peer2")
11610 mock_mass.players = controller
11611 controller._players = {"shell_2": player, "peer_2": peer}
11612 player._cache.clear()
11613 peer.refresh_state(signal_event=False)
11614 player.refresh_state(signal_event=False)
11615 assert PlayerFeature.SET_MEMBERS in player.state.supported_features
11616 assert "peer_2" in player.state.can_group_with
11617
11618 def test_locked_peer_excluded_from_group_targets(self, mock_mass: MagicMock) -> None:
11619 """An unlocked leader drops a locked peer but keeps an unlocked one as a target."""
11620 controller = PlayerController(mock_mass)
11621 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11622 leader = MockPlayer(provider, "shell_1", "Shell")
11623 leader._attr_supported_features = {PlayerFeature.SET_MEMBERS}
11624 leader._attr_can_group_with = {"locked_peer", "open_peer"}
11625 locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
11626 open_peer = MockPlayer(provider, "open_peer", "Open Peer")
11627 mock_mass.players = controller
11628 controller._players = {
11629 "shell_1": leader,
11630 "locked_peer": locked_peer,
11631 "open_peer": open_peer,
11632 }
11633 leader._cache.clear()
11634 for player in (locked_peer, open_peer, leader):
11635 player.refresh_state(signal_event=False)
11636 # the locked peer keeps its own group read-only, so it is not offered as a target,
11637 # while the ordinary peer still is
11638 assert "locked_peer" not in leader.state.can_group_with
11639 assert "open_peer" in leader.state.can_group_with
11640
11641 def test_linked_protocol_cannot_reintroduce_locked_member(self, mock_mass: MagicMock) -> None:
11642 """A locked member offered by a linked protocol is still kept out of the targets."""
11643 controller = PlayerController(mock_mass)
11644 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11645 leader = MockPlayer(provider, "shell_1", "Shell")
11646 leader._attr_supported_features = set() # no native grouping of its own
11647 protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
11648 protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
11649 protocol._attr_can_group_with = {"locked_peer", "open_peer"}
11650 protocol._cache.clear()
11651 protocol.set_protocol_parent_id("shell_1")
11652 leader.set_linked_output_protocols(
11653 [
11654 LinkedOutputProtocol(
11655 output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
11656 )
11657 ]
11658 )
11659 locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
11660 open_peer = MockPlayer(provider, "open_peer", "Open Peer")
11661 mock_mass.players = controller
11662 controller._players = {
11663 "shell_1": leader,
11664 "airplay_p": protocol,
11665 "locked_peer": locked_peer,
11666 "open_peer": open_peer,
11667 }
11668 for player in (locked_peer, open_peer, protocol):
11669 player.refresh_state(signal_event=False)
11670 leader._cache.clear()
11671 leader.refresh_state(signal_event=False)
11672 # the linked protocol lists the locked peer, but the final filter still excludes it
11673 assert "locked_peer" not in leader.state.can_group_with
11674 assert "open_peer" in leader.state.can_group_with
11675
11676
11677class _NativeGroupingMockPlayer(MockPlayer):
11678 """A player that runs its own multiroom and prefers native grouping."""
11679
11680 @property
11681 def prefer_native_grouping(self) -> bool:
11682 """Prefer native grouping over any linked protocol."""
11683 return True
11684
11685
11686class _RejectsNativeGroupingMockPlayer(MockPlayer):
11687 """A parent that never accepts another player as a native group peer."""
11688
11689 def is_native_group_compatible(self, other: Player) -> bool:
11690 """Reject every peer as native-incompatible (e.g. a cross-backend device)."""
11691 return False
11692
11693
11694class TestPreferNativeGrouping:
11695 """A player that prefers native grouping uses it before its preferred output protocol."""
11696
11697 def test_prefer_native_uses_native_before_child_preferred(self, mock_mass: MagicMock) -> None:
11698 """A prefer-native child groups natively even with an explicit protocol preference."""
11699 controller = PlayerController(mock_mass)
11700 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11701 parent = MockPlayer(provider, "shell_parent", "Parent")
11702 child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
11703 # an explicit child output-protocol preference that would otherwise win
11704 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="airplay_child")
11705 mock_mass.players = controller
11706 controller._players = {"shell_parent": parent, "shell_child": child}
11707 native_members: list[str] = []
11708 protocol_members: list[str] = []
11709 controller._select_grouping_for_member(
11710 child, parent, None, None, True, protocol_members, native_members
11711 )
11712 assert native_members == ["shell_child"]
11713 assert protocol_members == []
11714
11715 def test_prefer_native_falls_through_same_instance_not_native_peer(
11716 self, mock_mass: MagicMock
11717 ) -> None:
11718 """A same-instance pair the parent rejects as a native peer is not forced native."""
11719 controller = PlayerController(mock_mass)
11720 # official + generic LinkPlay share one wiim provider instance, but a cross-backend
11721 # peer is not native-group compatible, so the seam must not force native grouping.
11722 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11723 parent = _RejectsNativeGroupingMockPlayer(provider, "shell_parent", "Parent")
11724 child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
11725 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
11726 mock_mass.players = controller
11727 controller._players = {"shell_parent": parent, "shell_child": child}
11728 native_members: list[str] = []
11729 protocol_members: list[str] = []
11730 controller._select_grouping_for_member(
11731 child, parent, None, None, True, protocol_members, native_members
11732 )
11733 assert native_members == []
11734
11735 def test_prefer_native_falls_through_when_incompatible(self, mock_mass: MagicMock) -> None:
11736 """When native grouping is not possible (cross-backend), the seam does not force it."""
11737 controller = PlayerController(mock_mass)
11738 parent_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11739 child_provider = MockProvider("other", instance_id="other_instance", mass=mock_mass)
11740 parent = MockPlayer(parent_provider, "shell_parent", "Parent")
11741 parent._attr_can_group_with = set() # does not accept the cross-backend child
11742 child = _NativeGroupingMockPlayer(child_provider, "other_child", "Child")
11743 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
11744 mock_mass.players = controller
11745 controller._players = {"shell_parent": parent, "other_child": child}
11746 native_members: list[str] = []
11747 protocol_members: list[str] = []
11748 controller._select_grouping_for_member(
11749 child, parent, None, None, True, protocol_members, native_members
11750 )
11751 # the preferred-native seam did not force a cross-backend native group
11752 assert native_members == []
11753
11754 def test_default_player_keeps_protocol_first_ordering(self, mock_mass: MagicMock) -> None:
11755 """An ordinary player (no native preference) is unaffected by the seam."""
11756 controller = PlayerController(mock_mass)
11757 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11758 parent = MockPlayer(provider, "shell_parent", "Parent")
11759 child = MockPlayer(provider, "plain_child", "Child")
11760 assert child.prefer_native_grouping is False
11761 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
11762 mock_mass.players = controller
11763 controller._players = {"shell_parent": parent, "plain_child": child}
11764 native_members: list[str] = []
11765 protocol_members: list[str] = []
11766 controller._select_grouping_for_member(
11767 child, parent, None, None, True, protocol_members, native_members
11768 )
11769 # with no preferred/active/common protocol, the default player still reaches
11770 # native grouping via the normal priority (Priority 3), not the preferred seam
11771 assert native_members == ["plain_child"]
11772