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