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