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