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