/
/
/
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 TestCachedProtocolLinkRecovery:
7566 """Tests for recovering protocol links from config."""
7567
7568 def test_recovered_links_are_applied_through_the_setter(self, mock_mass: MagicMock) -> None:
7569 """Recovery applies a single link per protocol domain through the setter."""
7570 controller = PlayerController(mock_mass)
7571
7572 protocol_configs = {
7573 "ap_first": {
7574 "provider": "airplay--first",
7575 "player_type": "protocol",
7576 "values": {"protocol_parent_id": "heos_player"},
7577 },
7578 "ap_second": {
7579 "provider": "airplay--second",
7580 "player_type": "protocol",
7581 "values": {"protocol_parent_id": "heos_player"},
7582 },
7583 }
7584
7585 def config_get_side_effect(key: str, default: object = None) -> object:
7586 if key == "players/heos_player/values/linked_protocol_ids":
7587 return ["ap_first", "ap_second"]
7588 if key == "players":
7589 return protocol_configs
7590 if key.startswith("players/"):
7591 return protocol_configs.get(key.split("/", maxsplit=1)[1])
7592 return default
7593
7594 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
7595
7596 heos_player = MockPlayer(
7597 MockProvider("heos", mass=mock_mass),
7598 "heos_player",
7599 "Denon AVR-X2700H",
7600 )
7601
7602 controller._recover_cached_protocol_links(heos_player)
7603
7604 recovered = [link.output_protocol_id for link in heos_player.linked_output_protocols]
7605 assert recovered == ["ap_first"]
7606 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
7607
7608 # a repeat pass has nothing left to recover, so no update is triggered
7609 controller._recover_cached_protocol_links(heos_player)
7610 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
7611
7612
7613class TestMultiMACMatching:
7614 """Tests for multi-MAC matching (reported MAC vs ARP MAC)."""
7615
7616 def test_match_on_reported_mac_when_arp_mac_differs(self, mock_mass: MagicMock) -> None:
7617 """Two players match when one's reported MAC equals the other's ARP-resolved MAC."""
7618 controller = PlayerController(mock_mass)
7619
7620 provider = MockProvider("test")
7621 # Player A has ARP-resolved MAC in identifiers, reported MAC saved in extra_data
7622 player_a = MockPlayer(
7623 provider,
7624 "player_a",
7625 "Player A",
7626 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
7627 )
7628 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
7629
7630 # Player B has a MAC that matches player_a's reported MAC
7631 player_b = MockPlayer(
7632 provider,
7633 "player_b",
7634 "Player B",
7635 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
7636 )
7637
7638 assert controller._identifiers_match(player_a, player_b) is True
7639
7640 def test_match_on_reported_mac_reverse(self, mock_mass: MagicMock) -> None:
7641 """Match when player_b's reported MAC matches player_a's ARP MAC."""
7642 controller = PlayerController(mock_mass)
7643
7644 provider = MockProvider("test")
7645 player_a = MockPlayer(
7646 provider,
7647 "player_a",
7648 "Player A",
7649 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
7650 )
7651
7652 player_b = MockPlayer(
7653 provider,
7654 "player_b",
7655 "Player B",
7656 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
7657 )
7658 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
7659
7660 assert controller._identifiers_match(player_a, player_b) is True
7661
7662 def test_no_match_when_both_macs_differ(self, mock_mass: MagicMock) -> None:
7663 """No match when all MACs differ between players."""
7664 controller = PlayerController(mock_mass)
7665
7666 provider = MockProvider("test")
7667 player_a = MockPlayer(
7668 provider,
7669 "player_a",
7670 "Player A",
7671 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
7672 )
7673 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
7674
7675 player_b = MockPlayer(
7676 provider,
7677 "player_b",
7678 "Player B",
7679 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
7680 )
7681 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:04"
7682
7683 assert controller._identifiers_match(player_a, player_b) is False
7684
7685 def test_match_reported_mac_with_la_bit_normalization(self, mock_mass: MagicMock) -> None:
7686 """Reported MAC matching also applies LA bit normalization."""
7687 controller = PlayerController(mock_mass)
7688
7689 provider = MockProvider("test")
7690 # Player A has ARP MAC, reported MAC has LA bit set
7691 player_a = MockPlayer(
7692 provider,
7693 "player_a",
7694 "Player A",
7695 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
7696 )
7697 player_a.extra_data["reported_mac"] = "56:78:C9:E6:0D:A0" # LA variant
7698
7699 # Player B has the hardware MAC
7700 player_b = MockPlayer(
7701 provider,
7702 "player_b",
7703 "Player B",
7704 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}, # real MAC
7705 )
7706
7707 assert controller._identifiers_match(player_a, player_b) is True
7708
7709
7710class TestProtocolParentIdPersistence:
7711 """Tests for protocol_parent_id persistence for all parent types."""
7712
7713 def test_parent_id_saved_for_universal_parent(self, mock_mass: MagicMock) -> None:
7714 """protocol_parent_id should be saved when linking to a universal player."""
7715 controller = PlayerController(mock_mass)
7716
7717 def _config_get(key: str, default: object = None) -> object:
7718 # Protocol player config must exist for _save_protocol_parent_id to write
7719 if key == "players/airplay_test":
7720 return {"enabled": True}
7721 return default if default is not None else []
7722
7723 mock_mass.config.get = MagicMock(side_effect=_config_get)
7724
7725 universal_provider = create_mock_universal_provider(mock_mass)
7726 parent = UniversalPlayer(
7727 provider=universal_provider,
7728 player_id="up_test",
7729 name="Test Universal",
7730 device_info=DeviceInfo(),
7731 protocol_player_ids=[],
7732 )
7733 parent.update_state(signal_event=False)
7734
7735 protocol_provider = MockProvider("airplay")
7736 protocol_player = MockPlayer(
7737 protocol_provider,
7738 "airplay_test",
7739 "AirPlay Test",
7740 player_type=PlayerType.PROTOCOL,
7741 )
7742
7743 controller._players = {
7744 "up_test": parent,
7745 "airplay_test": protocol_player,
7746 }
7747
7748 controller._add_protocol_link(parent, protocol_player, "airplay")
7749
7750 # Verify _save_protocol_parent_id was called (config.set with parent ID)
7751 config_set_calls = [
7752 call
7753 for call in mock_mass.config.set.call_args_list
7754 if "protocol_parent_id" in str(call)
7755 ]
7756 assert len(config_set_calls) >= 1, (
7757 "protocol_parent_id should be saved for universal player parents"
7758 )
7759
7760 def test_parent_id_cleared_for_universal_parent(self, mock_mass: MagicMock) -> None:
7761 """protocol_parent_id should be cleared when unlinking from a universal player."""
7762 controller = PlayerController(mock_mass)
7763 mock_mass.config.get = MagicMock(return_value={"enabled": True})
7764
7765 universal_provider = create_mock_universal_provider(mock_mass)
7766 parent = UniversalPlayer(
7767 provider=universal_provider,
7768 player_id="up_test",
7769 name="Test Universal",
7770 device_info=DeviceInfo(),
7771 protocol_player_ids=["airplay_test"],
7772 )
7773 parent.update_state(signal_event=False)
7774
7775 protocol_provider = MockProvider("airplay")
7776 protocol_player = MockPlayer(
7777 protocol_provider,
7778 "airplay_test",
7779 "AirPlay Test",
7780 player_type=PlayerType.PROTOCOL,
7781 )
7782 protocol_player.set_protocol_parent_id("up_test")
7783
7784 # Set up linked protocols
7785 parent.set_linked_output_protocols(
7786 [
7787 LinkedOutputProtocol(
7788 output_protocol_id="airplay_test",
7789 protocol_domain="airplay",
7790 priority=10,
7791 )
7792 ]
7793 )
7794
7795 controller._players = {
7796 "up_test": parent,
7797 "airplay_test": protocol_player,
7798 }
7799
7800 controller._remove_protocol_link(parent, "airplay_test")
7801
7802 # Verify protocol_parent_id was cleared in config
7803 config_set_calls = [
7804 call
7805 for call in mock_mass.config.set.call_args_list
7806 if "protocol_parent_id" in str(call) and call.args[1] is None
7807 ]
7808 assert len(config_set_calls) >= 1, (
7809 "protocol_parent_id should be cleared for universal player parents"
7810 )
7811
7812
7813class TestCleanupProtocolLinks:
7814 """Tests for protocol link cleanup on player removal."""
7815
7816 def test_cleanup_clears_config_parent_id(self, mock_mass: MagicMock) -> None:
7817 """When a universal player is removed, protocol parent_ids are cleared in config."""
7818 controller = PlayerController(mock_mass)
7819 mock_mass.config.get = MagicMock(return_value={"enabled": True})
7820
7821 universal_provider = create_mock_universal_provider(mock_mass)
7822 parent = UniversalPlayer(
7823 provider=universal_provider,
7824 player_id="up_test",
7825 name="Test Universal",
7826 device_info=DeviceInfo(),
7827 protocol_player_ids=["airplay_test"],
7828 )
7829 parent.update_state(signal_event=False)
7830
7831 protocol_provider = MockProvider("airplay")
7832 protocol_player = MockPlayer(
7833 protocol_provider,
7834 "airplay_test",
7835 "AirPlay Test",
7836 player_type=PlayerType.PROTOCOL,
7837 )
7838 protocol_player.set_protocol_parent_id("up_test")
7839
7840 parent.set_linked_output_protocols(
7841 [
7842 LinkedOutputProtocol(
7843 output_protocol_id="airplay_test",
7844 protocol_domain="airplay",
7845 priority=10,
7846 )
7847 ]
7848 )
7849
7850 controller._players = {
7851 "up_test": parent,
7852 "airplay_test": protocol_player,
7853 }
7854
7855 controller._cleanup_protocol_links(parent)
7856
7857 # Verify protocol_parent_id was cleared in config
7858 config_set_calls = [
7859 call
7860 for call in mock_mass.config.set.call_args_list
7861 if "airplay_test" in str(call) and "protocol_parent_id" in str(call)
7862 ]
7863 assert len(config_set_calls) >= 1, (
7864 "protocol_parent_id should be cleared in config on parent removal"
7865 )
7866 # Verify in-memory parent cleared
7867 assert protocol_player.protocol_parent_id is None
7868
7869 def test_cleanup_native_parent_schedules_protocol_re_evaluation(
7870 self, mock_mass: MagicMock
7871 ) -> None:
7872 """Permanent native parent removal should reset links and reschedule protocols."""
7873 controller = PlayerController(mock_mass)
7874
7875 config_store: dict[str, object] = {
7876 "players/sonos_1": {"enabled": True},
7877 "players/sonos_1/values/linked_protocol_ids": ["airplay_live", "dlna_cached"],
7878 "players/airplay_live": {"enabled": True},
7879 "players/dlna_cached": {"enabled": True},
7880 }
7881
7882 def config_get(key: str, default: object = None) -> object:
7883 return config_store.get(key, default)
7884
7885 def config_set(key: str, value: object) -> None:
7886 config_store[key] = value
7887
7888 mock_mass.config.get = MagicMock(side_effect=config_get)
7889 mock_mass.config.set = MagicMock(side_effect=config_set)
7890
7891 native_provider = MockProvider("sonos", mass=mock_mass)
7892 parent = MockPlayer(
7893 native_provider,
7894 "sonos_1",
7895 "Sonos",
7896 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7897 )
7898
7899 protocol_provider = MockProvider("airplay", mass=mock_mass)
7900 protocol_player = MockPlayer(
7901 protocol_provider,
7902 "airplay_live",
7903 "AirPlay Test",
7904 player_type=PlayerType.PROTOCOL,
7905 )
7906 protocol_player.set_protocol_parent_id("sonos_1")
7907
7908 parent.set_linked_output_protocols(
7909 [
7910 LinkedOutputProtocol(
7911 output_protocol_id="airplay_live",
7912 protocol_domain="airplay",
7913 priority=10,
7914 )
7915 ]
7916 )
7917
7918 controller._players = {
7919 "sonos_1": parent,
7920 "airplay_live": protocol_player,
7921 }
7922
7923 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
7924 controller._cleanup_protocol_links(parent)
7925
7926 assert protocol_player.protocol_parent_id is None
7927 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
7928 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
7929 mock_schedule.assert_called_once_with(protocol_player)
7930
7931 def test_cleanup_universal_parent_clears_cached_only_protocol_ids(
7932 self, mock_mass: MagicMock
7933 ) -> None:
7934 """Universal cleanup should also clear cached protocol ids that are not active."""
7935 controller = PlayerController(mock_mass)
7936
7937 config_store: dict[str, object] = {
7938 "players/up_test": {"enabled": True},
7939 "players/airplay_live": {"enabled": True},
7940 "players/dlna_cached": {"enabled": True},
7941 }
7942
7943 def config_get(key: str, default: object = None) -> object:
7944 return config_store.get(key, default)
7945
7946 def config_set(key: str, value: object) -> None:
7947 config_store[key] = value
7948
7949 mock_mass.config.get = MagicMock(side_effect=config_get)
7950 mock_mass.config.set = MagicMock(side_effect=config_set)
7951
7952 universal_provider = create_mock_universal_provider(mock_mass)
7953 parent = UniversalPlayer(
7954 provider=universal_provider,
7955 player_id="up_test",
7956 name="Test Universal",
7957 device_info=DeviceInfo(),
7958 protocol_player_ids=["airplay_live", "dlna_cached"],
7959 )
7960 parent.update_state(signal_event=False)
7961
7962 protocol_provider = MockProvider("airplay", mass=mock_mass)
7963 protocol_player = MockPlayer(
7964 protocol_provider,
7965 "airplay_live",
7966 "AirPlay Test",
7967 player_type=PlayerType.PROTOCOL,
7968 )
7969 protocol_player.set_protocol_parent_id("up_test")
7970
7971 parent.set_linked_output_protocols(
7972 [
7973 LinkedOutputProtocol(
7974 output_protocol_id="airplay_live",
7975 protocol_domain="airplay",
7976 priority=10,
7977 )
7978 ]
7979 )
7980
7981 controller._players = {
7982 "up_test": parent,
7983 "airplay_live": protocol_player,
7984 }
7985
7986 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
7987 controller._cleanup_protocol_links(parent)
7988
7989 assert protocol_player.protocol_parent_id is None
7990 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
7991 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
7992 mock_schedule.assert_called_once_with(protocol_player)
7993
7994
7995class TestDetachProtocolChildren:
7996 """Tests for detaching protocol players of a removed player that is not registered."""
7997
7998 @staticmethod
7999 def _setup(
8000 mock_mass: MagicMock, live_parent_id: str | None
8001 ) -> tuple[PlayerController, MockPlayer, dict[str, object]]:
8002 """Set up a registered protocol player whose (unregistered) parent is sonos_1."""
8003 controller = PlayerController(mock_mass)
8004
8005 config_store: dict[str, object] = {
8006 "players": {"sonos_1": {}, "airplay_live": {}},
8007 "players/sonos_1": {"enabled": True},
8008 "players/airplay_live": {"enabled": True},
8009 "players/airplay_live/values/protocol_parent_id": "sonos_1",
8010 }
8011
8012 mock_mass.config.get = MagicMock(
8013 side_effect=lambda key, default=None: config_store.get(key, default)
8014 )
8015 mock_mass.config.set = MagicMock(
8016 side_effect=lambda key, value: config_store.__setitem__(key, value)
8017 )
8018
8019 protocol_player = MockPlayer(
8020 MockProvider("airplay", mass=mock_mass),
8021 "airplay_live",
8022 "AirPlay Test",
8023 player_type=PlayerType.PROTOCOL,
8024 )
8025 protocol_player.set_protocol_parent_id(live_parent_id)
8026 controller._players = {"airplay_live": protocol_player}
8027 return controller, protocol_player, config_store
8028
8029 def test_detach_on_removal_of_an_unregistered_parent(self, mock_mass: MagicMock) -> None:
8030 """Removing a player that is no longer registered detaches its protocol player."""
8031 controller, protocol_player, config_store = self._setup(mock_mass, "sonos_1")
8032
8033 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
8034 controller.delete_player_config("sonos_1")
8035
8036 assert protocol_player.protocol_parent_id is None
8037 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
8038 mock_schedule.assert_called_once_with(protocol_player)
8039
8040 def test_detach_of_a_protocol_player_waiting_for_its_parent(self, mock_mass: MagicMock) -> None:
8041 """A protocol player that only has the parent link in its config is detached too."""
8042 controller, protocol_player, config_store = self._setup(mock_mass, None)
8043
8044 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
8045 controller.delete_player_config("sonos_1")
8046
8047 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
8048 mock_schedule.assert_called_once_with(protocol_player)
8049
8050 def test_detach_skips_a_protocol_player_of_another_parent(self, mock_mass: MagicMock) -> None:
8051 """A protocol player that already moved to another parent keeps its link."""
8052 controller, protocol_player, config_store = self._setup(mock_mass, "cast_1")
8053
8054 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
8055 controller.delete_player_config("sonos_1")
8056
8057 assert protocol_player.protocol_parent_id == "cast_1"
8058 assert config_store["players/airplay_live/values/protocol_parent_id"] == "sonos_1"
8059 mock_schedule.assert_not_called()
8060
8061
8062class TestStaleConfigMigration:
8063 """Tests for protocol parent link repair on startup."""
8064
8065 def test_clears_stale_parent_ids(self, mock_mass: MagicMock) -> None:
8066 """Stale parent_ids pointing to deleted players are cleared on startup."""
8067 controller = PlayerController(mock_mass)
8068
8069 # Set up config with a protocol player pointing to a deleted universal player
8070 mock_mass.config.get = MagicMock(
8071 return_value={
8072 "airplay_test": {
8073 "player_type": "protocol",
8074 "values": {
8075 "protocol_parent_id": "up_deleted_player",
8076 },
8077 },
8078 "heos_player": {
8079 "player_type": "player",
8080 "values": {},
8081 },
8082 }
8083 )
8084
8085 controller._repair_protocol_parent_links()
8086
8087 # Verify the stale parent_id was cleared
8088 mock_mass.config.set.assert_any_call(
8089 "players/airplay_test/values/protocol_parent_id",
8090 None,
8091 )
8092 mock_mass.config.set_player_type.assert_not_called()
8093
8094 def test_keeps_valid_parent_ids(self, mock_mass: MagicMock) -> None:
8095 """Valid parent_ids pointing to existing players are preserved."""
8096 controller = PlayerController(mock_mass)
8097
8098 mock_mass.config.get = MagicMock(
8099 return_value={
8100 "airplay_test": {
8101 "player_type": "protocol",
8102 "values": {
8103 "protocol_parent_id": "up_valid_player",
8104 },
8105 },
8106 "up_valid_player": {
8107 "player_type": "group",
8108 "provider": "universal_player",
8109 "values": {},
8110 },
8111 }
8112 )
8113
8114 controller._repair_protocol_parent_links()
8115
8116 # Verify no set calls were made to clear the parent_id
8117 for call in mock_mass.config.set.call_args_list:
8118 assert "protocol_parent_id" not in str(call), "Valid parent_id should not be cleared"
8119 mock_mass.config.set_player_type.assert_not_called()
8120
8121 def test_heals_stale_player_type_of_linked_child(self, mock_mass: MagicMock) -> None:
8122 """A parented child whose player_type was corrupted is healed back to protocol."""
8123 controller = PlayerController(mock_mass)
8124
8125 mock_mass.config.get = MagicMock(
8126 return_value={
8127 "squeezelite_child": {
8128 "player_type": "player",
8129 "values": {
8130 "protocol_parent_id": "up_valid_player",
8131 },
8132 },
8133 "up_valid_player": {
8134 "player_type": "group",
8135 "provider": "universal_player",
8136 "values": {},
8137 },
8138 }
8139 )
8140
8141 controller._repair_protocol_parent_links()
8142
8143 mock_mass.config.set_player_type.assert_called_once_with(
8144 "squeezelite_child", PlayerType.PROTOCOL
8145 )
8146
8147 def test_native_registration_clears_stale_parent_link(self, mock_mass: MagicMock) -> None:
8148 """A player registering with a non-protocol type drops its leftover parent link."""
8149 controller = PlayerController(mock_mass)
8150 provider = MockProvider("sendspin", mass=mock_mass)
8151 player = MockPlayer(provider, "web_player", "Web Player", player_type=PlayerType.PLAYER)
8152
8153 config_values = {
8154 f"{CONF_PLAYERS}/web_player": {"player_type": "player"},
8155 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id": "up_old_parent",
8156 }
8157 mock_mass.config.get = MagicMock(
8158 side_effect=lambda key, default=None: config_values.get(key, default)
8159 )
8160
8161 with patch.object(controller, "_try_link_protocols_to_native"):
8162 controller._evaluate_protocol_links(player)
8163
8164 mock_mass.config.set.assert_any_call(
8165 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id",
8166 None,
8167 )
8168
8169
8170class TestUniversalPlayerRestoreOrphanCleanup:
8171 """Tests for UniversalPlayerProvider._restore_player membership repair behavior."""
8172
8173 @staticmethod
8174 def _setup_config_get(
8175 mock_mass: MagicMock,
8176 universal_id: str,
8177 linked_protocol_ids: list[str],
8178 protocol_configs: dict[str, dict[str, object]],
8179 ) -> None:
8180 """Wire mass.config.get to return universal/protocol configs by key."""
8181 universal_conf = {
8182 "values": {
8183 "linked_protocol_ids": list(linked_protocol_ids),
8184 "device_identifiers": {},
8185 "device_info": {},
8186 },
8187 "name": "Test Universal",
8188 }
8189 all_configs: dict[str, object] = {universal_id: universal_conf, **protocol_configs}
8190
8191 def _get(key: str, default: object = None) -> object:
8192 if key == CONF_PLAYERS:
8193 return all_configs
8194 if key.startswith(f"{CONF_PLAYERS}/"):
8195 pid = key.split("/", 1)[1]
8196 return all_configs.get(pid, default)
8197 return default
8198
8199 mock_mass.config.get.side_effect = _get
8200
8201 @staticmethod
8202 def _wire_nested_config(mock_mass: MagicMock, store: dict[str, Any]) -> None:
8203 """Wire mass.config get/set/remove to a nested, path-addressable store."""
8204
8205 def _get(key: str, default: object = None) -> object:
8206 node: Any = store
8207 for part in key.split("/"):
8208 if not isinstance(node, dict) or part not in node:
8209 return default
8210 node = node[part]
8211 return node
8212
8213 def _set(key: str, value: object) -> None:
8214 parts = key.split("/")
8215 node: dict[str, Any] = store
8216 for part in parts[:-1]:
8217 node = node.setdefault(part, {})
8218 node[parts[-1]] = value
8219
8220 def _remove(key: str) -> None:
8221 parts = key.split("/")
8222 node: Any = store
8223 for part in parts[:-1]:
8224 node = node.get(part) if isinstance(node, dict) else None
8225 if node is None:
8226 return
8227 if isinstance(node, dict):
8228 node.pop(parts[-1], None)
8229
8230 mock_mass.config.get = MagicMock(side_effect=_get)
8231 mock_mass.config.set = MagicMock(side_effect=_set)
8232 mock_mass.config.remove = MagicMock(side_effect=_remove)
8233
8234 @pytest.mark.asyncio
8235 async def test_orphan_protocol_dropped_but_configs_kept(self, mock_mass: MagicMock) -> None:
8236 """Orphan protocol is dropped from membership without deleting any config."""
8237 provider = create_mock_universal_provider(mock_mass)
8238 universal_id = "up_test"
8239 orphan_id = "spb_orphan"
8240
8241 self._setup_config_get(
8242 mock_mass,
8243 universal_id,
8244 [orphan_id],
8245 {
8246 orphan_id: {
8247 "player_type": "protocol",
8248 "values": {"protocol_parent_id": None},
8249 },
8250 },
8251 )
8252 mock_mass.players = MagicMock()
8253 mock_mass.players.get_player = MagicMock(return_value=None)
8254 mock_mass.players.delete_player_config = MagicMock()
8255 mock_mass.players.unregister = AsyncMock()
8256 mock_mass.players.register_or_update = AsyncMock()
8257 mock_mass.config.remove_player_config = AsyncMock()
8258
8259 await provider._restore_player(universal_id)
8260
8261 # Neither the orphan's config nor the universal's config is deleted
8262 mock_mass.players.delete_player_config.assert_not_called()
8263 mock_mass.players.unregister.assert_not_called()
8264 mock_mass.config.remove_player_config.assert_not_called()
8265 # With no valid protocols left, the universal is simply not restored
8266 mock_mass.players.register_or_update.assert_not_called()
8267
8268 @pytest.mark.asyncio
8269 async def test_valid_protocol_kept_and_no_cleanup(self, mock_mass: MagicMock) -> None:
8270 """Protocol with a valid protocol_parent_id is kept and no cleanup runs."""
8271 provider = create_mock_universal_provider(mock_mass)
8272 universal_id = "up_test"
8273 protocol_id = "spb_valid"
8274
8275 self._setup_config_get(
8276 mock_mass,
8277 universal_id,
8278 [protocol_id],
8279 {
8280 protocol_id: {
8281 "player_type": "protocol",
8282 "values": {"protocol_parent_id": universal_id},
8283 },
8284 },
8285 )
8286 mock_mass.players = MagicMock()
8287 mock_mass.players.get_player = MagicMock(return_value=None)
8288 mock_mass.players.delete_player_config = MagicMock()
8289 mock_mass.players.unregister = AsyncMock()
8290 mock_mass.players.register_or_update = AsyncMock()
8291 mock_mass.config.remove_player_config = AsyncMock()
8292 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
8293
8294 await provider._restore_player(universal_id)
8295
8296 mock_mass.players.delete_player_config.assert_not_called()
8297 mock_mass.players.unregister.assert_not_called()
8298 mock_mass.config.remove_player_config.assert_not_called()
8299 # Universal is constructed and registered with the valid protocol kept
8300 mock_mass.players.register_or_update.assert_awaited_once()
8301 registered = mock_mass.players.register_or_update.call_args.args[0]
8302 assert protocol_id in registered._protocol_player_ids
8303
8304 @pytest.mark.asyncio
8305 async def test_restore_keeps_linked_protocol_after_startup_type_rewrite(
8306 self, mock_mass: MagicMock
8307 ) -> None:
8308 """A linked protocol whose type was rewritten must not delete its universal config."""
8309 provider = create_mock_universal_provider(mock_mass)
8310 universal_id = "upe45f0170ef67"
8311 protocol_id = "e4:5f:01:70:ef:67"
8312 universal_config: dict[str, Any] = {
8313 "player_id": universal_id,
8314 "provider": "universal_player",
8315 "player_type": "player",
8316 "enabled": True,
8317 "name": "WC-Player",
8318 "default_name": "solarium-bath-sl",
8319 "values": {
8320 "hide_in_ui": True,
8321 "announce_volume_min": 55,
8322 "announce_volume_max": 98,
8323 "play_media_overrides_group": False,
8324 "linked_protocol_ids": [protocol_id],
8325 "device_identifiers": {},
8326 "device_info": {},
8327 },
8328 }
8329 configs: dict[str, dict[str, Any]] = {
8330 universal_id: universal_config,
8331 protocol_id: {
8332 "player_id": protocol_id,
8333 "provider": "squeezelite",
8334 "player_type": "player",
8335 "enabled": True,
8336 "values": {"protocol_parent_id": universal_id},
8337 },
8338 }
8339
8340 def _config_get(key: str, default: object = None) -> object:
8341 if key == CONF_PLAYERS:
8342 return configs
8343 if key.startswith(f"{CONF_PLAYERS}/"):
8344 pid = key.split("/", 1)[1]
8345 return configs.get(pid, default)
8346 return default
8347
8348 mock_mass.config.get.side_effect = _config_get
8349 mock_mass.config.remove_player_config = AsyncMock()
8350 mock_mass.config.get_base_player_config.return_value = create_mock_config("WC-Player")
8351 mock_mass.players = MagicMock()
8352 mock_mass.players.get_player = MagicMock(return_value=None)
8353 mock_mass.players.delete_player_config = MagicMock()
8354 mock_mass.players.unregister = AsyncMock()
8355 mock_mass.players.register_or_update = AsyncMock()
8356
8357 await provider._restore_player(universal_id)
8358
8359 mock_mass.config.remove_player_config.assert_not_called()
8360 mock_mass.players.delete_player_config.assert_not_called()
8361 mock_mass.players.unregister.assert_not_called()
8362 mock_mass.players.register_or_update.assert_awaited_once()
8363 registered = mock_mass.players.register_or_update.call_args.args[0]
8364 assert protocol_id in registered._protocol_player_ids
8365 assert configs[universal_id]["values"]["hide_in_ui"] is True
8366 assert configs[universal_id]["values"]["announce_volume_min"] == 55
8367
8368 @pytest.mark.asyncio
8369 async def test_membership_augmented_from_child_parent_link(self, mock_mass: MagicMock) -> None:
8370 """A child whose parent link points at the universal is re-added to membership."""
8371 provider = create_mock_universal_provider(mock_mass)
8372 universal_id = "up_test"
8373 stored_id = "spb_stored"
8374 missing_id = "spb_missing"
8375
8376 self._setup_config_get(
8377 mock_mass,
8378 universal_id,
8379 [stored_id],
8380 {
8381 stored_id: {
8382 "player_type": "protocol",
8383 "values": {"protocol_parent_id": universal_id},
8384 },
8385 missing_id: {
8386 "player_type": "protocol",
8387 "values": {"protocol_parent_id": universal_id},
8388 },
8389 },
8390 )
8391 mock_mass.players = MagicMock()
8392 mock_mass.players.get_player = MagicMock(return_value=None)
8393 mock_mass.players.register_or_update = AsyncMock()
8394 mock_mass.config.remove_player_config = AsyncMock()
8395 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
8396
8397 await provider._restore_player(universal_id)
8398
8399 # The missing child is restored to membership and persisted
8400 mock_mass.config.set.assert_any_call(
8401 f"{CONF_PLAYERS}/{universal_id}/values/linked_protocol_ids",
8402 [stored_id, missing_id],
8403 )
8404 registered = mock_mass.players.register_or_update.call_args.args[0]
8405 assert registered._protocol_player_ids == [stored_id, missing_id]
8406
8407 @pytest.mark.asyncio
8408 async def test_universal_config_kept_when_protocol_moved_to_other_parent(
8409 self, mock_mass: MagicMock
8410 ) -> None:
8411 """A protocol that moved to another parent is dropped, universal config is kept."""
8412 provider = create_mock_universal_provider(mock_mass)
8413 universal_id = "up_test"
8414 protocol_id = "spb_moved"
8415
8416 self._setup_config_get(
8417 mock_mass,
8418 universal_id,
8419 [protocol_id],
8420 {
8421 protocol_id: {
8422 "player_type": "protocol",
8423 "values": {"protocol_parent_id": "up_other"},
8424 },
8425 },
8426 )
8427 mock_mass.players = MagicMock()
8428 mock_mass.players.get_player = MagicMock(return_value=None)
8429 mock_mass.players.register_or_update = AsyncMock()
8430 mock_mass.config.remove_player_config = AsyncMock()
8431
8432 await provider._restore_player(universal_id)
8433
8434 mock_mass.config.remove_player_config.assert_not_called()
8435 mock_mass.players.register_or_update.assert_not_called()
8436
8437 @pytest.mark.asyncio
8438 async def test_disabled_parent_reparents_and_disables_orphaned_protocols(
8439 self, mock_mass: MagicMock
8440 ) -> None:
8441 """
8442 Self-heal: stale UP whose protocols belong to a disabled native parent.
8443
8444 Restoring such a UP should skip the wrapper, restore each protocol's
8445 parent_id back to the rightful (disabled) native parent, cascade-disable
8446 each protocol so the next registration cycle doesn't rebuild the wrapper,
8447 and hand the wrapper's config over to the native parent before deleting it.
8448 """
8449 provider = create_mock_universal_provider(mock_mass)
8450 universal_id = "up_test"
8451 native_parent_id = "sonos_disabled"
8452 ap_id = "airplay_1"
8453 dlna_id = "dlna_1"
8454
8455 all_configs = {
8456 universal_id: {
8457 "values": {
8458 "linked_protocol_ids": [ap_id, dlna_id],
8459 "device_identifiers": {},
8460 "device_info": {},
8461 },
8462 "name": "Test UP",
8463 },
8464 native_parent_id: {
8465 "enabled": False,
8466 "provider": "sonos",
8467 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
8468 },
8469 ap_id: {
8470 "player_type": "protocol",
8471 "enabled": True,
8472 "values": {"protocol_parent_id": universal_id},
8473 },
8474 dlna_id: {
8475 "player_type": "protocol",
8476 "enabled": True,
8477 "values": {"protocol_parent_id": universal_id},
8478 },
8479 }
8480
8481 def _config_get(key: str, default: object = None) -> object:
8482 if key == CONF_PLAYERS:
8483 return all_configs
8484 if key.startswith(f"{CONF_PLAYERS}/"):
8485 pid = key.split("/", 1)[1]
8486 return all_configs.get(pid, default)
8487 return default
8488
8489 mock_mass.config.get.side_effect = _config_get
8490 mock_mass.config.set = MagicMock()
8491 mock_mass.config.save_player_config = AsyncMock()
8492 mock_mass.config.remove_player_config = AsyncMock()
8493 mock_mass.players = MagicMock()
8494 mock_mass.players.get_player = MagicMock(return_value=None)
8495
8496 await provider._restore_player(universal_id)
8497
8498 # The wrapper is not restored; its settings are handed over to the
8499 # native parent and its now-obsolete config is deleted
8500 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
8501 universal_id, native_parent_id
8502 )
8503 mock_mass.players._repoint_group_memberships.assert_called_once_with(
8504 universal_id, native_parent_id
8505 )
8506 mock_mass.players.delete_player_config.assert_called_once_with(universal_id)
8507
8508 # Each protocol's parent_id is restored to the disabled native parent
8509 parent_restorations = {
8510 call.args[0]: call.args[1]
8511 for call in mock_mass.config.set.call_args_list
8512 if "protocol_parent_id" in call.args[0]
8513 }
8514 assert parent_restorations == {
8515 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": native_parent_id,
8516 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": native_parent_id,
8517 }
8518
8519 # Each protocol is cascade-disabled
8520 disabled_ids = {
8521 call.args[0] for call in mock_mass.config.save_player_config.await_args_list
8522 }
8523 assert disabled_ids == {ap_id, dlna_id}
8524
8525 @pytest.mark.asyncio
8526 async def test_protocols_reparented_to_their_own_native_claimer(
8527 self, mock_mass: MagicMock
8528 ) -> None:
8529 """Each protocol is reparented to the native player that claims it, not the first found."""
8530 provider = create_mock_universal_provider(mock_mass)
8531 universal_id = "up_test"
8532 ap_id = "airplay_1"
8533 slimproto_id = "slimproto_1"
8534
8535 all_configs = {
8536 universal_id: {
8537 "values": {
8538 "linked_protocol_ids": [ap_id, slimproto_id],
8539 "device_identifiers": {},
8540 "device_info": {},
8541 },
8542 "name": "Test UP",
8543 },
8544 "native_a": {
8545 "provider": "dlna",
8546 "values": {"linked_protocol_ids": [ap_id]},
8547 },
8548 "native_b": {
8549 "provider": "squeezelite",
8550 "values": {"linked_protocol_ids": [slimproto_id]},
8551 },
8552 ap_id: {
8553 "player_type": "protocol",
8554 "values": {"protocol_parent_id": universal_id},
8555 },
8556 slimproto_id: {
8557 "player_type": "protocol",
8558 "values": {"protocol_parent_id": universal_id},
8559 },
8560 }
8561
8562 def _config_get(key: str, default: object = None) -> object:
8563 if key == CONF_PLAYERS:
8564 return all_configs
8565 if key.startswith(f"{CONF_PLAYERS}/"):
8566 pid = key.split("/", 1)[1]
8567 return all_configs.get(pid, default)
8568 return default
8569
8570 mock_mass.config.get.side_effect = _config_get
8571 mock_mass.config.set = MagicMock()
8572 mock_mass.config.save_player_config = AsyncMock()
8573 mock_mass.config.remove_player_config = AsyncMock()
8574 mock_mass.players = MagicMock()
8575 mock_mass.players.get_player = MagicMock(return_value=None)
8576 mock_mass.players.register_or_update = AsyncMock()
8577
8578 await provider._restore_player(universal_id)
8579
8580 mock_mass.players.register_or_update.assert_not_called()
8581 parent_restorations = {
8582 call.args[0]: call.args[1]
8583 for call in mock_mass.config.set.call_args_list
8584 if "protocol_parent_id" in call.args[0]
8585 }
8586 assert parent_restorations == {
8587 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
8588 f"{CONF_PLAYERS}/{slimproto_id}/values/protocol_parent_id": "native_b",
8589 }
8590 for call in mock_mass.config.save_player_config.await_args_list:
8591 assert call.args[1] == {ATTR_ENABLED: False}
8592 # The wrapper's settings are handed to the first claimer, then its
8593 # config is deleted
8594 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
8595 universal_id, "native_a"
8596 )
8597 mock_mass.players._repoint_group_memberships.assert_called_once_with(
8598 universal_id, "native_a"
8599 )
8600 mock_mass.players.delete_player_config.assert_called_once_with(universal_id)
8601
8602 @pytest.mark.asyncio
8603 async def test_restore_native_claims_carries_config_and_deletes_wrapper(
8604 self, mock_mass: MagicMock
8605 ) -> None:
8606 """The wrapper's user settings move to the native claimer and its config is deleted."""
8607 universal_id = "up_old"
8608 native_id = "cast_1"
8609 ap_id = "ap_1"
8610 store: dict[str, Any] = {
8611 CONF_PLAYERS: {
8612 universal_id: {
8613 "provider": "universal_player",
8614 "enabled": True,
8615 "name": "Living Room",
8616 "default_name": "Soundbar (Universal)",
8617 "values": {
8618 "hide_in_ui": True,
8619 "volume_control": "up_volume_control",
8620 "linked_protocol_ids": [ap_id],
8621 "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
8622 "device_info": {"model": "Test", "manufacturer": "Test"},
8623 },
8624 },
8625 native_id: {
8626 "provider": "cast",
8627 "enabled": True,
8628 "name": "Soundbar",
8629 "default_name": "Soundbar",
8630 "values": {
8631 "volume_control": "native",
8632 "linked_protocol_ids": [ap_id],
8633 },
8634 },
8635 ap_id: {
8636 "player_type": "protocol",
8637 "enabled": True,
8638 "values": {"protocol_parent_id": universal_id},
8639 },
8640 "group_1": {
8641 "provider": "sync_group",
8642 "enabled": True,
8643 "values": {
8644 "group_members": [universal_id, "other"],
8645 "allowed_members": [universal_id],
8646 },
8647 },
8648 },
8649 CONF_PLAYER_DSP: {universal_id: {"enabled": True, "filters": []}},
8650 CONF_PLAYER_QUEUES: {
8651 universal_id: {"queue_id": universal_id, "values": {"crossfade_duration": 5}}
8652 },
8653 }
8654 self._wire_nested_config(mock_mass, store)
8655 mock_mass.config.save_player_config = AsyncMock()
8656 scheduled_tasks: list[Awaitable[object]] = []
8657 mock_mass.create_task = MagicMock(
8658 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
8659 )
8660 controller = PlayerController(mock_mass)
8661 controller._players = {}
8662 mock_mass.players = controller
8663 mock_mass.player_queues = MagicMock()
8664 provider = create_mock_universal_provider(mock_mass)
8665
8666 await provider._restore_player(universal_id)
8667
8668 players_tree = store[CONF_PLAYERS]
8669 # the protocol player is reparented to the native claimer
8670 assert players_tree[ap_id]["values"]["protocol_parent_id"] == native_id
8671 # the custom display name and user-set values moved onto the native player
8672 assert players_tree[native_id]["name"] == "Living Room"
8673 assert players_tree[native_id]["values"]["hide_in_ui"] is True
8674 # values the native player has explicitly set are not overwritten
8675 assert players_tree[native_id]["values"]["volume_control"] == "native"
8676 # wrapper bookkeeping values are not carried over
8677 assert "device_identifiers" not in players_tree[native_id]["values"]
8678 assert "device_info" not in players_tree[native_id]["values"]
8679 # DSP and queue settings moved onto the native player
8680 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": True, "filters": []}}
8681 assert store[CONF_PLAYER_QUEUES] == {
8682 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 5}}
8683 }
8684 # group memberships that referenced the wrapper now point at the native player
8685 assert players_tree["group_1"]["values"]["group_members"] == [native_id, "other"]
8686 assert players_tree["group_1"]["values"]["allowed_members"] == [native_id]
8687 # the wrapper's own config entries are all gone
8688 assert universal_id not in players_tree
8689 # cached queue state of the wrapper is purged as well
8690 mock_mass.player_queues.purge_saved_queue.assert_called_once_with(universal_id)
8691 # the wrapper itself is not registered as a player
8692 assert universal_id not in controller._players
8693 for task in scheduled_tasks:
8694 await task
8695
8696 @pytest.mark.asyncio
8697 async def test_restore_native_claims_keeps_native_name_dsp_and_queue_values(
8698 self, mock_mass: MagicMock
8699 ) -> None:
8700 """Values explicitly set on the native player win over the wrapper's on restore."""
8701 universal_id = "up_old"
8702 native_id = "cast_1"
8703 ap_id = "ap_1"
8704 store: dict[str, Any] = {
8705 CONF_PLAYERS: {
8706 universal_id: {
8707 "provider": "universal_player",
8708 "enabled": True,
8709 "name": "Living Room",
8710 "default_name": "Soundbar (Universal)",
8711 "values": {"hide_in_ui": True, "linked_protocol_ids": [ap_id]},
8712 },
8713 native_id: {
8714 "provider": "cast",
8715 "enabled": True,
8716 "name": "Kitchen",
8717 "default_name": "Soundbar",
8718 "values": {"linked_protocol_ids": [ap_id]},
8719 },
8720 ap_id: {
8721 "player_type": "protocol",
8722 "enabled": True,
8723 "values": {"protocol_parent_id": universal_id},
8724 },
8725 },
8726 CONF_PLAYER_DSP: {
8727 universal_id: {"enabled": True, "filters": ["universal"]},
8728 native_id: {"enabled": False, "filters": ["native"]},
8729 },
8730 CONF_PLAYER_QUEUES: {
8731 universal_id: {
8732 "queue_id": universal_id,
8733 "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
8734 },
8735 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 2}},
8736 },
8737 }
8738 self._wire_nested_config(mock_mass, store)
8739 mock_mass.config.save_player_config = AsyncMock()
8740 scheduled_tasks: list[Awaitable[object]] = []
8741 mock_mass.create_task = MagicMock(
8742 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
8743 )
8744 controller = PlayerController(mock_mass)
8745 controller._players = {}
8746 mock_mass.players = controller
8747 mock_mass.player_queues = MagicMock()
8748 provider = create_mock_universal_provider(mock_mass)
8749
8750 await provider._restore_player(universal_id)
8751
8752 players_tree = store[CONF_PLAYERS]
8753 # the native player's own custom name and DSP config win
8754 assert players_tree[native_id]["name"] == "Kitchen"
8755 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": False, "filters": ["native"]}}
8756 # queue values merge without overwriting the native queue's own values
8757 assert store[CONF_PLAYER_QUEUES] == {
8758 native_id: {
8759 "queue_id": native_id,
8760 "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
8761 }
8762 }
8763 # non-conflicting values still migrate and the wrapper config is gone
8764 assert players_tree[native_id]["values"]["hide_in_ui"] is True
8765 assert universal_id not in players_tree
8766 for task in scheduled_tasks:
8767 await task
8768
8769 @pytest.mark.asyncio
8770 async def test_restore_native_claims_keeps_config_while_wrapper_registered(
8771 self, mock_mass: MagicMock
8772 ) -> None:
8773 """No config carry-over/deletion while the wrapper is still registered."""
8774 provider = create_mock_universal_provider(mock_mass)
8775 universal_id = "up_test"
8776 ap_id = "airplay_1"
8777
8778 all_configs = {
8779 universal_id: {
8780 "values": {
8781 "linked_protocol_ids": [ap_id],
8782 "device_identifiers": {},
8783 "device_info": {},
8784 },
8785 "name": "Test UP",
8786 },
8787 "native_a": {
8788 "provider": "dlna",
8789 "values": {"linked_protocol_ids": [ap_id]},
8790 },
8791 ap_id: {
8792 "player_type": "protocol",
8793 "values": {"protocol_parent_id": universal_id},
8794 },
8795 }
8796
8797 def _config_get(key: str, default: object = None) -> object:
8798 if key == CONF_PLAYERS:
8799 return all_configs
8800 if key.startswith(f"{CONF_PLAYERS}/"):
8801 pid = key.split("/", 1)[1]
8802 return all_configs.get(pid, default)
8803 return default
8804
8805 mock_mass.config.get.side_effect = _config_get
8806 mock_mass.config.set = MagicMock()
8807 mock_mass.config.save_player_config = AsyncMock()
8808 mock_mass.players = MagicMock()
8809 mock_mass.players.get_player = MagicMock(return_value=MagicMock())
8810
8811 await provider._restore_player(universal_id)
8812
8813 # the protocol link repair still runs
8814 parent_restorations = {
8815 call.args[0]: call.args[1]
8816 for call in mock_mass.config.set.call_args_list
8817 if "protocol_parent_id" in call.args[0]
8818 }
8819 assert parent_restorations == {
8820 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
8821 }
8822 # but the registered wrapper keeps its config and group memberships
8823 mock_mass.players._migrate_universal_player_config.assert_not_called()
8824 mock_mass.players._repoint_group_memberships.assert_not_called()
8825 mock_mass.players.delete_player_config.assert_not_called()
8826 mock_mass.player_queues.on_player_remove.assert_not_called()
8827
8828
8829class TestParentDisableCascade:
8830 """Tests for cascading enable/disable from a native parent to its protocols."""
8831
8832 @staticmethod
8833 def _make_config(player_id: str, *, enabled: bool, provider: str = "sonos") -> MagicMock:
8834 """Build a minimal PlayerConfig mock for on_player_config_change."""
8835 config = MagicMock()
8836 config.player_id = player_id
8837 config.provider = provider
8838 config.enabled = enabled
8839 config.values = {}
8840 return config
8841
8842 @pytest.mark.asyncio
8843 async def test_disabling_native_parent_cascades_disable_to_linked_protocols(
8844 self, mock_mass: MagicMock
8845 ) -> None:
8846 """Disabling a native parent saves enabled=False for each linked protocol."""
8847 controller = PlayerController(mock_mass)
8848
8849 native_provider = MockProvider("sonos", mass=mock_mass)
8850 native_player = MockPlayer(
8851 native_provider,
8852 "sonos_native",
8853 "Native Speaker",
8854 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8855 )
8856 # Bypass the power-off branch in on_player_config_change
8857 native_player._attr_available = False
8858 native_player.set_linked_output_protocols(
8859 [
8860 LinkedOutputProtocol(
8861 output_protocol_id="ap_1",
8862 protocol_domain="airplay",
8863 priority=10,
8864 ),
8865 LinkedOutputProtocol(
8866 output_protocol_id="dlna_1",
8867 protocol_domain="dlna",
8868 priority=20,
8869 ),
8870 ]
8871 )
8872 controller._players = {native_player.player_id: native_player}
8873
8874 protocol_configs = {
8875 "ap_1": {"enabled": True, "player_type": "protocol"},
8876 "dlna_1": {"enabled": True, "player_type": "protocol"},
8877 }
8878
8879 def _config_get(key: str, default: object = None) -> object:
8880 if key.startswith(f"{CONF_PLAYERS}/"):
8881 pid = key.split("/", 1)[1]
8882 return protocol_configs.get(pid, default)
8883 return default
8884
8885 mock_mass.config.get = MagicMock(side_effect=_config_get)
8886 mock_mass.config.save_player_config = MagicMock()
8887 mock_mass.create_task = MagicMock()
8888 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
8889
8890 config = self._make_config(native_player.player_id, enabled=False)
8891
8892 await controller.on_player_config_change(config, {ATTR_ENABLED})
8893
8894 called = mock_mass.config.save_player_config.call_args_list
8895 assert {call.args[0] for call in called} == {"ap_1", "dlna_1"}
8896 for call in called:
8897 assert call.args[1] == {ATTR_ENABLED: False}
8898
8899 @pytest.mark.asyncio
8900 async def test_enabling_native_parent_cascades_enable_to_linked_protocols(
8901 self, mock_mass: MagicMock
8902 ) -> None:
8903 """Re-enabling a native parent saves enabled=True for each (currently disabled) protocol."""
8904 controller = PlayerController(mock_mass)
8905
8906 native_provider = MockProvider("sonos", mass=mock_mass)
8907 native_player = MockPlayer(
8908 native_provider,
8909 "sonos_native",
8910 "Native Speaker",
8911 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8912 )
8913 native_player._attr_available = False
8914 native_player.set_linked_output_protocols(
8915 [
8916 LinkedOutputProtocol(
8917 output_protocol_id="ap_1",
8918 protocol_domain="airplay",
8919 priority=10,
8920 ),
8921 ]
8922 )
8923 controller._players = {native_player.player_id: native_player}
8924
8925 protocol_configs = {
8926 "ap_1": {"enabled": False, "player_type": "protocol"},
8927 }
8928
8929 def _config_get(key: str, default: object = None) -> object:
8930 if key.startswith(f"{CONF_PLAYERS}/"):
8931 pid = key.split("/", 1)[1]
8932 return protocol_configs.get(pid, default)
8933 return default
8934
8935 mock_mass.config.get = MagicMock(side_effect=_config_get)
8936 mock_mass.config.save_player_config = MagicMock()
8937 mock_mass.create_task = MagicMock()
8938 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
8939
8940 config = self._make_config(native_player.player_id, enabled=True)
8941
8942 await controller.on_player_config_change(config, {ATTR_ENABLED})
8943
8944 mock_mass.config.save_player_config.assert_called_once_with("ap_1", {ATTR_ENABLED: True})
8945
8946 @pytest.mark.asyncio
8947 async def test_disabling_native_parent_falls_back_to_cached_protocol_ids(
8948 self, mock_mass: MagicMock
8949 ) -> None:
8950 """When the parent isn't registered, cascade uses cached linked_protocol_ids."""
8951 controller = PlayerController(mock_mass)
8952
8953 # Parent is not in _players (e.g. provider didn't register a disabled parent)
8954 controller._players = {}
8955
8956 cached_protocol_ids = ["ap_cached", "dlna_cached"]
8957 protocol_configs = {
8958 "ap_cached": {"enabled": True, "player_type": "protocol"},
8959 "dlna_cached": {"enabled": True, "player_type": "protocol"},
8960 }
8961
8962 def _config_get(key: str, default: object = None) -> object:
8963 if key == f"{CONF_PLAYERS}/unregistered_native/values/linked_protocol_ids":
8964 return list(cached_protocol_ids)
8965 if key.startswith(f"{CONF_PLAYERS}/"):
8966 pid = key.split("/", 1)[1]
8967 return protocol_configs.get(pid, default)
8968 return default
8969
8970 mock_mass.config.get = MagicMock(side_effect=_config_get)
8971 mock_mass.config.save_player_config = MagicMock()
8972 mock_mass.create_task = MagicMock()
8973 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
8974
8975 config = self._make_config("unregistered_native", enabled=False)
8976
8977 await controller.on_player_config_change(config, {ATTR_ENABLED})
8978
8979 called_ids = {call.args[0] for call in mock_mass.config.save_player_config.call_args_list}
8980 assert called_ids == set(cached_protocol_ids)
8981
8982 @pytest.mark.asyncio
8983 async def test_toggling_protocol_player_does_not_cascade(self, mock_mass: MagicMock) -> None:
8984 """A PROTOCOL-type player flipping enabled must not trigger any cascade."""
8985 controller = PlayerController(mock_mass)
8986
8987 ap_provider = MockProvider("airplay", mass=mock_mass)
8988 ap_player = MockPlayer(
8989 ap_provider,
8990 "ap_1",
8991 "AirPlay",
8992 player_type=PlayerType.PROTOCOL,
8993 )
8994 ap_player._attr_available = False
8995 controller._players = {ap_player.player_id: ap_player}
8996
8997 mock_mass.config.save_player_config = MagicMock()
8998 mock_mass.create_task = MagicMock()
8999 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
9000
9001 config = self._make_config(ap_player.player_id, enabled=False, provider="airplay")
9002
9003 await controller.on_player_config_change(config, {ATTR_ENABLED})
9004
9005 mock_mass.config.save_player_config.assert_not_called()
9006
9007
9008class TestDelayedEvalDisabledParentGuard:
9009 """Tests for the guard in _delayed_protocol_evaluation against disabled parents."""
9010
9011 @pytest.mark.asyncio
9012 async def test_skips_universal_creation_when_cached_parent_is_disabled(
9013 self, mock_mass: MagicMock
9014 ) -> None:
9015 """No universal player is created when the protocol's cached parent is disabled."""
9016 controller = PlayerController(mock_mass)
9017
9018 dlna_provider = MockProvider("dlna", mass=mock_mass)
9019 protocol_player = MockPlayer(
9020 dlna_provider,
9021 "dlna_orphan",
9022 "Orphan DLNA",
9023 player_type=PlayerType.PROTOCOL,
9024 )
9025 protocol_player.set_initialized()
9026 controller._players = {protocol_player.player_id: protocol_player}
9027
9028 cached_parent_id = "sonos_disabled"
9029
9030 def _config_get(key: str, default: object = None) -> object:
9031 if key.endswith("/protocol_parent_id"):
9032 return cached_parent_id
9033 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
9034 return {"enabled": False, "provider": "sonos"}
9035 return default
9036
9037 mock_mass.config.get = MagicMock(side_effect=_config_get)
9038
9039 with (
9040 patch.object(controller, "_try_link_to_existing_player", return_value=False),
9041 patch.object(controller, "_find_matching_universal_player", return_value=None),
9042 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
9043 ):
9044 await controller._delayed_protocol_evaluation(protocol_player.player_id)
9045
9046 mock_create_up.assert_not_called()
9047
9048 @pytest.mark.asyncio
9049 async def test_creates_universal_when_cached_parent_is_enabled_but_offline(
9050 self, mock_mass: MagicMock
9051 ) -> None:
9052 """Sanity check: an enabled-but-unregistered cached parent still falls through."""
9053 controller = PlayerController(mock_mass)
9054
9055 dlna_provider = MockProvider("dlna", mass=mock_mass)
9056 protocol_player = MockPlayer(
9057 dlna_provider,
9058 "dlna_orphan",
9059 "Orphan DLNA",
9060 player_type=PlayerType.PROTOCOL,
9061 )
9062 protocol_player.set_initialized()
9063 controller._players = {protocol_player.player_id: protocol_player}
9064
9065 cached_parent_id = "sonos_offline"
9066
9067 def _config_get(key: str, default: object = None) -> object:
9068 if key.endswith("/protocol_parent_id"):
9069 return cached_parent_id
9070 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
9071 return {"enabled": True, "provider": "sonos"}
9072 return default
9073
9074 mock_mass.config.get = MagicMock(side_effect=_config_get)
9075
9076 with (
9077 patch.object(controller, "_try_link_to_existing_player", return_value=False),
9078 patch.object(controller, "_find_matching_universal_player", return_value=None),
9079 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
9080 patch.object(
9081 controller,
9082 "_find_matching_protocol_players",
9083 return_value=[protocol_player],
9084 ),
9085 ):
9086 await controller._delayed_protocol_evaluation(protocol_player.player_id)
9087
9088 mock_create_up.assert_called_once()
9089
9090
9091class TestUniversalPlayerCurrentMedia:
9092 """Tests for current_media resolution while an output protocol is active."""
9093
9094 def test_current_media_surfaces_active_output_protocol(self, mock_mass: MagicMock) -> None:
9095 """
9096 While outputting via a protocol player, surface that player's raw current_media.
9097
9098 A sync group mirrors its leader's raw current_media to resolve the active queue
9099 item. When the leader is a universal player outputting via AirPlay, returning None
9100 here strands the group with a frozen current item (regression guard for grouped
9101 AirPlay live-metadata never refreshing).
9102 """
9103 protocol_provider = MockProvider("airplay", mass=mock_mass)
9104 protocol_player = MockPlayer(
9105 protocol_provider, "ap_1", "AirPlay", player_type=PlayerType.PROTOCOL
9106 )
9107 media = PlayerMedia(
9108 uri="http://x/flow/sess/queue_1/item_1/ap_1.flac", queue_item_id="item_1"
9109 )
9110 protocol_player._attr_current_media = media
9111
9112 universal = _create_universal_player(mock_mass, "up_1", "Universal", ["ap_1"])
9113 players = {"up_1": universal, "ap_1": protocol_player}
9114 mock_mass.players.get_player = MagicMock(side_effect=lambda pid: players.get(pid))
9115
9116 # without an active output protocol there is nothing to surface
9117 assert universal.current_media is None
9118
9119 universal.set_active_output_protocol("ap_1")
9120 assert universal.current_media is media
9121
9122 def test_current_media_none_for_native_output(self, mock_mass: MagicMock) -> None:
9123 """A native output protocol has no separate protocol player to surface."""
9124 universal = _create_universal_player(mock_mass, "up_1", "Universal", [])
9125 universal.set_active_output_protocol("native")
9126 assert universal.current_media is None
9127
9128
9129class TestSelfReferentialProtocolLinks:
9130 """
9131 Tests for the self-link bug when a Sendspin device changes player type.
9132
9133 A Sendspin device keeps a stable client_id (the MA player_id) across reflashes.
9134 When it first registers as a protocol player it gets wrapped in a universal
9135 player that caches that id as one of its protocols. If the device later
9136 reconnects as a non-protocol player (e.g. its roles changed to display-only)
9137 with the same id, the replace logic could link it to itself, hiding it.
9138 """
9139
9140 def test_add_protocol_link_refuses_self(self, mock_mass: MagicMock) -> None:
9141 """A player must never become its own protocol parent."""
9142 controller = PlayerController(mock_mass)
9143 provider = MockProvider("sendspin", mass=mock_mass)
9144 player = MockPlayer(provider, "esp_client", "ESP", player_type=PlayerType.PROTOCOL)
9145 mock_mass.players = controller
9146 controller._players = {"esp_client": player}
9147
9148 controller._add_protocol_link(player, player, "sendspin")
9149
9150 assert player.protocol_parent_id is None
9151 assert all(
9152 link.output_protocol_id != "esp_client" for link in player.linked_output_protocols
9153 )
9154
9155 def test_type_change_replaces_universal_without_self_link(self, mock_mass: MagicMock) -> None:
9156 """Replacing a universal player with the same-id native player must not self-link."""
9157 universal = _create_universal_player(
9158 mock_mass, "upespclient", "ESP", protocol_player_ids=["esp_client"]
9159 )
9160 display_provider = MockProvider("sendspin", mass=mock_mass)
9161 display_player = MockPlayer(
9162 display_provider, "esp_client", "ESP", player_type=PlayerType.DISPLAY
9163 )
9164
9165 store: dict[str, Any] = {"players/esp_client": {"enabled": True}}
9166 mock_mass.config.get.side_effect = lambda key, default=None: store.get(key, default)
9167 mock_mass.config.set.side_effect = lambda key, value: store.__setitem__(key, value)
9168 mock_mass.create_task = MagicMock()
9169 mock_mass.players = controller = PlayerController(mock_mass)
9170 controller._players = {"upespclient": universal, "esp_client": display_player}
9171
9172 controller._check_replace_universal_player(display_player)
9173
9174 assert display_player.protocol_parent_id is None
9175 assert store.get("players/esp_client/values/protocol_parent_id") != "esp_client"
9176 assert "esp_client" not in store.get("players/esp_client/values/linked_protocol_ids", [])
9177 # the obsolete universal player must not keep listing the native player,
9178 # or its permanent cleanup would re-evaluate it as an orphaned protocol
9179 assert "esp_client" not in universal._protocol_player_ids
9180
9181 async def test_migrate_clears_persisted_self_referential_link(self) -> None:
9182 """Config migration scrubs a self-referential link left by an older version."""
9183 config = ConfigController.__new__(ConfigController)
9184 config._data = {
9185 CONF_PLAYERS: {
9186 "esp_client": {
9187 "provider": "sendspin",
9188 "player_id": "esp_client",
9189 "values": {
9190 "protocol_parent_id": "esp_client",
9191 "linked_protocol_ids": ["esp_client"],
9192 },
9193 }
9194 }
9195 }
9196 await migrate(config._data)
9197
9198 values = config._data[CONF_PLAYERS]["esp_client"]["values"]
9199 assert values["protocol_parent_id"] is None
9200 assert "esp_client" not in values["linked_protocol_ids"]
9201
9202
9203class TestDerivedProtocolLinking:
9204 """
9205 Tests for derived protocol players (players with underlying_player_id set).
9206
9207 Derived protocol players (e.g. Sendspin bridges riding on an AirPlay player)
9208 resolve their parent deterministically via the underlying player instead of
9209 device identifier matching, and never seed universal players themselves.
9210 """
9211
9212 @staticmethod
9213 def _make_derived_player(
9214 mock_mass: MagicMock, player_id: str, underlying_player_id: str
9215 ) -> MockPlayer:
9216 """Create a derived protocol player without any device identifiers."""
9217 provider = MockProvider("sendspin", mass=mock_mass)
9218 player = MockPlayer(
9219 provider,
9220 player_id,
9221 f"Bridge {player_id}",
9222 player_type=PlayerType.PROTOCOL,
9223 )
9224 player._attr_underlying_player_id = underlying_player_id
9225 player.set_initialized()
9226 return player
9227
9228 def test_derived_links_to_parent_of_linked_underlying(self, mock_mass: MagicMock) -> None:
9229 """Test a derived player joins the parent of its (linked) underlying player."""
9230 controller = PlayerController(mock_mass)
9231 mock_mass.config.get = MagicMock(return_value=[])
9232
9233 native = MockPlayer(
9234 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
9235 )
9236 native.set_initialized()
9237 ap_player = MockPlayer(
9238 MockProvider("airplay", mass=mock_mass),
9239 "ap_1",
9240 "AVR AirPlay",
9241 player_type=PlayerType.PROTOCOL,
9242 )
9243 ap_player.set_initialized()
9244 controller._players = {"native_1": native, "ap_1": ap_player}
9245 controller._add_protocol_link(native, ap_player, "airplay")
9246
9247 # The derived player has NO identifiers - the edge alone must resolve it
9248 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
9249 controller._players["spb_1"] = derived
9250 controller._try_link_protocol_to_native(derived)
9251
9252 assert derived.protocol_parent_id == "native_1"
9253 # the derived link carries its base output, the non-derived link does not
9254 links = {link.output_protocol_id: link for link in native.linked_output_protocols}
9255 assert links["spb_1"].derived_from == "ap_1"
9256 assert links["ap_1"].derived_from is None
9257
9258 def test_waiting_derived_follows_when_underlying_links(self, mock_mass: MagicMock) -> None:
9259 """Test a derived player that registered early follows once its underlying links."""
9260 controller = PlayerController(mock_mass)
9261 mock_mass.config.get = MagicMock(return_value=[])
9262
9263 native = MockPlayer(
9264 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
9265 )
9266 native.set_initialized()
9267 ap_player = MockPlayer(
9268 MockProvider("airplay", mass=mock_mass),
9269 "ap_1",
9270 "AVR AirPlay",
9271 player_type=PlayerType.PROTOCOL,
9272 )
9273 ap_player.set_initialized()
9274 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
9275 controller._players = {"native_1": native, "ap_1": ap_player, "spb_1": derived}
9276
9277 # Underlying is not linked yet: derived stays unlinked
9278 controller._try_link_protocol_to_native(derived)
9279 assert derived.protocol_parent_id is None
9280
9281 # Linking the underlying player propagates to the waiting derived player
9282 controller._add_protocol_link(native, ap_player, "airplay")
9283 assert derived.protocol_parent_id == "native_1"
9284
9285 def test_derived_links_directly_to_non_protocol_underlying(self, mock_mass: MagicMock) -> None:
9286 """Test a derived player parents to the underlying player itself when not a protocol."""
9287 controller = PlayerController(mock_mass)
9288 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
9289
9290 local_player = MockPlayer(
9291 MockProvider("local_audio", mass=mock_mass),
9292 "local_1",
9293 "Dummy Output",
9294 player_type=PlayerType.PLAYER,
9295 )
9296 local_player.set_initialized()
9297 derived = self._make_derived_player(mock_mass, "spb_1", "local_1")
9298 controller._players = {"spb_1": derived, "local_1": local_player}
9299
9300 # The final derived pass of _try_link_protocols_to_native picks it up
9301 controller._try_link_protocols_to_native(local_player)
9302
9303 assert derived.protocol_parent_id == "local_1"
9304 # riding on the parent player itself normalizes the base output to "native"
9305 links = {link.output_protocol_id: link for link in local_player.linked_output_protocols}
9306 assert links["spb_1"].derived_from == "native"
9307
9308 @pytest.mark.asyncio
9309 async def test_derived_delayed_eval_never_creates_universal_player(
9310 self, mock_mass: MagicMock
9311 ) -> None:
9312 """Test delayed evaluation of a derived player never wraps it in a universal player."""
9313 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
9314 mock_mass.config.get = MagicMock(return_value=[])
9315
9316 derived = self._make_derived_player(mock_mass, "spb_1", "ap_missing")
9317 controller._players = {"spb_1": derived}
9318
9319 await controller._delayed_protocol_evaluation("spb_1")
9320
9321 assert derived.protocol_parent_id is None
9322 assert not any(pid.startswith("up") for pid in controller._players)
9323
9324 def test_derived_link_refused_on_domain_duplicate(self, mock_mass: MagicMock) -> None:
9325 """Test a derived player stays unlinked when the parent already has its domain."""
9326 controller = PlayerController(mock_mass)
9327 mock_mass.config.get = MagicMock(return_value=[])
9328
9329 native = MockPlayer(
9330 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
9331 )
9332 native.set_initialized()
9333 ap_player = MockPlayer(
9334 MockProvider("airplay", mass=mock_mass),
9335 "ap_1",
9336 "AVR AirPlay",
9337 player_type=PlayerType.PROTOCOL,
9338 )
9339 ap_player.set_initialized()
9340 other_sendspin = MockPlayer(
9341 MockProvider("sendspin", mass=mock_mass),
9342 "sendspin_native_client",
9343 "AVR Sendspin",
9344 player_type=PlayerType.PROTOCOL,
9345 )
9346 other_sendspin.set_initialized()
9347 controller._players = {
9348 "native_1": native,
9349 "ap_1": ap_player,
9350 "sendspin_native_client": other_sendspin,
9351 }
9352 controller._add_protocol_link(native, ap_player, "airplay")
9353 controller._add_protocol_link(native, other_sendspin, "sendspin")
9354
9355 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
9356 controller._players["spb_1"] = derived
9357 controller._try_link_protocol_to_native(derived)
9358
9359 assert derived.protocol_parent_id is None
9360
9361 def test_derived_joins_universal_parent(self, mock_mass: MagicMock) -> None:
9362 """Test a derived player joins the universal player of its underlying player."""
9363 controller = PlayerController(mock_mass)
9364 mock_mass.config.get = MagicMock(return_value=[])
9365
9366 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
9367 if hasattr(coro, "close"):
9368 coro.close()
9369
9370 mock_mass.create_task = MagicMock(side_effect=_close_coro)
9371
9372 universal = _create_universal_player(
9373 mock_mass,
9374 "up_aabbccddeeff",
9375 "Living Room Speaker",
9376 protocol_player_ids=["ap_1"],
9377 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9378 )
9379 ap_player = MockPlayer(
9380 MockProvider("airplay", mass=mock_mass),
9381 "ap_1",
9382 "AirPlay Speaker",
9383 player_type=PlayerType.PROTOCOL,
9384 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9385 )
9386 ap_player.set_initialized()
9387 controller._players = {"up_aabbccddeeff": universal, "ap_1": ap_player}
9388 controller._add_protocol_link(universal, ap_player, "airplay")
9389
9390 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
9391 controller._players["spb_1"] = derived
9392 controller._try_link_protocol_to_native(derived)
9393
9394 assert derived.protocol_parent_id == "up_aabbccddeeff"
9395 assert "spb_1" in universal._protocol_player_ids
9396
9397 def test_save_underlying_player_id_persists_and_clears(self, mock_mass: MagicMock) -> None:
9398 """Test the derived edge is persisted, cleared when revoked and skipped otherwise."""
9399 controller = PlayerController(mock_mass)
9400 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
9401 conf_key = "players/spb_1/values/underlying_player_id"
9402 stored: dict[str, Any] = {"players/spb_1": {"values": {}}}
9403 mock_mass.config.get = MagicMock(
9404 side_effect=lambda key, default=None: stored.get(key, default)
9405 )
9406 mock_mass.config.set = MagicMock(side_effect=stored.__setitem__)
9407
9408 # derived player: edge gets persisted
9409 controller._save_underlying_player_id(derived)
9410 assert stored[conf_key] == "ap_1"
9411
9412 # edge revoked (e.g. bridge client turned web player): stale value is cleared
9413 derived._attr_underlying_player_id = None
9414 controller._save_underlying_player_id(derived)
9415 assert stored[conf_key] is None
9416
9417 # nothing persisted and no edge: no write at all
9418 mock_mass.config.set.reset_mock()
9419 del stored[conf_key]
9420 controller._save_underlying_player_id(derived)
9421 mock_mass.config.set.assert_not_called()
9422
9423 # player config gone: never create partial entries
9424 derived._attr_underlying_player_id = "ap_1"
9425 del stored["players/spb_1"]
9426 controller._save_underlying_player_id(derived)
9427 mock_mass.config.set.assert_not_called()
9428
9429
9430class TestLocalAudioPlayerPromotion:
9431 """
9432 Tests for the local_audio reshape: attribution stub -> regular native player.
9433
9434 The local_audio device player (bare device-uuid player_id) is a regular,
9435 visible PLAYER that parents its Sendspin bridge directly. On upgrade, the
9436 previously auto-created universal player wrapper (up<uuid-hex>) must be
9437 absorbed by the native player without re-wrapping it.
9438 """
9439
9440 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
9441 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
9442
9443 def test_native_local_player_absorbs_universal_wrapper(self, mock_mass: MagicMock) -> None:
9444 """The native player takes over the bridge protocol and the wrapper is emptied."""
9445 universal = _create_universal_player(
9446 mock_mass,
9447 self.UNIVERSAL_ID,
9448 "Dummy Output",
9449 protocol_player_ids=[self.DEVICE_UUID, "spb_1"],
9450 )
9451 native = MockPlayer(
9452 MockProvider("local_audio", mass=mock_mass),
9453 self.DEVICE_UUID,
9454 "Dummy Output",
9455 player_type=PlayerType.PLAYER,
9456 identifiers={IdentifierType.UUID: self.DEVICE_UUID},
9457 )
9458 native.set_initialized()
9459 spb = MockPlayer(
9460 MockProvider("sendspin", mass=mock_mass),
9461 "spb_1",
9462 "Dummy Output Sendspin",
9463 player_type=PlayerType.PROTOCOL,
9464 )
9465 spb._attr_underlying_player_id = self.DEVICE_UUID
9466 spb.set_initialized()
9467
9468 store: dict[str, Any] = {
9469 f"players/{self.DEVICE_UUID}": {"enabled": True},
9470 "players/spb_1": {"enabled": True},
9471 }
9472 mock_mass.config.get = MagicMock(
9473 side_effect=lambda key, default=None: store.get(key, default)
9474 )
9475 mock_mass.config.set = MagicMock(side_effect=store.__setitem__)
9476
9477 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
9478 if hasattr(coro, "close"):
9479 coro.close()
9480
9481 mock_mass.create_task = MagicMock(side_effect=_close_coro)
9482 mock_mass.players = controller = PlayerController(mock_mass)
9483 controller._players = {
9484 self.UNIVERSAL_ID: universal,
9485 self.DEVICE_UUID: native,
9486 "spb_1": spb,
9487 }
9488 controller._add_protocol_link(universal, spb, "sendspin")
9489
9490 controller._try_link_protocols_to_native(native)
9491
9492 assert spb.protocol_parent_id == self.DEVICE_UUID
9493 assert [link.output_protocol_id for link in native.linked_output_protocols] == ["spb_1"]
9494 # the obsolete wrapper keeps neither the bridge nor the native player itself,
9495 # so its permanent cleanup has no orphaned protocols to re-evaluate
9496 assert universal._protocol_player_ids == []
9497 assert store.get("players/spb_1/values/protocol_parent_id") == self.DEVICE_UUID
9498
9499 async def test_delayed_eval_skips_type_changed_player(self, mock_mass: MagicMock) -> None:
9500 """A pending evaluation for a player that is not (or no longer) a protocol is a no-op."""
9501 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
9502 mock_mass.config.get = MagicMock(return_value=[])
9503
9504 native = MockPlayer(
9505 MockProvider("local_audio", mass=mock_mass),
9506 self.DEVICE_UUID,
9507 "Dummy Output",
9508 player_type=PlayerType.PLAYER,
9509 )
9510 native.set_initialized()
9511 controller._players = {self.DEVICE_UUID: native}
9512
9513 await controller._delayed_protocol_evaluation(self.DEVICE_UUID)
9514
9515 assert native.protocol_parent_id is None
9516 assert not any(pid.startswith("up") for pid in controller._players)
9517
9518
9519class TestLocalAudioStubMigration:
9520 """Tests for the config migration that promotes local_audio stubs to regular players."""
9521
9522 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
9523 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
9524
9525 def _make_data(self) -> dict[str, Any]:
9526 """Build a settings dict as persisted by the previous local_audio setup."""
9527 return {
9528 CONF_PLAYERS: {
9529 self.DEVICE_UUID: {
9530 "player_id": self.DEVICE_UUID,
9531 "provider": "local_audio",
9532 "player_type": "protocol",
9533 "enabled": True,
9534 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
9535 },
9536 self.UNIVERSAL_ID: {
9537 "player_id": self.UNIVERSAL_ID,
9538 "provider": "universal_player",
9539 "player_type": "group",
9540 "enabled": False,
9541 "name": "Kitchen Output",
9542 "values": {
9543 # ghost_spb has no config anymore (device gone) - must not crash
9544 "linked_protocol_ids": [self.DEVICE_UUID, "spb_1", "ghost_spb"],
9545 "device_identifiers": {"uuid": self.DEVICE_UUID},
9546 "device_info": {"model": "Dummy Output", "manufacturer": "Local Audio"},
9547 "expose_to_ha": False,
9548 "hide_in_ui": True,
9549 },
9550 },
9551 "spb_1": {
9552 "player_id": "spb_1",
9553 "provider": "sendspin",
9554 "player_type": "protocol",
9555 "enabled": True,
9556 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
9557 },
9558 "syncgroup_1": {
9559 "player_id": "syncgroup_1",
9560 "provider": "sync_group",
9561 "player_type": "group",
9562 "enabled": True,
9563 "values": {
9564 "group_members": [self.UNIVERSAL_ID, "other_player"],
9565 "allowed_members": [self.UNIVERSAL_ID],
9566 },
9567 },
9568 },
9569 CONF_PLAYER_QUEUES: {
9570 self.UNIVERSAL_ID: {
9571 "queue_id": self.UNIVERSAL_ID,
9572 "values": {"autoplay_mode": "smart"},
9573 },
9574 },
9575 CONF_PLAYER_DSP: {
9576 self.UNIVERSAL_ID: {"enabled": True},
9577 },
9578 }
9579
9580 async def test_stub_promoted_and_universal_absorbed(self) -> None:
9581 """The stub becomes a regular player carrying the universal player's settings."""
9582 data = self._make_data()
9583
9584 assert await migrate(data) is True
9585
9586 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
9587 assert stub_cfg["player_type"] == "player"
9588 # the universal player was the device toggle the user operated
9589 assert stub_cfg["enabled"] is False
9590 assert stub_cfg["name"] == "Kitchen Output"
9591 values = stub_cfg["values"]
9592 assert "protocol_parent_id" not in values
9593 assert values["expose_to_ha"] is False
9594 assert values["hide_in_ui"] is True
9595 assert values["linked_protocol_ids"] == ["spb_1", "ghost_spb"]
9596 assert "device_identifiers" not in values
9597 assert "device_info" not in values
9598
9599 # the wrapper is gone and the bridge protocol re-parented
9600 assert self.UNIVERSAL_ID not in data[CONF_PLAYERS]
9601 assert data[CONF_PLAYERS]["spb_1"]["values"]["protocol_parent_id"] == self.DEVICE_UUID
9602 assert data[CONF_PLAYERS]["spb_1"]["player_type"] == "protocol"
9603
9604 # queue settings, DSP config and group memberships follow the new player_id
9605 queue_cfg = data[CONF_PLAYER_QUEUES][self.DEVICE_UUID]
9606 assert queue_cfg["queue_id"] == self.DEVICE_UUID
9607 assert queue_cfg["values"]["autoplay_mode"] == "smart"
9608 assert self.UNIVERSAL_ID not in data[CONF_PLAYER_QUEUES]
9609 assert data[CONF_PLAYER_DSP] == {self.DEVICE_UUID: {"enabled": True}}
9610 group_values = data[CONF_PLAYERS]["syncgroup_1"]["values"]
9611 assert group_values["group_members"] == [self.DEVICE_UUID, "other_player"]
9612 assert group_values["allowed_members"] == [self.DEVICE_UUID]
9613
9614 async def test_stub_without_universal_wrapper(self) -> None:
9615 """A stub without a universal player wrapper is still promoted."""
9616 data = self._make_data()
9617 del data[CONF_PLAYERS][self.UNIVERSAL_ID]
9618
9619 assert await migrate(data) is True
9620
9621 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
9622 assert stub_cfg["player_type"] == "player"
9623 assert stub_cfg["enabled"] is True
9624 assert "protocol_parent_id" not in stub_cfg["values"]
9625
9626 async def test_migration_is_idempotent(self) -> None:
9627 """A second run finds nothing left to migrate."""
9628 data = self._make_data()
9629
9630 assert await migrate(data) is True
9631 assert await migrate(data) is False
9632