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