/
/
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 _repoint_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 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 _repoint_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, _ = self._setup_carry_over_scenario(mock_mass, config_store)
7267 group_1 = MockPlayer(MockProvider("player_group", mass=mock_mass), "group_1", "Group")
7268 group_1.set_initialized()
7269 controller._players["group_1"] = group_1
7270
7271 with patch.object(group_1, "refresh_state") as mock_refresh:
7272 controller._check_replace_universal_player(native)
7273
7274 # the wrapper id is replaced by the native id on both membership keys
7275 assert config_store["players/group_1/values/group_members"] == ["cast_1", "other"]
7276 assert config_store["players/group_1/values/allowed_members"] == ["cast_1"]
7277 # no duplicate when the native id is already a member
7278 assert config_store["players/group_2/values/group_members"] == ["cast_1", "extra"]
7279 # a registered player whose membership changed is refreshed
7280 mock_refresh.assert_called()
7281
7282 async def test_replace_stops_playing_universal_before_unregister(
7283 self, mock_mass: MagicMock
7284 ) -> None:
7285 """A playing universal player is stopped before it is permanently removed."""
7286 config_store: dict[str, Any] = {
7287 "players/cast_1": {"enabled": True},
7288 "players/up_old": {
7289 "enabled": True,
7290 "name": "Soundbar (Universal)",
7291 "default_name": "Soundbar (Universal)",
7292 "values": {"linked_protocol_ids": ["ap_live"]},
7293 },
7294 "players/ap_live": {"enabled": True},
7295 }
7296 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7297 mock_mass, config_store
7298 )
7299 controller._players["up_old"]._attr_playback_state = PlaybackState.PLAYING
7300
7301 call_order: list[str] = []
7302 mock_mass.player_queues.stop = AsyncMock(
7303 side_effect=lambda *_a, **_k: call_order.append("stop")
7304 )
7305
7306 controller._check_replace_universal_player(native)
7307 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7308
7309 def _record_unregister(*_a: Any, **_k: Any) -> None:
7310 call_order.append("unregister")
7311
7312 with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
7313 await task
7314
7315 assert call_order == ["stop", "unregister"]
7316 mock_mass.player_queues.stop.assert_awaited_once_with("up_old")
7317
7318 async def test_replace_hands_the_user_filters_to_the_native_player(
7319 self, mock_mass: MagicMock
7320 ) -> None:
7321 """The replaced wrapper hands its access filter entries to the native player."""
7322 config_store: dict[str, Any] = {
7323 "players/cast_1": {"enabled": True},
7324 "players/up_old": {
7325 "enabled": True,
7326 "name": "Soundbar (Universal)",
7327 "default_name": "Soundbar (Universal)",
7328 "values": {"linked_protocol_ids": ["ap_live"]},
7329 },
7330 "players/ap_live": {"enabled": True},
7331 }
7332 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7333 mock_mass, config_store
7334 )
7335
7336 controller._check_replace_universal_player(native)
7337
7338 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7339 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7340 await task
7341
7342 mock_unregister.assert_awaited_once_with(
7343 "up_old", permanent=True, replacement_player_id="cast_1"
7344 )
7345
7346 async def test_replace_idle_universal_not_stopped(self, mock_mass: MagicMock) -> None:
7347 """An idle universal player is removed without a stop command."""
7348 config_store: dict[str, Any] = {
7349 "players/cast_1": {"enabled": True},
7350 "players/up_old": {
7351 "enabled": True,
7352 "name": "Soundbar (Universal)",
7353 "default_name": "Soundbar (Universal)",
7354 "values": {"linked_protocol_ids": ["ap_live"]},
7355 },
7356 "players/ap_live": {"enabled": True},
7357 }
7358 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7359 mock_mass, config_store
7360 )
7361 mock_mass.player_queues.stop = AsyncMock()
7362
7363 controller._check_replace_universal_player(native)
7364 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7365 await task
7366
7367 mock_mass.player_queues.stop.assert_not_awaited()
7368 assert "up_old" not in controller._players
7369
7370 async def test_provider_discovery_rechecks_registered_native_players(
7371 self, mock_mass: MagicMock
7372 ) -> None:
7373 """A native player that registered before its wrapper was restored is promoted."""
7374 controller = PlayerController(mock_mass)
7375 config_store: dict[str, Any] = {
7376 CONF_PLAYERS: {
7377 "sonos_1": {"enabled": True, "values": {}},
7378 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7379 "ap_live": {
7380 "enabled": True,
7381 "provider": "airplay--test",
7382 "player_type": "protocol",
7383 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7384 },
7385 }
7386 }
7387 _wire_nested_config(mock_mass, config_store)
7388 mock_mass.players = controller
7389 mock_mass.player_queues = MagicMock()
7390 mock_mass.create_task = MagicMock()
7391 mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7392
7393 provider = create_mock_universal_provider(mock_mass)
7394 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7395 universal = _create_universal_player(
7396 mock_mass, "up_old", "Old Universal", ["ap_live"], identifiers=identifiers
7397 )
7398 native = MockPlayer(
7399 MockProvider("sonos", mass=mock_mass), "sonos_1", "Sonos", identifiers=identifiers
7400 )
7401 native.set_initialized()
7402 ap_live = MockPlayer(
7403 MockProvider("airplay", mass=mock_mass),
7404 "ap_live",
7405 "AirPlay",
7406 player_type=PlayerType.PROTOCOL,
7407 identifiers=identifiers,
7408 )
7409 ap_live.set_initialized()
7410 controller._players = {
7411 "up_old": universal,
7412 "sonos_1": native,
7413 "ap_live": ap_live,
7414 }
7415 controller._add_protocol_link(universal, ap_live, "airplay")
7416
7417 await provider.discover_players()
7418
7419 assert ap_live.protocol_parent_id == "sonos_1"
7420 assert native.protocol_parent_id is None
7421
7422 async def test_provider_discovery_does_not_replace_wrapper_with_group(
7423 self, mock_mass: MagicMock
7424 ) -> None:
7425 """A matching group player must never take over a wrapper's protocols."""
7426 controller = PlayerController(mock_mass)
7427 config_store: dict[str, Any] = {
7428 CONF_PLAYERS: {
7429 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7430 "ap_owned": {
7431 "enabled": True,
7432 "provider": "airplay--test",
7433 "player_type": "protocol",
7434 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7435 },
7436 }
7437 }
7438 _wire_nested_config(mock_mass, config_store)
7439 mock_mass.players = controller
7440 mock_mass.player_queues = MagicMock()
7441 mock_mass.create_task = MagicMock()
7442 mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7443
7444 provider = create_mock_universal_provider(mock_mass)
7445 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7446 universal = _create_universal_player(
7447 mock_mass, "up_old", "Old Universal", ["ap_owned"], identifiers=identifiers
7448 )
7449 group = MockPlayer(
7450 MockProvider("sync_group", mass=mock_mass),
7451 "group_1",
7452 "Group",
7453 player_type=PlayerType.GROUP,
7454 identifiers=identifiers,
7455 )
7456 group.set_initialized()
7457 ap_owned = MockPlayer(
7458 MockProvider("airplay", mass=mock_mass),
7459 "ap_owned",
7460 "AirPlay",
7461 player_type=PlayerType.PROTOCOL,
7462 identifiers=identifiers,
7463 )
7464 ap_owned.set_initialized()
7465 controller._players = {"up_old": universal, "group_1": group, "ap_owned": ap_owned}
7466 controller._add_protocol_link(universal, ap_owned, "airplay")
7467
7468 await provider.discover_players()
7469
7470 assert ap_owned.protocol_parent_id == "up_old"
7471 assert not group.linked_output_protocols
7472
7473
7474class TestEndToEndDuplicateProtocol:
7475 """
7476 End-to-end integration tests for duplicate protocol → separate universal player flow.
7477
7478 These tests exercise the full async flow through _delayed_protocol_evaluation,
7479 ensure_universal_players_for_protocols, and _create_separate_universal_player,
7480 verifying that a second instance of the same protocol domain on the same host
7481 results in a separate universal player.
7482 """
7483
7484 @staticmethod
7485 def _setup_e2e_controller(
7486 mock_mass: MagicMock,
7487 ) -> tuple[PlayerController, UniversalPlayerProvider]:
7488 """
7489 Set up a PlayerController and UniversalPlayerProvider wired for E2E testing.
7490
7491 The mock_mass is configured so that:
7492 - get_providers(ProviderType.PLAYER) returns the universal provider
7493 - register_or_update adds the player to the controller's _players dict
7494 - config.create_default_player_config does not fail
7495 - config.set does not fail
7496
7497 :return: Tuple of (controller, universal_provider).
7498 """
7499 controller = PlayerController(mock_mass)
7500 up_provider = create_mock_universal_provider(mock_mass)
7501
7502 # Wire get_providers to return our universal provider
7503 mock_mass.get_providers = MagicMock(return_value=[up_provider])
7504
7505 # Wire register_or_update to add the player to controller._players
7506 async def fake_register_or_update(player: Player) -> None:
7507 controller._players[player.player_id] = player
7508 player.set_initialized()
7509
7510 mock_mass.players = MagicMock()
7511 mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
7512 mock_mass.players.get_player = lambda pid: controller._players.get(pid)
7513
7514 # Wire config methods
7515 mock_mass.config.create_default_player_config = MagicMock()
7516 mock_mass.config.set = MagicMock()
7517 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
7518
7519 return controller, up_provider
7520
7521 @pytest.mark.asyncio
7522 async def test_path_a_delayed_eval_creates_separate_universal_for_duplicate(
7523 self, mock_mass: MagicMock
7524 ) -> None:
7525 """
7526 Path A: _delayed_protocol_evaluation creates separate universal player for duplicate.
7527
7528 Scenario: Two AirPlay instances register on the same host.
7529 1. First AirPlay links to existing universal player
7530 via _add_protocol_to_existing_universal
7531 2. Second AirPlay arrives at _delayed_protocol_evaluation
7532 3. It finds the existing universal player but is refused (domain duplicate)
7533 4. Falls through to _find_matching_protocol_players (which skips same-domain)
7534 5. Creates a NEW separate universal player via _create_or_update_universal_player
7535 """
7536 controller, _ = self._setup_e2e_controller(mock_mass)
7537
7538 # Create existing universal player with AirPlay already linked
7539 universal = _create_universal_player(
7540 mock_mass,
7541 "up_aabbccddeeff",
7542 "Living Room Speaker",
7543 protocol_player_ids=["ap_1"],
7544 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7545 )
7546
7547 # Create first AirPlay (already linked to universal)
7548 ap_provider = MockProvider("airplay", mass=mock_mass)
7549 ap1 = MockPlayer(
7550 ap_provider,
7551 "ap_1",
7552 "AirPlay Instance 1",
7553 player_type=PlayerType.PROTOCOL,
7554 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7555 )
7556 ap1.set_initialized()
7557 controller._add_protocol_link(universal, ap1, "airplay")
7558 assert ap1.protocol_parent_id == "up_aabbccddeeff"
7559
7560 # Create second AirPlay (same MAC, not yet linked)
7561 ap2 = MockPlayer(
7562 ap_provider,
7563 "ap_2",
7564 "AirPlay Instance 2",
7565 player_type=PlayerType.PROTOCOL,
7566 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7567 )
7568 ap2.set_initialized()
7569
7570 controller._players = {
7571 "up_aabbccddeeff": universal,
7572 "ap_1": ap1,
7573 "ap_2": ap2,
7574 }
7575
7576 # Run delayed evaluation for ap2 - full async flow
7577 await controller._delayed_protocol_evaluation("ap_2")
7578
7579 # ap2 should NOT be linked to the existing universal player
7580 assert ap2.protocol_parent_id != "up_aabbccddeeff"
7581
7582 # ap2 should have its own (freshly minted) universal player
7583 assert ap2.protocol_parent_id is not None
7584 separate_up_id = ap2.protocol_parent_id
7585 assert separate_up_id in controller._players
7586 separate_up = controller._players[separate_up_id]
7587 assert isinstance(separate_up, UniversalPlayer)
7588
7589 # The original universal player should still have only ap_1
7590 original_ap_links = [
7591 link for link in universal.linked_output_protocols if link.protocol_domain == "airplay"
7592 ]
7593 assert len(original_ap_links) == 1
7594 assert original_ap_links[0].output_protocol_id == "ap_1"
7595
7596 @pytest.mark.asyncio
7597 async def test_path_a_delayed_eval_joins_existing_universal_different_domain(
7598 self, mock_mass: MagicMock
7599 ) -> None:
7600 """
7601 Path A: _delayed_protocol_evaluation correctly joins when domain is different.
7602
7603 Scenario: AirPlay universal player exists, DLNA arrives via delayed eval.
7604 DLNA should successfully join the existing universal player (different domain).
7605 """
7606 controller, _ = self._setup_e2e_controller(mock_mass)
7607
7608 # Create existing universal player with AirPlay linked
7609 universal = _create_universal_player(
7610 mock_mass,
7611 "up_aabbccddeeff",
7612 "Living Room Speaker",
7613 protocol_player_ids=["ap_1"],
7614 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7615 )
7616
7617 ap_provider = MockProvider("airplay", mass=mock_mass)
7618 ap1 = MockPlayer(
7619 ap_provider,
7620 "ap_1",
7621 "AirPlay",
7622 player_type=PlayerType.PROTOCOL,
7623 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7624 )
7625 ap1.set_initialized()
7626 controller._add_protocol_link(universal, ap1, "airplay")
7627
7628 # Create DLNA player (same device, different protocol)
7629 dlna_provider = MockProvider("dlna", mass=mock_mass)
7630 dlna1 = MockPlayer(
7631 dlna_provider,
7632 "dlna_1",
7633 "DLNA",
7634 player_type=PlayerType.PROTOCOL,
7635 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7636 )
7637 dlna1.set_initialized()
7638
7639 controller._players = {
7640 "up_aabbccddeeff": universal,
7641 "ap_1": ap1,
7642 "dlna_1": dlna1,
7643 }
7644
7645 # Run delayed evaluation for DLNA
7646 await controller._delayed_protocol_evaluation("dlna_1")
7647
7648 # DLNA should join the existing universal player
7649 assert dlna1.protocol_parent_id == "up_aabbccddeeff"
7650
7651 # Universal player should now have both AirPlay and DLNA
7652 domains = {link.protocol_domain for link in universal.linked_output_protocols}
7653 assert "airplay" in domains
7654 assert "dlna" in domains
7655
7656 @pytest.mark.asyncio
7657 async def test_path_b_ensure_universal_separates_duplicate_domain(
7658 self, mock_mass: MagicMock
7659 ) -> None:
7660 """
7661 Path B: ensure_universal_players_for_protocols separates can_join vs rejected.
7662
7663 Scenario: Two AirPlay players arrive simultaneously for delayed eval.
7664 Both are unlinked, both pass to _create_or_update_universal_player.
7665 The first creates a universal player. The second finds the existing one
7666 and is rejected (duplicate domain), getting its own separate universal player.
7667 """
7668 controller, _ = self._setup_e2e_controller(mock_mass)
7669
7670 ap_provider = MockProvider("airplay", mass=mock_mass)
7671 ap1 = MockPlayer(
7672 ap_provider,
7673 "ap_1",
7674 "AirPlay Instance 1",
7675 player_type=PlayerType.PROTOCOL,
7676 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7677 )
7678 ap1.set_initialized()
7679
7680 ap2 = MockPlayer(
7681 ap_provider,
7682 "ap_2",
7683 "AirPlay Instance 2",
7684 player_type=PlayerType.PROTOCOL,
7685 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7686 )
7687 ap2.set_initialized()
7688
7689 controller._players = {
7690 "ap_1": ap1,
7691 "ap_2": ap2,
7692 }
7693
7694 # First: ap1 goes through delayed evaluation, creates a universal player
7695 await controller._delayed_protocol_evaluation("ap_1")
7696
7697 # ap1 should have a universal player parent
7698 assert ap1.protocol_parent_id is not None
7699 first_up_id = ap1.protocol_parent_id
7700 assert first_up_id in controller._players
7701
7702 # Second: ap2 goes through delayed evaluation
7703 # _find_matching_universal_player will find the universal player by MAC
7704 # _add_protocol_to_existing_universal will refuse (duplicate domain)
7705 # _find_matching_protocol_players will skip ap1 (same domain)
7706 # _create_or_update_universal_player → ensure_universal_players_for_protocols
7707 # finds existing universal player, separates ap2 as rejected
7708 await controller._delayed_protocol_evaluation("ap_2")
7709
7710 # ap2 should have its own separate universal player
7711 assert ap2.protocol_parent_id is not None
7712 second_up_id = ap2.protocol_parent_id
7713 assert second_up_id != first_up_id
7714 assert second_up_id in controller._players
7715
7716 # Both universal players should exist
7717 first_up = controller._players[first_up_id]
7718 second_up = controller._players[second_up_id]
7719 assert isinstance(first_up, UniversalPlayer)
7720 assert isinstance(second_up, UniversalPlayer)
7721
7722 @pytest.mark.asyncio
7723 async def test_path_b_first_creates_then_second_gets_separate(
7724 self, mock_mass: MagicMock
7725 ) -> None:
7726 """
7727 Path B: First protocol player creates universal, second gets separate.
7728
7729 Scenario: AirPlay and DLNA create a universal player together.
7730 Then a second AirPlay arrives and gets its own separate universal player.
7731 """
7732 controller, _ = self._setup_e2e_controller(mock_mass)
7733
7734 ap_provider = MockProvider("airplay", mass=mock_mass)
7735 dlna_provider = MockProvider("dlna", mass=mock_mass)
7736
7737 ap1 = MockPlayer(
7738 ap_provider,
7739 "ap_1",
7740 "AirPlay",
7741 player_type=PlayerType.PROTOCOL,
7742 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7743 )
7744 ap1.set_initialized()
7745
7746 dlna1 = MockPlayer(
7747 dlna_provider,
7748 "dlna_1",
7749 "DLNA",
7750 player_type=PlayerType.PROTOCOL,
7751 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7752 )
7753 dlna1.set_initialized()
7754
7755 ap2 = MockPlayer(
7756 ap_provider,
7757 "ap_2",
7758 "AirPlay 2",
7759 player_type=PlayerType.PROTOCOL,
7760 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7761 )
7762 ap2.set_initialized()
7763
7764 controller._players = {
7765 "ap_1": ap1,
7766 "dlna_1": dlna1,
7767 "ap_2": ap2,
7768 }
7769
7770 # First: ap1 arrives at delayed eval, finds dlna1 as matching (different domain)
7771 # Together they create a universal player
7772 await controller._delayed_protocol_evaluation("ap_1")
7773
7774 assert ap1.protocol_parent_id is not None
7775 # dlna1 should also be linked (found by _find_matching_protocol_players)
7776 assert dlna1.protocol_parent_id == ap1.protocol_parent_id
7777 shared_up_id = ap1.protocol_parent_id
7778
7779 # Second: ap2 arrives at delayed eval
7780 await controller._delayed_protocol_evaluation("ap_2")
7781
7782 # ap2 should get its own universal player
7783 assert ap2.protocol_parent_id is not None
7784 assert ap2.protocol_parent_id != shared_up_id
7785 assert ap2.protocol_parent_id in controller._players
7786
7787 # Shared universal player should have AirPlay + DLNA
7788 shared_up = controller._players[shared_up_id]
7789 shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
7790 assert "airplay" in shared_domains
7791 assert "dlna" in shared_domains
7792
7793 @pytest.mark.asyncio
7794 async def test_path_c_sendspin_duplicate_gets_separate_universal(
7795 self, mock_mass: MagicMock
7796 ) -> None:
7797 """
7798 Path C: Two Sendspin bridge instances on same host get separate universal players.
7799
7800 Sendspin bridges match via CAST_UUID / AIRPLAY_ID, not MAC. Two bridges
7801 on the same host share the same MAC (from ARP) but have different CAST_UUIDs.
7802 Each should get its own universal player.
7803 """
7804 controller, _ = self._setup_e2e_controller(mock_mass)
7805
7806 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7807
7808 # First Sendspin bridge
7809 spb1 = MockPlayer(
7810 sendspin_provider,
7811 "spb_1",
7812 "Sendspin Bridge 1",
7813 player_type=PlayerType.PROTOCOL,
7814 identifiers={
7815 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
7816 IdentifierType.CAST_UUID: "cast-uuid-111",
7817 },
7818 )
7819 spb1.set_initialized()
7820
7821 # Second Sendspin bridge (same MAC, different CAST_UUID)
7822 spb2 = MockPlayer(
7823 sendspin_provider,
7824 "spb_2",
7825 "Sendspin Bridge 2",
7826 player_type=PlayerType.PROTOCOL,
7827 identifiers={
7828 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
7829 IdentifierType.CAST_UUID: "cast-uuid-222",
7830 },
7831 )
7832 spb2.set_initialized()
7833
7834 controller._players = {
7835 "spb_1": spb1,
7836 "spb_2": spb2,
7837 }
7838
7839 # First: spb1 goes through delayed evaluation
7840 await controller._delayed_protocol_evaluation("spb_1")
7841
7842 assert spb1.protocol_parent_id is not None
7843 first_up_id = spb1.protocol_parent_id
7844
7845 # Second: spb2 goes through delayed evaluation
7846 # _find_matching_protocol_players skips spb1 (same domain: "sendspin")
7847 # _find_matching_universal_player may find the first universal player by MAC
7848 # _add_protocol_to_existing_universal refuses (duplicate domain: "sendspin")
7849 # Falls through to create separate universal player
7850 await controller._delayed_protocol_evaluation("spb_2")
7851
7852 assert spb2.protocol_parent_id is not None
7853 second_up_id = spb2.protocol_parent_id
7854
7855 # Each should have its own universal player
7856 assert first_up_id != second_up_id
7857 assert first_up_id in controller._players
7858 assert second_up_id in controller._players
7859
7860 first_up = controller._players[first_up_id]
7861 second_up = controller._players[second_up_id]
7862 assert isinstance(first_up, UniversalPlayer)
7863 assert isinstance(second_up, UniversalPlayer)
7864
7865 @pytest.mark.asyncio
7866 async def test_three_airplay_instances_get_separate_universals(
7867 self, mock_mass: MagicMock
7868 ) -> None:
7869 """
7870 Test that three AirPlay instances each get their own universal player.
7871
7872 Exercises the flow when there are 3+ instances of the same protocol.
7873 """
7874 controller, _ = self._setup_e2e_controller(mock_mass)
7875
7876 ap_provider = MockProvider("airplay", mass=mock_mass)
7877 players = []
7878 for i in range(1, 4):
7879 p = MockPlayer(
7880 ap_provider,
7881 f"ap_{i}",
7882 f"AirPlay Instance {i}",
7883 player_type=PlayerType.PROTOCOL,
7884 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7885 )
7886 p.set_initialized()
7887 players.append(p)
7888
7889 controller._players = {p.player_id: p for p in players}
7890
7891 # Run delayed evaluation for each in sequence
7892 for p in players:
7893 await controller._delayed_protocol_evaluation(p.player_id)
7894
7895 # Each should have its own universal player
7896 parent_ids = {p.protocol_parent_id for p in players}
7897 assert None not in parent_ids
7898 # All three should have distinct parents
7899 assert len(parent_ids) == 3
7900
7901 # All three universal players should exist
7902 for pid in parent_ids:
7903 assert pid in controller._players
7904 assert isinstance(controller._players[pid], UniversalPlayer)
7905
7906 @pytest.mark.asyncio
7907 async def test_same_domain_same_ip_get_separate_universals(self, mock_mass: MagicMock) -> None:
7908 """
7909 Test that multiple instances of the same protocol on the same host stay separate.
7910
7911 Scenario: 3 shairport-sync AirPlay instances on the same Raspberry Pi.
7912 All share the same IP and have locally-administered MACs.
7913 The IP fallback must NOT merge them — domain dedup takes precedence.
7914 """
7915 controller, _ = self._setup_e2e_controller(mock_mass)
7916
7917 ap_provider = MockProvider("airplay", mass=mock_mass)
7918 players = []
7919 for i in range(1, 4):
7920 p = MockPlayer(
7921 ap_provider,
7922 f"ap_{i}",
7923 f"Shairport Zone {i}",
7924 player_type=PlayerType.PROTOCOL,
7925 identifiers={
7926 # Each has a distinct LA MAC (as shairport-sync generates)
7927 IdentifierType.MAC_ADDRESS: f"02:AA:BB:CC:DD:{i:02X}",
7928 IdentifierType.IP_ADDRESS: "192.168.1.10",
7929 },
7930 )
7931 p.set_initialized()
7932 players.append(p)
7933
7934 controller._players = {p.player_id: p for p in players}
7935
7936 for p in players:
7937 await controller._delayed_protocol_evaluation(p.player_id)
7938
7939 # Each should have its own universal player (NOT merged)
7940 parent_ids = {p.protocol_parent_id for p in players}
7941 assert None not in parent_ids
7942 assert len(parent_ids) == 3
7943
7944 @pytest.mark.asyncio
7945 async def test_same_domain_same_mac_get_separate_universals(self, mock_mass: MagicMock) -> None:
7946 """
7947 Test that two snapcast instances with the same ARP-resolved MAC stay separate.
7948
7949 Scenario: Two snapcast clients on the same Mac mini. Both have distinct
7950 locally-administered MACs but ARP resolves both to the same real hardware MAC.
7951 They should each get their own universal player, not be merged.
7952 """
7953 controller, _ = self._setup_e2e_controller(mock_mass)
7954
7955 snap_provider = MockProvider("snapcast", mass=mock_mass)
7956 snap1 = MockPlayer(
7957 snap_provider,
7958 "ma_cea0b4ed2221",
7959 "Mac-mini-15269.local",
7960 player_type=PlayerType.PROTOCOL,
7961 identifiers={
7962 # ARP-enriched: both resolve to same real MAC
7963 IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
7964 IdentifierType.IP_ADDRESS: "192.168.1.42",
7965 },
7966 )
7967 snap2 = MockPlayer(
7968 snap_provider,
7969 "ma_cea0b4ed2220",
7970 "Mac-mini-63197.local",
7971 player_type=PlayerType.PROTOCOL,
7972 identifiers={
7973 IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
7974 IdentifierType.IP_ADDRESS: "192.168.1.42",
7975 },
7976 )
7977 snap1._attr_available = False
7978 snap1._cache.clear()
7979 snap1.update_state(signal_event=False)
7980 snap2._attr_available = False
7981 snap2._cache.clear()
7982 snap2.update_state(signal_event=False)
7983 snap1.set_initialized()
7984 snap2.set_initialized()
7985
7986 controller._players = {"ma_cea0b4ed2221": snap1, "ma_cea0b4ed2220": snap2}
7987
7988 await controller._delayed_protocol_evaluation("ma_cea0b4ed2221")
7989 await controller._delayed_protocol_evaluation("ma_cea0b4ed2220")
7990
7991 # Each should have its own universal player
7992 assert snap1.protocol_parent_id is not None
7993 assert snap2.protocol_parent_id is not None
7994 assert snap1.protocol_parent_id != snap2.protocol_parent_id
7995
7996 @pytest.mark.asyncio
7997 async def test_mixed_protocols_plus_duplicate_creates_correct_topology(
7998 self, mock_mass: MagicMock
7999 ) -> None:
8000 """
8001 Test that mixed protocols + duplicate create correct player topology.
8002
8003 Scenario:
8004 - Device has AirPlay, DLNA, and Chromecast protocols
8005 - Plus a second AirPlay instance (duplicate)
8006 Result:
8007 - One universal player with AirPlay + DLNA + Chromecast
8008 - One separate universal player for the second AirPlay
8009 """
8010 controller, _ = self._setup_e2e_controller(mock_mass)
8011
8012 ap_provider = MockProvider("airplay", mass=mock_mass)
8013 dlna_provider = MockProvider("dlna", mass=mock_mass)
8014 cc_provider = MockProvider("chromecast", mass=mock_mass)
8015
8016 ap1 = MockPlayer(
8017 ap_provider,
8018 "ap_1",
8019 "AirPlay",
8020 player_type=PlayerType.PROTOCOL,
8021 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8022 )
8023 ap1.set_initialized()
8024
8025 dlna1 = MockPlayer(
8026 dlna_provider,
8027 "dlna_1",
8028 "DLNA",
8029 player_type=PlayerType.PROTOCOL,
8030 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8031 )
8032 dlna1.set_initialized()
8033
8034 cc1 = MockPlayer(
8035 cc_provider,
8036 "cc_1",
8037 "Chromecast",
8038 player_type=PlayerType.PROTOCOL,
8039 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8040 )
8041 cc1.set_initialized()
8042
8043 ap2 = MockPlayer(
8044 ap_provider,
8045 "ap_2",
8046 "AirPlay Duplicate",
8047 player_type=PlayerType.PROTOCOL,
8048 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8049 )
8050 ap2.set_initialized()
8051
8052 controller._players = {
8053 "ap_1": ap1,
8054 "dlna_1": dlna1,
8055 "cc_1": cc1,
8056 "ap_2": ap2,
8057 }
8058
8059 # ap1 goes through delayed eval - finds dlna1 and cc1 as matching
8060 # (different domains), creates universal player with all three
8061 await controller._delayed_protocol_evaluation("ap_1")
8062
8063 assert ap1.protocol_parent_id is not None
8064 assert dlna1.protocol_parent_id == ap1.protocol_parent_id
8065 assert cc1.protocol_parent_id == ap1.protocol_parent_id
8066 shared_up_id = ap1.protocol_parent_id
8067
8068 # ap2 goes through delayed eval - finds universal player but is rejected
8069 await controller._delayed_protocol_evaluation("ap_2")
8070
8071 assert ap2.protocol_parent_id is not None
8072 assert ap2.protocol_parent_id != shared_up_id
8073
8074 # Shared universal player should have 3 protocols
8075 shared_up = controller._players[shared_up_id]
8076 shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8077 assert shared_domains == {"airplay", "dlna", "chromecast"}
8078
8079
8080class TestEndToEndUniversalPlayerId:
8081 """
8082 End-to-end tests for the minted player id of a universal player.
8083
8084 Regression tests for music-assistant/support#5888: the id used to be derived from
8085 whichever device identifiers happened to be available when the wrapper was created,
8086 so a re-created wrapper could silently change id and orphan the entities API
8087 consumers (such as the Home Assistant integration) bind to it. These tests drive
8088 the real evaluation path against a config store that persists what the flow writes.
8089 """
8090
8091 @staticmethod
8092 def _setup_controller(
8093 mock_mass: MagicMock,
8094 ) -> tuple[PlayerController, dict[str, Any]]:
8095 """
8096 Wire a controller, the universal player provider and a persisting config store.
8097
8098 :param mock_mass: The mocked MusicAssistant instance to wire.
8099 :return: Tuple of (controller, backing config store).
8100 """
8101 controller = PlayerController(mock_mass)
8102 up_provider = create_mock_universal_provider(mock_mass)
8103 mock_mass.get_providers = MagicMock(return_value=[up_provider])
8104
8105 store: dict[str, Any] = {CONF_PLAYERS: {}}
8106 _wire_nested_config(mock_mass, store)
8107 mock_mass.config.create_default_player_config = MagicMock(
8108 side_effect=lambda *args, **kwargs: _fake_create_default_player_config(
8109 store, *args, **kwargs
8110 )
8111 )
8112 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
8113
8114 async def fake_register_or_update(player: Player) -> None:
8115 controller._players[player.player_id] = player
8116 player.set_initialized()
8117
8118 mock_mass.players = MagicMock()
8119 mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
8120 mock_mass.players.get_player = lambda pid, *_args, **_kwargs: controller._players.get(pid)
8121 return controller, store
8122
8123 @staticmethod
8124 def _make_airplay_player(
8125 mock_mass: MagicMock, player_id: str, name: str, identifiers: dict[IdentifierType, str]
8126 ) -> MockPlayer:
8127 """Return an unlinked AirPlay protocol player."""
8128 player = MockPlayer(
8129 MockProvider("airplay", mass=mock_mass),
8130 player_id,
8131 name,
8132 player_type=PlayerType.PROTOCOL,
8133 identifiers=identifiers,
8134 )
8135 player.set_initialized()
8136 return player
8137
8138 async def test_id_survives_recreation_with_other_identifiers(
8139 self, mock_mass: MagicMock
8140 ) -> None:
8141 """
8142 A re-created wrapper keeps its id when a different set of identifiers is known.
8143
8144 The first evaluation only knows the device's MAC, the second only its UUID.
8145 The derived key changed along with that, taking the player id with it.
8146 """
8147 controller, store = self._setup_controller(mock_mass)
8148 first = self._make_airplay_player(
8149 mock_mass, "ap_1", "Living Room", {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8150 )
8151 controller._players = {"ap_1": first}
8152
8153 await controller._delayed_protocol_evaluation("ap_1")
8154
8155 universal_id = first.protocol_parent_id
8156 assert universal_id is not None
8157 assert re.fullmatch(r"up[0-9a-f]{8}", universal_id)
8158
8159 # tear the wrapper down (its config is kept, as a non-permanent unregister does)
8160 # and bring the device back with only a UUID to identify it by
8161 del controller._players[universal_id]
8162 second = self._make_airplay_player(
8163 mock_mass,
8164 "ap_1",
8165 "Living Room",
8166 {IdentifierType.UUID: "12345678-1234-1234-1234-123456789abc"},
8167 )
8168 controller._players = {"ap_1": second}
8169
8170 await controller._delayed_protocol_evaluation("ap_1")
8171
8172 assert second.protocol_parent_id == universal_id
8173 assert universal_id in controller._players
8174 assert store[CONF_PLAYERS]["ap_1"]["values"][CONF_PROTOCOL_PARENT_ID] == universal_id
8175
8176 async def test_wrappers_created_in_one_second_still_order(self, mock_mass: MagicMock) -> None:
8177 """
8178 Two wrappers created back to back get distinct creation timestamps.
8179
8180 A merge between them must be able to keep the older one, which it cannot do
8181 when both carry the same timestamp and the tiebreak falls back to the id.
8182 """
8183 controller, store = self._setup_controller(mock_mass)
8184 players = [
8185 self._make_airplay_player(
8186 mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8187 )
8188 for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8189 ]
8190 controller._players = {player.player_id: player for player in players}
8191
8192 for player in players:
8193 await controller._delayed_protocol_evaluation(player.player_id)
8194
8195 created = [
8196 store[CONF_PLAYERS][player.protocol_parent_id]["values"][CONF_CREATED_AT]
8197 for player in players
8198 ]
8199 assert all(isinstance(value, int) for value in created)
8200 assert created[0] < created[1]
8201
8202 async def test_new_devices_get_distinct_minted_ids(self, mock_mass: MagicMock) -> None:
8203 """Devices that were never wrapped before each get their own minted id."""
8204 controller, _ = self._setup_controller(mock_mass)
8205 players = [
8206 self._make_airplay_player(
8207 mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8208 )
8209 for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8210 ]
8211 controller._players = {player.player_id: player for player in players}
8212
8213 for player in players:
8214 await controller._delayed_protocol_evaluation(player.player_id)
8215
8216 universal_ids = [player.protocol_parent_id for player in players]
8217 assert all(pid and re.fullmatch(r"up[0-9a-f]{8}", pid) for pid in universal_ids)
8218 assert len(set(universal_ids)) == len(players)
8219
8220 async def test_duplicate_domain_gets_a_second_wrapper_with_a_parent_link(
8221 self, mock_mass: MagicMock
8222 ) -> None:
8223 """
8224 Two players of one protocol domain end up on two wrappers, both linked.
8225
8226 The handoff must persist a parent link for the second player as well,
8227 otherwise it silently stays unwrapped.
8228 """
8229 controller, store = self._setup_controller(mock_mass)
8230 identifiers = {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8231 ap1 = self._make_airplay_player(mock_mass, "ap_1", "Zone 1", identifiers)
8232 ap2 = self._make_airplay_player(mock_mass, "ap_2", "Zone 2", identifiers)
8233 controller._players = {"ap_1": ap1, "ap_2": ap2}
8234
8235 await controller._create_or_update_universal_player([ap1, ap2])
8236
8237 assert ap1.protocol_parent_id is not None
8238 assert ap2.protocol_parent_id is not None
8239 assert ap1.protocol_parent_id != ap2.protocol_parent_id
8240 for player in (ap1, ap2):
8241 stored = store[CONF_PLAYERS][player.player_id]["values"][CONF_PROTOCOL_PARENT_ID]
8242 assert stored == player.protocol_parent_id
8243
8244
8245class TestSiblingProtocolMatching:
8246 """
8247 Tests for sibling protocol matching via _match_via_linked_protocols.
8248
8249 When a native player (e.g., HEOS) doesn't have its own MAC/serial identifiers
8250 but has protocol players (e.g., AirPlay) already linked, a new protocol player
8251 (e.g., Sendspin bridge) should be matched to the native player through the
8252 shared identifiers of sibling protocols.
8253 """
8254
8255 def test_sendspin_matches_heos_via_airplay_sibling(self, mock_mass: MagicMock) -> None:
8256 """
8257 Test Sendspin links to HEOS through AirPlay's shared AIRPLAY_ID.
8258
8259 HEOS has no MAC/UUID identifiers. AirPlay is already linked to HEOS.
8260 Sendspin has AIRPLAY_ID matching AirPlay. Sibling matching should link
8261 Sendspin to HEOS.
8262 """
8263 controller = PlayerController(mock_mass)
8264
8265 # HEOS native player - no MAC, no serial, no UUID (just IP from discovery)
8266 heos_provider = MockProvider("heos", mass=mock_mass)
8267 heos_player = MockPlayer(
8268 heos_provider,
8269 "2140037800",
8270 "Denon AVR-X2700H",
8271 player_type=PlayerType.PLAYER,
8272 )
8273 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8274 heos_player._cache.clear()
8275 heos_player.set_initialized()
8276
8277 # AirPlay protocol player already linked to HEOS
8278 ap_provider = MockProvider("airplay", mass=mock_mass)
8279 ap_player = MockPlayer(
8280 ap_provider,
8281 "ap000678747b16",
8282 "Denon AVR-X2700H (AirPlay)",
8283 player_type=PlayerType.PROTOCOL,
8284 identifiers={
8285 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8286 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8287 },
8288 )
8289 ap_player.set_initialized()
8290
8291 # Link AirPlay to HEOS
8292 controller._players = {
8293 "2140037800": heos_player,
8294 "ap000678747b16": ap_player,
8295 }
8296 controller._add_protocol_link(heos_player, ap_player, "airplay")
8297 assert ap_player.protocol_parent_id == "2140037800"
8298
8299 # Sendspin bridge with AIRPLAY_ID matching AirPlay
8300 spb_provider = MockProvider("sendspin", mass=mock_mass)
8301 spb_player = MockPlayer(
8302 spb_provider,
8303 "spb_000678747b16",
8304 "Denon AVR-X2700H (Sendspin)",
8305 player_type=PlayerType.PROTOCOL,
8306 identifiers={
8307 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8308 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8309 },
8310 )
8311 spb_player.set_initialized()
8312 controller._players["spb_000678747b16"] = spb_player
8313
8314 # Direct identifier match would fail (HEOS has no identifiers)
8315 assert controller._identifiers_match(heos_player, spb_player, "sendspin") is False
8316
8317 # Sibling matching should succeed through AirPlay's identifiers
8318 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is True
8319 assert spb_player.protocol_parent_id == "2140037800"
8320
8321 def test_sibling_match_no_linked_protocols(self, mock_mass: MagicMock) -> None:
8322 """Test sibling matching returns False when native player has no linked protocols."""
8323 controller = PlayerController(mock_mass)
8324
8325 heos_provider = MockProvider("heos", mass=mock_mass)
8326 heos_player = MockPlayer(
8327 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
8328 )
8329 heos_player.set_initialized()
8330
8331 spb_provider = MockProvider("sendspin", mass=mock_mass)
8332 spb_player = MockPlayer(
8333 spb_provider,
8334 "spb_1",
8335 "Sendspin",
8336 player_type=PlayerType.PROTOCOL,
8337 identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
8338 )
8339 spb_player.set_initialized()
8340
8341 controller._players = {"heos_1": heos_player, "spb_1": spb_player}
8342
8343 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
8344
8345 def test_sibling_match_no_identifier_overlap(self, mock_mass: MagicMock) -> None:
8346 """Test sibling matching returns False when identifiers don't overlap."""
8347 controller = PlayerController(mock_mass)
8348
8349 heos_provider = MockProvider("heos", mass=mock_mass)
8350 heos_player = MockPlayer(
8351 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
8352 )
8353 heos_player.set_initialized()
8354
8355 # AirPlay with one MAC
8356 ap_provider = MockProvider("airplay", mass=mock_mass)
8357 ap_player = MockPlayer(
8358 ap_provider,
8359 "ap_1",
8360 "AirPlay",
8361 player_type=PlayerType.PROTOCOL,
8362 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8363 )
8364 ap_player.set_initialized()
8365
8366 controller._players = {"heos_1": heos_player, "ap_1": ap_player}
8367 controller._add_protocol_link(heos_player, ap_player, "airplay")
8368
8369 # Sendspin with different MAC, no shared identifiers
8370 spb_provider = MockProvider("sendspin", mass=mock_mass)
8371 spb_player = MockPlayer(
8372 spb_provider,
8373 "spb_1",
8374 "Sendspin",
8375 player_type=PlayerType.PROTOCOL,
8376 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
8377 )
8378 spb_player.set_initialized()
8379 controller._players["spb_1"] = spb_player
8380
8381 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
8382 assert spb_player.protocol_parent_id is None
8383
8384 def test_sibling_match_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
8385 """Test sibling matching refuses link when domain is already active on native player."""
8386 controller = PlayerController(mock_mass)
8387
8388 heos_provider = MockProvider("heos", mass=mock_mass)
8389 heos_player = MockPlayer(
8390 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
8391 )
8392 heos_player.set_initialized()
8393
8394 # AirPlay already linked
8395 ap_provider = MockProvider("airplay", mass=mock_mass)
8396 ap_player = MockPlayer(
8397 ap_provider,
8398 "ap_1",
8399 "AirPlay",
8400 player_type=PlayerType.PROTOCOL,
8401 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8402 )
8403 ap_player.set_initialized()
8404
8405 controller._players = {"heos_1": heos_player, "ap_1": ap_player}
8406 controller._add_protocol_link(heos_player, ap_player, "airplay")
8407
8408 # Second AirPlay instance with same MAC - should be refused (duplicate domain)
8409 ap2 = MockPlayer(
8410 ap_provider,
8411 "ap_2",
8412 "AirPlay 2",
8413 player_type=PlayerType.PROTOCOL,
8414 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8415 )
8416 ap2.set_initialized()
8417 controller._players["ap_2"] = ap2
8418
8419 assert controller._match_via_linked_protocols(heos_player, ap2, "airplay") is False
8420 assert ap2.protocol_parent_id is None
8421
8422 def test_try_link_to_existing_uses_sibling_matching(self, mock_mass: MagicMock) -> None:
8423 """Test _try_link_to_existing_player falls back to sibling matching."""
8424 controller = PlayerController(mock_mass)
8425
8426 # No cached protocol IDs for HEOS
8427 mock_mass.config.get = MagicMock(return_value=[])
8428
8429 heos_provider = MockProvider("heos", mass=mock_mass)
8430 heos_player = MockPlayer(
8431 heos_provider,
8432 "2140037800",
8433 "Denon AVR-X2700H",
8434 player_type=PlayerType.PLAYER,
8435 )
8436 heos_player.set_initialized()
8437
8438 ap_provider = MockProvider("airplay", mass=mock_mass)
8439 ap_player = MockPlayer(
8440 ap_provider,
8441 "ap000678747b16",
8442 "Denon AirPlay",
8443 player_type=PlayerType.PROTOCOL,
8444 identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
8445 )
8446 ap_player.set_initialized()
8447
8448 controller._players = {
8449 "2140037800": heos_player,
8450 "ap000678747b16": ap_player,
8451 }
8452 controller._add_protocol_link(heos_player, ap_player, "airplay")
8453
8454 # Sendspin with matching AIRPLAY_ID
8455 spb_provider = MockProvider("sendspin", mass=mock_mass)
8456 spb_player = MockPlayer(
8457 spb_provider,
8458 "spb_000678747b16",
8459 "Denon Sendspin",
8460 player_type=PlayerType.PROTOCOL,
8461 identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
8462 )
8463 spb_player.set_initialized()
8464 controller._players["spb_000678747b16"] = spb_player
8465
8466 # _try_link_to_existing_player should succeed via sibling matching
8467 result = controller._try_link_to_existing_player(spb_player, "sendspin")
8468 assert result is True
8469 assert spb_player.protocol_parent_id == "2140037800"
8470
8471
8472class TestDelayedEvalRetry:
8473 """
8474 Tests for the retry of _try_link_to_existing_player in _delayed_protocol_evaluation.
8475
8476 When a protocol player's delayed evaluation fires, native players may have
8477 registered during the delay period. The retry ensures they are checked before
8478 falling through to universal player creation.
8479 """
8480
8481 @pytest.mark.asyncio
8482 async def test_delayed_eval_links_to_native_registered_during_delay(
8483 self, mock_mass: MagicMock
8484 ) -> None:
8485 """
8486 Test delayed eval links to a native player that registered during the delay.
8487
8488 Scenario: Sendspin registers before HEOS. The delayed evaluation fires
8489 after HEOS has registered and has AirPlay linked. The retry should find
8490 HEOS via sibling matching and link Sendspin.
8491 """
8492 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
8493
8494 # No cached protocol IDs
8495 mock_mass.config.get = MagicMock(return_value=[])
8496
8497 # HEOS native player (no device identifiers)
8498 heos_provider = MockProvider("heos", mass=mock_mass)
8499 heos_player = MockPlayer(
8500 heos_provider,
8501 "2140037800",
8502 "Denon AVR-X2700H",
8503 player_type=PlayerType.PLAYER,
8504 )
8505 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8506 heos_player._cache.clear()
8507 heos_player.set_initialized()
8508
8509 # AirPlay already linked to HEOS
8510 ap_provider = MockProvider("airplay", mass=mock_mass)
8511 ap_player = MockPlayer(
8512 ap_provider,
8513 "ap000678747b16",
8514 "Denon AirPlay",
8515 player_type=PlayerType.PROTOCOL,
8516 identifiers={
8517 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8518 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8519 },
8520 )
8521 ap_player.set_initialized()
8522 ap_player.set_protocol_parent_id("2140037800")
8523
8524 # Sendspin (not yet linked)
8525 spb_provider = MockProvider("sendspin", mass=mock_mass)
8526 spb_player = MockPlayer(
8527 spb_provider,
8528 "spb_000678747b16",
8529 "Denon Sendspin",
8530 player_type=PlayerType.PROTOCOL,
8531 identifiers={
8532 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8533 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8534 },
8535 )
8536 spb_player.set_initialized()
8537
8538 # Set up players dict (simulating: HEOS registered during the delay)
8539 controller._players = {
8540 "2140037800": heos_player,
8541 "ap000678747b16": ap_player,
8542 "spb_000678747b16": spb_player,
8543 }
8544
8545 # Link AirPlay to HEOS
8546 controller._add_protocol_link(heos_player, ap_player, "airplay")
8547
8548 # Run delayed evaluation for Sendspin
8549 await controller._delayed_protocol_evaluation("spb_000678747b16")
8550
8551 # Sendspin should be linked to HEOS, not a new universal player
8552 assert spb_player.protocol_parent_id == "2140037800"
8553
8554 @pytest.mark.asyncio
8555 async def test_delayed_eval_skips_already_linked(self, mock_mass: MagicMock) -> None:
8556 """Test delayed eval returns early if protocol player was linked during the delay."""
8557 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
8558
8559 spb_provider = MockProvider("sendspin", mass=mock_mass)
8560 spb_player = MockPlayer(
8561 spb_provider,
8562 "spb_1",
8563 "Sendspin",
8564 player_type=PlayerType.PROTOCOL,
8565 identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
8566 )
8567 spb_player.set_initialized()
8568 # Simulate it was linked during the delay
8569 spb_player.set_protocol_parent_id("some_parent")
8570
8571 controller._players = {"spb_1": spb_player}
8572
8573 # Should return early without creating universal player
8574 await controller._delayed_protocol_evaluation("spb_1")
8575 assert spb_player.protocol_parent_id == "some_parent"
8576
8577
8578class TestNativePlayerSiblingLinking:
8579 """
8580 Tests for the second pass in _try_link_protocols_to_native using sibling matching.
8581
8582 After a native player registers and recovers cached protocol links, the second
8583 pass should match remaining unlinked protocol players via sibling identifiers.
8584 """
8585
8586 def test_native_registration_links_sendspin_via_sibling(self, mock_mass: MagicMock) -> None:
8587 """
8588 Test HEOS registration links Sendspin via AirPlay sibling identifiers.
8589
8590 Scenario: Sendspin registered before HEOS. When HEOS registers, the first
8591 pass can't match Sendspin (no direct identifiers). After recovering cached
8592 AirPlay link, the second pass matches Sendspin via AirPlay's AIRPLAY_ID.
8593 """
8594 controller = PlayerController(mock_mass)
8595
8596 # Set up config to return cached AirPlay link for HEOS
8597 def config_get_side_effect(key: str, default: object = None) -> object:
8598 if key == "players/2140037800/values/linked_protocol_ids":
8599 return ["ap000678747b16"]
8600 if key == "players/ap000678747b16":
8601 return {
8602 "provider": "airplay--test",
8603 "player_type": "protocol",
8604 "values": {"protocol_parent_id": "2140037800"},
8605 }
8606 if key == "players":
8607 return {
8608 "ap000678747b16": {
8609 "provider": "airplay--test",
8610 "player_type": "protocol",
8611 "values": {"protocol_parent_id": "2140037800"},
8612 },
8613 "spb_000678747b16": {
8614 "provider": "sendspin--test",
8615 "player_type": "protocol",
8616 "values": {},
8617 },
8618 }
8619 return default
8620
8621 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
8622 mock_mass.get_provider = MagicMock(return_value=None)
8623
8624 # AirPlay protocol player (already registered, waiting for parent)
8625 ap_provider = MockProvider("airplay", mass=mock_mass)
8626 ap_player = MockPlayer(
8627 ap_provider,
8628 "ap000678747b16",
8629 "Denon AirPlay",
8630 player_type=PlayerType.PROTOCOL,
8631 identifiers={
8632 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8633 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8634 },
8635 )
8636 ap_player.set_initialized()
8637 # AirPlay has cached parent set but parent hasn't registered yet
8638 ap_player.set_protocol_parent_id("2140037800")
8639
8640 # Sendspin (registered, not linked)
8641 spb_provider = MockProvider("sendspin", mass=mock_mass)
8642 spb_player = MockPlayer(
8643 spb_provider,
8644 "spb_000678747b16",
8645 "Denon Sendspin",
8646 player_type=PlayerType.PROTOCOL,
8647 identifiers={
8648 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8649 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8650 },
8651 )
8652 spb_player.set_initialized()
8653
8654 # HEOS native player registers now
8655 heos_provider = MockProvider("heos", mass=mock_mass)
8656 heos_player = MockPlayer(
8657 heos_provider,
8658 "2140037800",
8659 "Denon AVR-X2700H",
8660 player_type=PlayerType.PLAYER,
8661 )
8662 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8663 heos_player._cache.clear()
8664 heos_player.set_initialized()
8665
8666 controller._players = {
8667 "ap000678747b16": ap_player,
8668 "spb_000678747b16": spb_player,
8669 "2140037800": heos_player,
8670 }
8671
8672 # Simulate what happens when HEOS registers
8673 controller._try_link_protocols_to_native(heos_player)
8674
8675 # AirPlay should be recovered from cache (linked_output_protocols)
8676 airplay_linked = any(
8677 link.output_protocol_id == "ap000678747b16"
8678 for link in heos_player.linked_output_protocols
8679 )
8680 assert airplay_linked, "AirPlay should be recovered from cached protocol links"
8681
8682 # Sendspin should be linked via sibling matching (second pass)
8683 assert spb_player.protocol_parent_id == "2140037800"
8684 sendspin_linked = any(
8685 link.output_protocol_id == "spb_000678747b16"
8686 for link in heos_player.linked_output_protocols
8687 )
8688 assert sendspin_linked, "Sendspin should be linked via sibling protocol matching"
8689
8690
8691class TestCachedProtocolLinkRecovery:
8692 """Tests for recovering protocol links from config."""
8693
8694 def test_recovered_links_are_applied_through_the_setter(self, mock_mass: MagicMock) -> None:
8695 """Recovery applies a single link per protocol domain through the setter."""
8696 controller = PlayerController(mock_mass)
8697
8698 protocol_configs = {
8699 "ap_first": {
8700 "provider": "airplay--first",
8701 "player_type": "protocol",
8702 "values": {"protocol_parent_id": "heos_player"},
8703 },
8704 "ap_second": {
8705 "provider": "airplay--second",
8706 "player_type": "protocol",
8707 "values": {"protocol_parent_id": "heos_player"},
8708 },
8709 }
8710
8711 def config_get_side_effect(key: str, default: object = None) -> object:
8712 if key == "players/heos_player/values/linked_protocol_ids":
8713 return ["ap_first", "ap_second"]
8714 if key == "players":
8715 return protocol_configs
8716 if key.startswith("players/"):
8717 return protocol_configs.get(key.split("/", maxsplit=1)[1])
8718 return default
8719
8720 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
8721
8722 heos_player = MockPlayer(
8723 MockProvider("heos", mass=mock_mass),
8724 "heos_player",
8725 "Denon AVR-X2700H",
8726 )
8727
8728 controller._recover_cached_protocol_links(heos_player)
8729
8730 recovered = [link.output_protocol_id for link in heos_player.linked_output_protocols]
8731 assert recovered == ["ap_first"]
8732 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
8733
8734 # a repeat pass has nothing left to recover, so no update is triggered
8735 controller._recover_cached_protocol_links(heos_player)
8736 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
8737
8738
8739class TestMultiMACMatching:
8740 """Tests for multi-MAC matching (reported MAC vs ARP MAC)."""
8741
8742 def test_match_on_reported_mac_when_arp_mac_differs(self, mock_mass: MagicMock) -> None:
8743 """Two players match when one's reported MAC equals the other's ARP-resolved MAC."""
8744 controller = PlayerController(mock_mass)
8745
8746 provider = MockProvider("test")
8747 # Player A has ARP-resolved MAC in identifiers, reported MAC saved in extra_data
8748 player_a = MockPlayer(
8749 provider,
8750 "player_a",
8751 "Player A",
8752 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
8753 )
8754 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
8755
8756 # Player B has a MAC that matches player_a's reported MAC
8757 player_b = MockPlayer(
8758 provider,
8759 "player_b",
8760 "Player B",
8761 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
8762 )
8763
8764 assert controller._identifiers_match(player_a, player_b) is True
8765
8766 def test_match_on_reported_mac_reverse(self, mock_mass: MagicMock) -> None:
8767 """Match when player_b's reported MAC matches player_a's ARP MAC."""
8768 controller = PlayerController(mock_mass)
8769
8770 provider = MockProvider("test")
8771 player_a = MockPlayer(
8772 provider,
8773 "player_a",
8774 "Player A",
8775 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
8776 )
8777
8778 player_b = MockPlayer(
8779 provider,
8780 "player_b",
8781 "Player B",
8782 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
8783 )
8784 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
8785
8786 assert controller._identifiers_match(player_a, player_b) is True
8787
8788 def test_no_match_when_both_macs_differ(self, mock_mass: MagicMock) -> None:
8789 """No match when all MACs differ between players."""
8790 controller = PlayerController(mock_mass)
8791
8792 provider = MockProvider("test")
8793 player_a = MockPlayer(
8794 provider,
8795 "player_a",
8796 "Player A",
8797 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
8798 )
8799 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
8800
8801 player_b = MockPlayer(
8802 provider,
8803 "player_b",
8804 "Player B",
8805 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
8806 )
8807 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:04"
8808
8809 assert controller._identifiers_match(player_a, player_b) is False
8810
8811 def test_match_reported_mac_with_la_bit_normalization(self, mock_mass: MagicMock) -> None:
8812 """Reported MAC matching also applies LA bit normalization."""
8813 controller = PlayerController(mock_mass)
8814
8815 provider = MockProvider("test")
8816 # Player A has ARP MAC, reported MAC has LA bit set
8817 player_a = MockPlayer(
8818 provider,
8819 "player_a",
8820 "Player A",
8821 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
8822 )
8823 player_a.extra_data["reported_mac"] = "56:78:C9:E6:0D:A0" # LA variant
8824
8825 # Player B has the hardware MAC
8826 player_b = MockPlayer(
8827 provider,
8828 "player_b",
8829 "Player B",
8830 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}, # real MAC
8831 )
8832
8833 assert controller._identifiers_match(player_a, player_b) is True
8834
8835
8836class TestProtocolParentIdPersistence:
8837 """Tests for protocol_parent_id persistence for all parent types."""
8838
8839 def test_parent_id_saved_for_universal_parent(self, mock_mass: MagicMock) -> None:
8840 """protocol_parent_id should be saved when linking to a universal player."""
8841 controller = PlayerController(mock_mass)
8842
8843 def _config_get(key: str, default: object = None) -> object:
8844 # Protocol player config must exist for _save_protocol_parent_id to write
8845 if key == "players/airplay_test":
8846 return {"enabled": True}
8847 return default if default is not None else []
8848
8849 mock_mass.config.get = MagicMock(side_effect=_config_get)
8850
8851 universal_provider = create_mock_universal_provider(mock_mass)
8852 parent = UniversalPlayer(
8853 provider=universal_provider,
8854 player_id="up_test",
8855 name="Test Universal",
8856 device_info=DeviceInfo(),
8857 protocol_player_ids=[],
8858 )
8859 parent.update_state(signal_event=False)
8860
8861 protocol_provider = MockProvider("airplay")
8862 protocol_player = MockPlayer(
8863 protocol_provider,
8864 "airplay_test",
8865 "AirPlay Test",
8866 player_type=PlayerType.PROTOCOL,
8867 )
8868
8869 controller._players = {
8870 "up_test": parent,
8871 "airplay_test": protocol_player,
8872 }
8873
8874 controller._add_protocol_link(parent, protocol_player, "airplay")
8875
8876 # Verify _save_protocol_parent_id was called (config.set with parent ID)
8877 config_set_calls = [
8878 call
8879 for call in mock_mass.config.set.call_args_list
8880 if "protocol_parent_id" in str(call)
8881 ]
8882 assert len(config_set_calls) >= 1, (
8883 "protocol_parent_id should be saved for universal player parents"
8884 )
8885
8886 def test_parent_id_cleared_for_universal_parent(self, mock_mass: MagicMock) -> None:
8887 """protocol_parent_id should be cleared when unlinking from a universal player."""
8888 controller = PlayerController(mock_mass)
8889 mock_mass.config.get = MagicMock(return_value={"enabled": True})
8890
8891 universal_provider = create_mock_universal_provider(mock_mass)
8892 parent = UniversalPlayer(
8893 provider=universal_provider,
8894 player_id="up_test",
8895 name="Test Universal",
8896 device_info=DeviceInfo(),
8897 protocol_player_ids=["airplay_test"],
8898 )
8899 parent.update_state(signal_event=False)
8900
8901 protocol_provider = MockProvider("airplay")
8902 protocol_player = MockPlayer(
8903 protocol_provider,
8904 "airplay_test",
8905 "AirPlay Test",
8906 player_type=PlayerType.PROTOCOL,
8907 )
8908 protocol_player.set_protocol_parent_id("up_test")
8909
8910 # Set up linked protocols
8911 parent.set_linked_output_protocols(
8912 [
8913 LinkedOutputProtocol(
8914 output_protocol_id="airplay_test",
8915 protocol_domain="airplay",
8916 priority=10,
8917 )
8918 ]
8919 )
8920
8921 controller._players = {
8922 "up_test": parent,
8923 "airplay_test": protocol_player,
8924 }
8925
8926 controller._remove_protocol_link(parent, "airplay_test")
8927
8928 # Verify protocol_parent_id was cleared in config
8929 config_set_calls = [
8930 call
8931 for call in mock_mass.config.set.call_args_list
8932 if "protocol_parent_id" in str(call) and call.args[1] is None
8933 ]
8934 assert len(config_set_calls) >= 1, (
8935 "protocol_parent_id should be cleared for universal player parents"
8936 )
8937
8938
8939class TestCleanupProtocolLinks:
8940 """Tests for protocol link cleanup on player removal."""
8941
8942 def test_cleanup_clears_config_parent_id(self, mock_mass: MagicMock) -> None:
8943 """When a universal player is removed, protocol parent_ids are cleared in config."""
8944 controller = PlayerController(mock_mass)
8945 mock_mass.config.get = MagicMock(return_value={"enabled": True})
8946
8947 universal_provider = create_mock_universal_provider(mock_mass)
8948 parent = UniversalPlayer(
8949 provider=universal_provider,
8950 player_id="up_test",
8951 name="Test Universal",
8952 device_info=DeviceInfo(),
8953 protocol_player_ids=["airplay_test"],
8954 )
8955 parent.update_state(signal_event=False)
8956
8957 protocol_provider = MockProvider("airplay")
8958 protocol_player = MockPlayer(
8959 protocol_provider,
8960 "airplay_test",
8961 "AirPlay Test",
8962 player_type=PlayerType.PROTOCOL,
8963 )
8964 protocol_player.set_protocol_parent_id("up_test")
8965
8966 parent.set_linked_output_protocols(
8967 [
8968 LinkedOutputProtocol(
8969 output_protocol_id="airplay_test",
8970 protocol_domain="airplay",
8971 priority=10,
8972 )
8973 ]
8974 )
8975
8976 controller._players = {
8977 "up_test": parent,
8978 "airplay_test": protocol_player,
8979 }
8980
8981 controller._cleanup_protocol_links(parent)
8982
8983 # Verify protocol_parent_id was cleared in config
8984 config_set_calls = [
8985 call
8986 for call in mock_mass.config.set.call_args_list
8987 if "airplay_test" in str(call) and "protocol_parent_id" in str(call)
8988 ]
8989 assert len(config_set_calls) >= 1, (
8990 "protocol_parent_id should be cleared in config on parent removal"
8991 )
8992 # Verify in-memory parent cleared
8993 assert protocol_player.protocol_parent_id is None
8994
8995 def test_cleanup_native_parent_schedules_protocol_re_evaluation(
8996 self, mock_mass: MagicMock
8997 ) -> None:
8998 """Permanent native parent removal should reset links and reschedule protocols."""
8999 controller = PlayerController(mock_mass)
9000
9001 config_store: dict[str, object] = {
9002 "players/sonos_1": {"enabled": True},
9003 "players/sonos_1/values/linked_protocol_ids": ["airplay_live", "dlna_cached"],
9004 "players/airplay_live": {"enabled": True},
9005 "players/dlna_cached": {"enabled": True},
9006 }
9007
9008 def config_get(key: str, default: object = None) -> object:
9009 return config_store.get(key, default)
9010
9011 def config_set(key: str, value: object) -> None:
9012 config_store[key] = value
9013
9014 mock_mass.config.get = MagicMock(side_effect=config_get)
9015 mock_mass.config.set = MagicMock(side_effect=config_set)
9016
9017 native_provider = MockProvider("sonos", mass=mock_mass)
9018 parent = MockPlayer(
9019 native_provider,
9020 "sonos_1",
9021 "Sonos",
9022 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9023 )
9024
9025 protocol_provider = MockProvider("airplay", mass=mock_mass)
9026 protocol_player = MockPlayer(
9027 protocol_provider,
9028 "airplay_live",
9029 "AirPlay Test",
9030 player_type=PlayerType.PROTOCOL,
9031 )
9032 protocol_player.set_protocol_parent_id("sonos_1")
9033
9034 parent.set_linked_output_protocols(
9035 [
9036 LinkedOutputProtocol(
9037 output_protocol_id="airplay_live",
9038 protocol_domain="airplay",
9039 priority=10,
9040 )
9041 ]
9042 )
9043
9044 controller._players = {
9045 "sonos_1": parent,
9046 "airplay_live": protocol_player,
9047 }
9048
9049 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9050 controller._cleanup_protocol_links(parent)
9051
9052 assert protocol_player.protocol_parent_id is None
9053 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9054 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
9055 mock_schedule.assert_called_once_with(protocol_player)
9056
9057 def test_cleanup_universal_parent_clears_cached_only_protocol_ids(
9058 self, mock_mass: MagicMock
9059 ) -> None:
9060 """Universal cleanup should also clear cached protocol ids that are not active."""
9061 controller = PlayerController(mock_mass)
9062
9063 config_store: dict[str, object] = {
9064 "players/up_test": {"enabled": True},
9065 "players/airplay_live": {"enabled": True},
9066 "players/dlna_cached": {"enabled": True},
9067 }
9068
9069 def config_get(key: str, default: object = None) -> object:
9070 return config_store.get(key, default)
9071
9072 def config_set(key: str, value: object) -> None:
9073 config_store[key] = value
9074
9075 mock_mass.config.get = MagicMock(side_effect=config_get)
9076 mock_mass.config.set = MagicMock(side_effect=config_set)
9077
9078 universal_provider = create_mock_universal_provider(mock_mass)
9079 parent = UniversalPlayer(
9080 provider=universal_provider,
9081 player_id="up_test",
9082 name="Test Universal",
9083 device_info=DeviceInfo(),
9084 protocol_player_ids=["airplay_live", "dlna_cached"],
9085 )
9086 parent.update_state(signal_event=False)
9087
9088 protocol_provider = MockProvider("airplay", mass=mock_mass)
9089 protocol_player = MockPlayer(
9090 protocol_provider,
9091 "airplay_live",
9092 "AirPlay Test",
9093 player_type=PlayerType.PROTOCOL,
9094 )
9095 protocol_player.set_protocol_parent_id("up_test")
9096
9097 parent.set_linked_output_protocols(
9098 [
9099 LinkedOutputProtocol(
9100 output_protocol_id="airplay_live",
9101 protocol_domain="airplay",
9102 priority=10,
9103 )
9104 ]
9105 )
9106
9107 controller._players = {
9108 "up_test": parent,
9109 "airplay_live": protocol_player,
9110 }
9111
9112 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9113 controller._cleanup_protocol_links(parent)
9114
9115 assert protocol_player.protocol_parent_id is None
9116 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9117 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
9118 mock_schedule.assert_called_once_with(protocol_player)
9119
9120
9121class TestDetachProtocolChildren:
9122 """Tests for detaching protocol players of a removed player that is not registered."""
9123
9124 @staticmethod
9125 def _setup(
9126 mock_mass: MagicMock, live_parent_id: str | None
9127 ) -> tuple[PlayerController, MockPlayer, dict[str, object]]:
9128 """Set up a registered protocol player whose (unregistered) parent is sonos_1."""
9129 controller = PlayerController(mock_mass)
9130
9131 config_store: dict[str, object] = {
9132 "players": {"sonos_1": {}, "airplay_live": {}},
9133 "players/sonos_1": {"enabled": True},
9134 "players/airplay_live": {"enabled": True},
9135 "players/airplay_live/values/protocol_parent_id": "sonos_1",
9136 }
9137
9138 mock_mass.config.get = MagicMock(
9139 side_effect=lambda key, default=None: config_store.get(key, default)
9140 )
9141 mock_mass.config.set = MagicMock(
9142 side_effect=lambda key, value: config_store.__setitem__(key, value)
9143 )
9144
9145 protocol_player = MockPlayer(
9146 MockProvider("airplay", mass=mock_mass),
9147 "airplay_live",
9148 "AirPlay Test",
9149 player_type=PlayerType.PROTOCOL,
9150 )
9151 protocol_player.set_protocol_parent_id(live_parent_id)
9152 controller._players = {"airplay_live": protocol_player}
9153 return controller, protocol_player, config_store
9154
9155 def test_detach_on_removal_of_an_unregistered_parent(self, mock_mass: MagicMock) -> None:
9156 """Removing a player that is no longer registered detaches its protocol player."""
9157 controller, protocol_player, config_store = self._setup(mock_mass, "sonos_1")
9158
9159 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9160 controller.delete_player_config("sonos_1")
9161
9162 assert protocol_player.protocol_parent_id is None
9163 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9164 mock_schedule.assert_called_once_with(protocol_player)
9165
9166 def test_detach_of_a_protocol_player_waiting_for_its_parent(self, mock_mass: MagicMock) -> None:
9167 """A protocol player that only has the parent link in its config is detached too."""
9168 controller, protocol_player, config_store = self._setup(mock_mass, None)
9169
9170 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9171 controller.delete_player_config("sonos_1")
9172
9173 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
9174 mock_schedule.assert_called_once_with(protocol_player)
9175
9176 def test_detach_skips_a_protocol_player_of_another_parent(self, mock_mass: MagicMock) -> None:
9177 """A protocol player that already moved to another parent keeps its link."""
9178 controller, protocol_player, config_store = self._setup(mock_mass, "cast_1")
9179
9180 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
9181 controller.delete_player_config("sonos_1")
9182
9183 assert protocol_player.protocol_parent_id == "cast_1"
9184 assert config_store["players/airplay_live/values/protocol_parent_id"] == "sonos_1"
9185 mock_schedule.assert_not_called()
9186
9187
9188class TestStaleConfigMigration:
9189 """Tests for protocol parent link repair on startup."""
9190
9191 def test_clears_stale_parent_ids(self, mock_mass: MagicMock) -> None:
9192 """Stale parent_ids pointing to deleted players are cleared on startup."""
9193 controller = PlayerController(mock_mass)
9194
9195 # Set up config with a protocol player pointing to a deleted universal player
9196 mock_mass.config.get = MagicMock(
9197 return_value={
9198 "airplay_test": {
9199 "player_type": "protocol",
9200 "values": {
9201 "protocol_parent_id": "up_deleted_player",
9202 },
9203 },
9204 "heos_player": {
9205 "player_type": "player",
9206 "values": {},
9207 },
9208 }
9209 )
9210
9211 controller._repair_protocol_parent_links()
9212
9213 # Verify the stale parent_id was cleared
9214 mock_mass.config.set.assert_any_call(
9215 "players/airplay_test/values/protocol_parent_id",
9216 None,
9217 )
9218 mock_mass.config.set_player_type.assert_not_called()
9219
9220 def test_keeps_valid_parent_ids(self, mock_mass: MagicMock) -> None:
9221 """Valid parent_ids pointing to existing players are preserved."""
9222 controller = PlayerController(mock_mass)
9223
9224 mock_mass.config.get = MagicMock(
9225 return_value={
9226 "airplay_test": {
9227 "player_type": "protocol",
9228 "values": {
9229 "protocol_parent_id": "up_valid_player",
9230 },
9231 },
9232 "up_valid_player": {
9233 "player_type": "group",
9234 "provider": "universal_player",
9235 "values": {},
9236 },
9237 }
9238 )
9239
9240 controller._repair_protocol_parent_links()
9241
9242 # Verify no set calls were made to clear the parent_id
9243 for call in mock_mass.config.set.call_args_list:
9244 assert "protocol_parent_id" not in str(call), "Valid parent_id should not be cleared"
9245 mock_mass.config.set_player_type.assert_not_called()
9246
9247 def test_heals_stale_player_type_of_linked_child(self, mock_mass: MagicMock) -> None:
9248 """A parented child whose player_type was corrupted is healed back to protocol."""
9249 controller = PlayerController(mock_mass)
9250
9251 mock_mass.config.get = MagicMock(
9252 return_value={
9253 "squeezelite_child": {
9254 "player_type": "player",
9255 "values": {
9256 "protocol_parent_id": "up_valid_player",
9257 },
9258 },
9259 "up_valid_player": {
9260 "player_type": "group",
9261 "provider": "universal_player",
9262 "values": {},
9263 },
9264 }
9265 )
9266
9267 controller._repair_protocol_parent_links()
9268
9269 mock_mass.config.set_player_type.assert_called_once_with(
9270 "squeezelite_child", PlayerType.PROTOCOL
9271 )
9272
9273 def test_native_registration_clears_stale_parent_link(self, mock_mass: MagicMock) -> None:
9274 """A player registering with a non-protocol type drops its leftover parent link."""
9275 controller = PlayerController(mock_mass)
9276 provider = MockProvider("sendspin", mass=mock_mass)
9277 player = MockPlayer(provider, "web_player", "Web Player", player_type=PlayerType.PLAYER)
9278
9279 config_values = {
9280 f"{CONF_PLAYERS}/web_player": {"player_type": "player"},
9281 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id": "up_old_parent",
9282 }
9283 mock_mass.config.get = MagicMock(
9284 side_effect=lambda key, default=None: config_values.get(key, default)
9285 )
9286
9287 with patch.object(controller, "_try_link_protocols_to_native"):
9288 controller._evaluate_protocol_links(player)
9289
9290 mock_mass.config.set.assert_any_call(
9291 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id",
9292 None,
9293 )
9294
9295
9296class TestUniversalPlayerRestoreOrphanCleanup:
9297 """Tests for UniversalPlayerProvider._restore_player membership repair behavior."""
9298
9299 @staticmethod
9300 def _setup_config_get(
9301 mock_mass: MagicMock,
9302 universal_id: str,
9303 linked_protocol_ids: list[str],
9304 protocol_configs: dict[str, dict[str, object]],
9305 ) -> None:
9306 """Wire mass.config.get to return universal/protocol configs by key."""
9307 universal_conf = {
9308 "values": {
9309 "linked_protocol_ids": list(linked_protocol_ids),
9310 "device_identifiers": {},
9311 "device_info": {},
9312 },
9313 "name": "Test Universal",
9314 }
9315 all_configs: dict[str, object] = {universal_id: universal_conf, **protocol_configs}
9316
9317 def _get(key: str, default: object = None) -> object:
9318 if key == CONF_PLAYERS:
9319 return all_configs
9320 if key.startswith(f"{CONF_PLAYERS}/"):
9321 pid = key.split("/", 1)[1]
9322 return all_configs.get(pid, default)
9323 return default
9324
9325 mock_mass.config.get.side_effect = _get
9326
9327 @pytest.mark.asyncio
9328 async def test_orphan_protocol_dropped_but_configs_kept(self, mock_mass: MagicMock) -> None:
9329 """Orphan protocol is dropped from membership without deleting any config."""
9330 provider = create_mock_universal_provider(mock_mass)
9331 universal_id = "up_test"
9332 orphan_id = "spb_orphan"
9333
9334 self._setup_config_get(
9335 mock_mass,
9336 universal_id,
9337 [orphan_id],
9338 {
9339 orphan_id: {
9340 "player_type": "protocol",
9341 "values": {"protocol_parent_id": None},
9342 },
9343 },
9344 )
9345 mock_mass.players = MagicMock()
9346 mock_mass.players.get_player = MagicMock(return_value=None)
9347 mock_mass.players.delete_player_config = MagicMock()
9348 mock_mass.players.unregister = AsyncMock()
9349 mock_mass.players.register_or_update = AsyncMock()
9350 mock_mass.config.remove_player_config = AsyncMock()
9351
9352 await provider._restore_player(universal_id)
9353
9354 # Neither the orphan's config nor the universal's config is deleted
9355 mock_mass.players.delete_player_config.assert_not_called()
9356 mock_mass.players.unregister.assert_not_called()
9357 mock_mass.config.remove_player_config.assert_not_called()
9358 # With no valid protocols left, the universal is simply not restored
9359 mock_mass.players.register_or_update.assert_not_called()
9360
9361 @pytest.mark.asyncio
9362 async def test_valid_protocol_kept_and_no_cleanup(self, mock_mass: MagicMock) -> None:
9363 """Protocol with a valid protocol_parent_id is kept and no cleanup runs."""
9364 provider = create_mock_universal_provider(mock_mass)
9365 universal_id = "up_test"
9366 protocol_id = "spb_valid"
9367
9368 self._setup_config_get(
9369 mock_mass,
9370 universal_id,
9371 [protocol_id],
9372 {
9373 protocol_id: {
9374 "player_type": "protocol",
9375 "values": {"protocol_parent_id": universal_id},
9376 },
9377 },
9378 )
9379 mock_mass.players = MagicMock()
9380 mock_mass.players.get_player = MagicMock(return_value=None)
9381 mock_mass.players.delete_player_config = MagicMock()
9382 mock_mass.players.unregister = AsyncMock()
9383 mock_mass.players.register_or_update = AsyncMock()
9384 mock_mass.config.remove_player_config = AsyncMock()
9385 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
9386
9387 await provider._restore_player(universal_id)
9388
9389 mock_mass.players.delete_player_config.assert_not_called()
9390 mock_mass.players.unregister.assert_not_called()
9391 mock_mass.config.remove_player_config.assert_not_called()
9392 # Universal is constructed and registered with the valid protocol kept
9393 mock_mass.players.register_or_update.assert_awaited_once()
9394 registered = mock_mass.players.register_or_update.call_args.args[0]
9395 assert protocol_id in registered._protocol_player_ids
9396
9397 @pytest.mark.asyncio
9398 async def test_restore_keeps_linked_protocol_after_startup_type_rewrite(
9399 self, mock_mass: MagicMock
9400 ) -> None:
9401 """A linked protocol whose type was rewritten must not delete its universal config."""
9402 provider = create_mock_universal_provider(mock_mass)
9403 universal_id = "upe45f0170ef67"
9404 protocol_id = "e4:5f:01:70:ef:67"
9405 universal_config: dict[str, Any] = {
9406 "player_id": universal_id,
9407 "provider": "universal_player",
9408 "player_type": "player",
9409 "enabled": True,
9410 "name": "WC-Player",
9411 "default_name": "solarium-bath-sl",
9412 "values": {
9413 "hide_in_ui": True,
9414 "announce_volume_min": 55,
9415 "announce_volume_max": 98,
9416 "play_media_overrides_group": False,
9417 "linked_protocol_ids": [protocol_id],
9418 "device_identifiers": {},
9419 "device_info": {},
9420 },
9421 }
9422 configs: dict[str, dict[str, Any]] = {
9423 universal_id: universal_config,
9424 protocol_id: {
9425 "player_id": protocol_id,
9426 "provider": "squeezelite",
9427 "player_type": "player",
9428 "enabled": True,
9429 "values": {"protocol_parent_id": universal_id},
9430 },
9431 }
9432
9433 def _config_get(key: str, default: object = None) -> object:
9434 if key == CONF_PLAYERS:
9435 return configs
9436 if key.startswith(f"{CONF_PLAYERS}/"):
9437 pid = key.split("/", 1)[1]
9438 return configs.get(pid, default)
9439 return default
9440
9441 mock_mass.config.get.side_effect = _config_get
9442 mock_mass.config.remove_player_config = AsyncMock()
9443 mock_mass.config.get_base_player_config.return_value = create_mock_config("WC-Player")
9444 mock_mass.players = MagicMock()
9445 mock_mass.players.get_player = MagicMock(return_value=None)
9446 mock_mass.players.delete_player_config = MagicMock()
9447 mock_mass.players.unregister = AsyncMock()
9448 mock_mass.players.register_or_update = AsyncMock()
9449
9450 await provider._restore_player(universal_id)
9451
9452 mock_mass.config.remove_player_config.assert_not_called()
9453 mock_mass.players.delete_player_config.assert_not_called()
9454 mock_mass.players.unregister.assert_not_called()
9455 mock_mass.players.register_or_update.assert_awaited_once()
9456 registered = mock_mass.players.register_or_update.call_args.args[0]
9457 assert protocol_id in registered._protocol_player_ids
9458 assert configs[universal_id]["values"]["hide_in_ui"] is True
9459 assert configs[universal_id]["values"]["announce_volume_min"] == 55
9460
9461 @pytest.mark.asyncio
9462 async def test_membership_augmented_from_child_parent_link(self, mock_mass: MagicMock) -> None:
9463 """A child whose parent link points at the universal is re-added to membership."""
9464 provider = create_mock_universal_provider(mock_mass)
9465 universal_id = "up_test"
9466 stored_id = "spb_stored"
9467 missing_id = "spb_missing"
9468
9469 self._setup_config_get(
9470 mock_mass,
9471 universal_id,
9472 [stored_id],
9473 {
9474 stored_id: {
9475 "player_type": "protocol",
9476 "values": {"protocol_parent_id": universal_id},
9477 },
9478 missing_id: {
9479 "player_type": "protocol",
9480 "values": {"protocol_parent_id": universal_id},
9481 },
9482 },
9483 )
9484 mock_mass.players = MagicMock()
9485 mock_mass.players.get_player = MagicMock(return_value=None)
9486 mock_mass.players.register_or_update = AsyncMock()
9487 mock_mass.config.remove_player_config = AsyncMock()
9488 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
9489
9490 await provider._restore_player(universal_id)
9491
9492 # The missing child is restored to membership and persisted
9493 mock_mass.config.set.assert_any_call(
9494 f"{CONF_PLAYERS}/{universal_id}/values/linked_protocol_ids",
9495 [stored_id, missing_id],
9496 )
9497 registered = mock_mass.players.register_or_update.call_args.args[0]
9498 assert registered._protocol_player_ids == [stored_id, missing_id]
9499
9500 @pytest.mark.asyncio
9501 async def test_universal_config_kept_when_protocol_moved_to_other_parent(
9502 self, mock_mass: MagicMock
9503 ) -> None:
9504 """A protocol that moved to another parent is dropped, universal config is kept."""
9505 provider = create_mock_universal_provider(mock_mass)
9506 universal_id = "up_test"
9507 protocol_id = "spb_moved"
9508
9509 self._setup_config_get(
9510 mock_mass,
9511 universal_id,
9512 [protocol_id],
9513 {
9514 protocol_id: {
9515 "player_type": "protocol",
9516 "values": {"protocol_parent_id": "up_other"},
9517 },
9518 },
9519 )
9520 mock_mass.players = MagicMock()
9521 mock_mass.players.get_player = MagicMock(return_value=None)
9522 mock_mass.players.register_or_update = AsyncMock()
9523 mock_mass.config.remove_player_config = AsyncMock()
9524
9525 await provider._restore_player(universal_id)
9526
9527 mock_mass.config.remove_player_config.assert_not_called()
9528 mock_mass.players.register_or_update.assert_not_called()
9529
9530 @pytest.mark.asyncio
9531 async def test_disabled_parent_reparents_and_disables_orphaned_protocols(
9532 self, mock_mass: MagicMock
9533 ) -> None:
9534 """
9535 Self-heal: stale UP whose protocols belong to a disabled native parent.
9536
9537 Restoring such a UP should skip the wrapper, restore each protocol's
9538 parent_id back to the rightful (disabled) native parent, cascade-disable
9539 each protocol so the next registration cycle doesn't rebuild the wrapper,
9540 and hand the wrapper's config over to the native parent before deleting it.
9541 """
9542 provider = create_mock_universal_provider(mock_mass)
9543 universal_id = "up_test"
9544 native_parent_id = "sonos_disabled"
9545 ap_id = "airplay_1"
9546 dlna_id = "dlna_1"
9547
9548 all_configs = {
9549 universal_id: {
9550 "values": {
9551 "linked_protocol_ids": [ap_id, dlna_id],
9552 "device_identifiers": {},
9553 "device_info": {},
9554 },
9555 "name": "Test UP",
9556 },
9557 native_parent_id: {
9558 "enabled": False,
9559 "provider": "sonos",
9560 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
9561 },
9562 ap_id: {
9563 "player_type": "protocol",
9564 "enabled": True,
9565 "values": {"protocol_parent_id": universal_id},
9566 },
9567 dlna_id: {
9568 "player_type": "protocol",
9569 "enabled": True,
9570 "values": {"protocol_parent_id": universal_id},
9571 },
9572 }
9573
9574 def _config_get(key: str, default: object = None) -> object:
9575 if key == CONF_PLAYERS:
9576 return all_configs
9577 if key.startswith(f"{CONF_PLAYERS}/"):
9578 pid = key.split("/", 1)[1]
9579 return all_configs.get(pid, default)
9580 return default
9581
9582 mock_mass.config.get.side_effect = _config_get
9583 mock_mass.config.set = MagicMock()
9584 mock_mass.config.save_player_config = AsyncMock()
9585 mock_mass.config.remove_player_config = AsyncMock()
9586 mock_mass.players = MagicMock()
9587 mock_mass.players.get_player = MagicMock(return_value=None)
9588
9589 await provider._restore_player(universal_id)
9590
9591 # The wrapper is not restored; its settings are handed over to the
9592 # native parent and its now-obsolete config is deleted
9593 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
9594 universal_id, native_parent_id
9595 )
9596 mock_mass.players._repoint_group_memberships.assert_called_once_with(
9597 universal_id, native_parent_id
9598 )
9599 mock_mass.players.delete_player_config.assert_called_once_with(
9600 universal_id, replacement_player_id=native_parent_id
9601 )
9602
9603 # Each protocol's parent_id is restored to the disabled native parent
9604 parent_restorations = {
9605 call.args[0]: call.args[1]
9606 for call in mock_mass.config.set.call_args_list
9607 if "protocol_parent_id" in call.args[0]
9608 }
9609 assert parent_restorations == {
9610 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": native_parent_id,
9611 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": native_parent_id,
9612 }
9613
9614 # Each protocol is cascade-disabled
9615 disabled_ids = {
9616 call.args[0] for call in mock_mass.config.save_player_config.await_args_list
9617 }
9618 assert disabled_ids == {ap_id, dlna_id}
9619
9620 @pytest.mark.asyncio
9621 async def test_universal_replaced_by_linkplay_shell(self, mock_mass: MagicMock) -> None:
9622 """A generic LinkPlay shell that owns DLNA+AirPlay children replaces the Universal."""
9623 provider = create_mock_universal_provider(mock_mass)
9624 universal_id = "up_edifier"
9625 shell_id = "wiim_uuid:FF97F002-783E-6505-6579-F15FFF97F002"
9626 ap_id = "airplay_edifier"
9627 dlna_id = "dlna_edifier"
9628
9629 all_configs = {
9630 universal_id: {
9631 "values": {
9632 "linked_protocol_ids": [ap_id, dlna_id],
9633 "device_identifiers": {},
9634 "device_info": {},
9635 },
9636 "name": "Edifier MS50A",
9637 },
9638 shell_id: {
9639 "enabled": True,
9640 "provider": "wiim",
9641 "player_type": "player",
9642 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
9643 },
9644 ap_id: {
9645 "player_type": "protocol",
9646 "enabled": True,
9647 "values": {"protocol_parent_id": universal_id},
9648 },
9649 dlna_id: {
9650 "player_type": "protocol",
9651 "enabled": True,
9652 "values": {"protocol_parent_id": universal_id},
9653 },
9654 }
9655
9656 def _config_get(key: str, default: object = None) -> object:
9657 if key == CONF_PLAYERS:
9658 return all_configs
9659 if key.startswith(f"{CONF_PLAYERS}/"):
9660 pid = key.split("/", 1)[1]
9661 return all_configs.get(pid, default)
9662 return default
9663
9664 mock_mass.config.get.side_effect = _config_get
9665 mock_mass.config.set = MagicMock()
9666 mock_mass.config.save_player_config = AsyncMock()
9667 mock_mass.config.remove_player_config = AsyncMock()
9668 mock_mass.players = MagicMock()
9669 mock_mass.players.get_player = MagicMock(return_value=None)
9670 mock_mass.players.register_or_update = AsyncMock()
9671
9672 await provider._restore_player(universal_id)
9673
9674 # both protocol children are reparented from the Universal to the LinkPlay shell
9675 parent_restorations = {
9676 call.args[0]: call.args[1]
9677 for call in mock_mass.config.set.call_args_list
9678 if "protocol_parent_id" in call.args[0]
9679 }
9680 assert parent_restorations == {
9681 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": shell_id,
9682 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": shell_id,
9683 }
9684 # the Universal's saved config/name/DSP/preference and group refs migrate to the
9685 # shell, and the now-obsolete Universal is removed
9686 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
9687 universal_id, shell_id
9688 )
9689 mock_mass.players._repoint_group_memberships.assert_called_once_with(universal_id, shell_id)
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._repoint_group_memberships.assert_called_once_with(
9767 universal_id, "native_a"
9768 )
9769 mock_mass.players.delete_player_config.assert_called_once_with(
9770 universal_id, replacement_player_id="native_a"
9771 )
9772
9773 @pytest.mark.asyncio
9774 async def test_restore_native_claims_carries_config_and_deletes_wrapper(
9775 self, mock_mass: MagicMock
9776 ) -> None:
9777 """The wrapper's user settings move to the native claimer and its config is deleted."""
9778 universal_id = "up_old"
9779 native_id = "cast_1"
9780 ap_id = "ap_1"
9781 store: dict[str, Any] = {
9782 CONF_PLAYERS: {
9783 universal_id: {
9784 "provider": "universal_player",
9785 "enabled": True,
9786 "name": "Living Room",
9787 "default_name": "Soundbar (Universal)",
9788 "values": {
9789 "hide_in_ui": True,
9790 "volume_control": "up_volume_control",
9791 "linked_protocol_ids": [ap_id],
9792 "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
9793 "device_info": {"model": "Test", "manufacturer": "Test"},
9794 },
9795 },
9796 native_id: {
9797 "provider": "cast",
9798 "enabled": True,
9799 "name": "Soundbar",
9800 "default_name": "Soundbar",
9801 "values": {
9802 "volume_control": "native",
9803 "linked_protocol_ids": [ap_id],
9804 },
9805 },
9806 ap_id: {
9807 "player_type": "protocol",
9808 "enabled": True,
9809 "values": {"protocol_parent_id": universal_id},
9810 },
9811 "group_1": {
9812 "provider": "sync_group",
9813 "enabled": True,
9814 "values": {
9815 "group_members": [universal_id, "other"],
9816 "allowed_members": [universal_id],
9817 },
9818 },
9819 },
9820 CONF_PLAYER_DSP: {universal_id: {"enabled": True, "filters": []}},
9821 CONF_PLAYER_QUEUES: {
9822 universal_id: {"queue_id": universal_id, "values": {"crossfade_duration": 5}}
9823 },
9824 }
9825 _wire_nested_config(mock_mass, store)
9826 mock_mass.config.save_player_config = AsyncMock()
9827 scheduled_tasks: list[Awaitable[object]] = []
9828 mock_mass.create_task = MagicMock(
9829 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
9830 )
9831 controller = PlayerController(mock_mass)
9832 controller._players = {}
9833 mock_mass.players = controller
9834 mock_mass.player_queues = MagicMock()
9835 provider = create_mock_universal_provider(mock_mass)
9836
9837 await provider._restore_player(universal_id)
9838
9839 players_tree = store[CONF_PLAYERS]
9840 # the protocol player is reparented to the native claimer
9841 assert players_tree[ap_id]["values"]["protocol_parent_id"] == native_id
9842 # the custom display name and user-set values moved onto the native player
9843 assert players_tree[native_id]["name"] == "Living Room"
9844 assert players_tree[native_id]["values"]["hide_in_ui"] is True
9845 # values the native player has explicitly set are not overwritten
9846 assert players_tree[native_id]["values"]["volume_control"] == "native"
9847 # wrapper bookkeeping values are not carried over
9848 assert "device_identifiers" not in players_tree[native_id]["values"]
9849 assert "device_info" not in players_tree[native_id]["values"]
9850 # DSP and queue settings moved onto the native player
9851 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": True, "filters": []}}
9852 assert store[CONF_PLAYER_QUEUES] == {
9853 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 5}}
9854 }
9855 # group memberships that referenced the wrapper now point at the native player
9856 assert players_tree["group_1"]["values"]["group_members"] == [native_id, "other"]
9857 assert players_tree["group_1"]["values"]["allowed_members"] == [native_id]
9858 # the wrapper's own config entries are all gone
9859 assert universal_id not in players_tree
9860 # cached queue state of the wrapper is purged as well
9861 mock_mass.player_queues.purge_saved_queue.assert_called_once_with(universal_id)
9862 # the wrapper itself is not registered as a player
9863 assert universal_id not in controller._players
9864 for task in scheduled_tasks:
9865 await task
9866
9867 @pytest.mark.asyncio
9868 async def test_restore_native_claims_keeps_native_name_dsp_and_queue_values(
9869 self, mock_mass: MagicMock
9870 ) -> None:
9871 """Values explicitly set on the native player win over the wrapper's on restore."""
9872 universal_id = "up_old"
9873 native_id = "cast_1"
9874 ap_id = "ap_1"
9875 store: dict[str, Any] = {
9876 CONF_PLAYERS: {
9877 universal_id: {
9878 "provider": "universal_player",
9879 "enabled": True,
9880 "name": "Living Room",
9881 "default_name": "Soundbar (Universal)",
9882 "values": {"hide_in_ui": True, "linked_protocol_ids": [ap_id]},
9883 },
9884 native_id: {
9885 "provider": "cast",
9886 "enabled": True,
9887 "name": "Kitchen",
9888 "default_name": "Soundbar",
9889 "values": {"linked_protocol_ids": [ap_id]},
9890 },
9891 ap_id: {
9892 "player_type": "protocol",
9893 "enabled": True,
9894 "values": {"protocol_parent_id": universal_id},
9895 },
9896 },
9897 CONF_PLAYER_DSP: {
9898 universal_id: {"enabled": True, "filters": ["universal"]},
9899 native_id: {"enabled": False, "filters": ["native"]},
9900 },
9901 CONF_PLAYER_QUEUES: {
9902 universal_id: {
9903 "queue_id": universal_id,
9904 "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
9905 },
9906 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 2}},
9907 },
9908 }
9909 _wire_nested_config(mock_mass, store)
9910 mock_mass.config.save_player_config = AsyncMock()
9911 scheduled_tasks: list[Awaitable[object]] = []
9912 mock_mass.create_task = MagicMock(
9913 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
9914 )
9915 controller = PlayerController(mock_mass)
9916 controller._players = {}
9917 mock_mass.players = controller
9918 mock_mass.player_queues = MagicMock()
9919 provider = create_mock_universal_provider(mock_mass)
9920
9921 await provider._restore_player(universal_id)
9922
9923 players_tree = store[CONF_PLAYERS]
9924 # the native player's own custom name and DSP config win
9925 assert players_tree[native_id]["name"] == "Kitchen"
9926 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": False, "filters": ["native"]}}
9927 # queue values merge without overwriting the native queue's own values
9928 assert store[CONF_PLAYER_QUEUES] == {
9929 native_id: {
9930 "queue_id": native_id,
9931 "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
9932 }
9933 }
9934 # non-conflicting values still migrate and the wrapper config is gone
9935 assert players_tree[native_id]["values"]["hide_in_ui"] is True
9936 assert universal_id not in players_tree
9937 for task in scheduled_tasks:
9938 await task
9939
9940 @pytest.mark.asyncio
9941 async def test_restore_native_claims_keeps_config_while_wrapper_registered(
9942 self, mock_mass: MagicMock
9943 ) -> None:
9944 """No config carry-over/deletion while the wrapper is still registered."""
9945 provider = create_mock_universal_provider(mock_mass)
9946 universal_id = "up_test"
9947 ap_id = "airplay_1"
9948
9949 all_configs = {
9950 universal_id: {
9951 "values": {
9952 "linked_protocol_ids": [ap_id],
9953 "device_identifiers": {},
9954 "device_info": {},
9955 },
9956 "name": "Test UP",
9957 },
9958 "native_a": {
9959 "provider": "dlna",
9960 "values": {"linked_protocol_ids": [ap_id]},
9961 },
9962 ap_id: {
9963 "player_type": "protocol",
9964 "values": {"protocol_parent_id": universal_id},
9965 },
9966 }
9967
9968 def _config_get(key: str, default: object = None) -> object:
9969 if key == CONF_PLAYERS:
9970 return all_configs
9971 if key.startswith(f"{CONF_PLAYERS}/"):
9972 pid = key.split("/", 1)[1]
9973 return all_configs.get(pid, default)
9974 return default
9975
9976 mock_mass.config.get.side_effect = _config_get
9977 mock_mass.config.set = MagicMock()
9978 mock_mass.config.save_player_config = AsyncMock()
9979 mock_mass.players = MagicMock()
9980 mock_mass.players.get_player = MagicMock(return_value=MagicMock())
9981
9982 await provider._restore_player(universal_id)
9983
9984 # the protocol link repair still runs
9985 parent_restorations = {
9986 call.args[0]: call.args[1]
9987 for call in mock_mass.config.set.call_args_list
9988 if "protocol_parent_id" in call.args[0]
9989 }
9990 assert parent_restorations == {
9991 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
9992 }
9993 # but the registered wrapper keeps its config and group memberships
9994 mock_mass.players._migrate_universal_player_config.assert_not_called()
9995 mock_mass.players._repoint_group_memberships.assert_not_called()
9996 mock_mass.players.delete_player_config.assert_not_called()
9997 mock_mass.player_queues.on_player_remove.assert_not_called()
9998
9999
10000class TestParentDisableCascade:
10001 """Tests for cascading enable/disable from a native parent to its protocols."""
10002
10003 @staticmethod
10004 def _make_config(player_id: str, *, enabled: bool, provider: str = "sonos") -> MagicMock:
10005 """Build a minimal PlayerConfig mock for on_player_config_change."""
10006 config = MagicMock()
10007 config.player_id = player_id
10008 config.provider = provider
10009 config.enabled = enabled
10010 config.values = {}
10011 return config
10012
10013 @pytest.mark.asyncio
10014 async def test_disabling_native_parent_cascades_disable_to_linked_protocols(
10015 self, mock_mass: MagicMock
10016 ) -> None:
10017 """Disabling a native parent saves enabled=False for each linked protocol."""
10018 controller = PlayerController(mock_mass)
10019
10020 native_provider = MockProvider("sonos", mass=mock_mass)
10021 native_player = MockPlayer(
10022 native_provider,
10023 "sonos_native",
10024 "Native Speaker",
10025 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10026 )
10027 # Bypass the power-off branch in on_player_config_change
10028 native_player._attr_available = False
10029 native_player.set_linked_output_protocols(
10030 [
10031 LinkedOutputProtocol(
10032 output_protocol_id="ap_1",
10033 protocol_domain="airplay",
10034 priority=10,
10035 ),
10036 LinkedOutputProtocol(
10037 output_protocol_id="dlna_1",
10038 protocol_domain="dlna",
10039 priority=20,
10040 ),
10041 ]
10042 )
10043 controller._players = {native_player.player_id: native_player}
10044
10045 protocol_configs = {
10046 "ap_1": {"enabled": True, "player_type": "protocol"},
10047 "dlna_1": {"enabled": True, "player_type": "protocol"},
10048 }
10049
10050 def _config_get(key: str, default: object = None) -> object:
10051 if key.startswith(f"{CONF_PLAYERS}/"):
10052 pid = key.split("/", 1)[1]
10053 return protocol_configs.get(pid, default)
10054 return default
10055
10056 mock_mass.config.get = MagicMock(side_effect=_config_get)
10057 mock_mass.config.save_player_config = MagicMock()
10058 mock_mass.create_task = MagicMock()
10059 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10060
10061 config = self._make_config(native_player.player_id, enabled=False)
10062
10063 await controller.on_player_config_change(config, {ATTR_ENABLED})
10064
10065 called = mock_mass.config.save_player_config.call_args_list
10066 assert {call.args[0] for call in called} == {"ap_1", "dlna_1"}
10067 for call in called:
10068 assert call.args[1] == {ATTR_ENABLED: False}
10069
10070 @pytest.mark.asyncio
10071 async def test_enabling_native_parent_cascades_enable_to_linked_protocols(
10072 self, mock_mass: MagicMock
10073 ) -> None:
10074 """Re-enabling a native parent saves enabled=True for each (currently disabled) protocol."""
10075 controller = PlayerController(mock_mass)
10076
10077 native_provider = MockProvider("sonos", mass=mock_mass)
10078 native_player = MockPlayer(
10079 native_provider,
10080 "sonos_native",
10081 "Native Speaker",
10082 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10083 )
10084 native_player._attr_available = False
10085 native_player.set_linked_output_protocols(
10086 [
10087 LinkedOutputProtocol(
10088 output_protocol_id="ap_1",
10089 protocol_domain="airplay",
10090 priority=10,
10091 ),
10092 ]
10093 )
10094 controller._players = {native_player.player_id: native_player}
10095
10096 protocol_configs = {
10097 "ap_1": {"enabled": False, "player_type": "protocol"},
10098 }
10099
10100 def _config_get(key: str, default: object = None) -> object:
10101 if key.startswith(f"{CONF_PLAYERS}/"):
10102 pid = key.split("/", 1)[1]
10103 return protocol_configs.get(pid, default)
10104 return default
10105
10106 mock_mass.config.get = MagicMock(side_effect=_config_get)
10107 mock_mass.config.save_player_config = MagicMock()
10108 mock_mass.create_task = MagicMock()
10109 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10110
10111 config = self._make_config(native_player.player_id, enabled=True)
10112
10113 await controller.on_player_config_change(config, {ATTR_ENABLED})
10114
10115 mock_mass.config.save_player_config.assert_called_once_with("ap_1", {ATTR_ENABLED: True})
10116
10117 @pytest.mark.asyncio
10118 async def test_disabling_native_parent_falls_back_to_cached_protocol_ids(
10119 self, mock_mass: MagicMock
10120 ) -> None:
10121 """When the parent isn't registered, cascade uses cached linked_protocol_ids."""
10122 controller = PlayerController(mock_mass)
10123
10124 # Parent is not in _players (e.g. provider didn't register a disabled parent)
10125 controller._players = {}
10126
10127 cached_protocol_ids = ["ap_cached", "dlna_cached"]
10128 protocol_configs = {
10129 "ap_cached": {"enabled": True, "player_type": "protocol"},
10130 "dlna_cached": {"enabled": True, "player_type": "protocol"},
10131 }
10132
10133 def _config_get(key: str, default: object = None) -> object:
10134 if key == f"{CONF_PLAYERS}/unregistered_native/values/linked_protocol_ids":
10135 return list(cached_protocol_ids)
10136 if key.startswith(f"{CONF_PLAYERS}/"):
10137 pid = key.split("/", 1)[1]
10138 return protocol_configs.get(pid, default)
10139 return default
10140
10141 mock_mass.config.get = MagicMock(side_effect=_config_get)
10142 mock_mass.config.save_player_config = MagicMock()
10143 mock_mass.create_task = MagicMock()
10144 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10145
10146 config = self._make_config("unregistered_native", enabled=False)
10147
10148 await controller.on_player_config_change(config, {ATTR_ENABLED})
10149
10150 called_ids = {call.args[0] for call in mock_mass.config.save_player_config.call_args_list}
10151 assert called_ids == set(cached_protocol_ids)
10152
10153 @pytest.mark.asyncio
10154 async def test_toggling_protocol_player_does_not_cascade(self, mock_mass: MagicMock) -> None:
10155 """A PROTOCOL-type player flipping enabled must not trigger any cascade."""
10156 controller = PlayerController(mock_mass)
10157
10158 ap_provider = MockProvider("airplay", mass=mock_mass)
10159 ap_player = MockPlayer(
10160 ap_provider,
10161 "ap_1",
10162 "AirPlay",
10163 player_type=PlayerType.PROTOCOL,
10164 )
10165 ap_player._attr_available = False
10166 controller._players = {ap_player.player_id: ap_player}
10167
10168 mock_mass.config.save_player_config = MagicMock()
10169 mock_mass.create_task = MagicMock()
10170 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
10171
10172 config = self._make_config(ap_player.player_id, enabled=False, provider="airplay")
10173
10174 await controller.on_player_config_change(config, {ATTR_ENABLED})
10175
10176 mock_mass.config.save_player_config.assert_not_called()
10177
10178
10179class TestDelayedEvalDisabledParentGuard:
10180 """Tests for the guard in _delayed_protocol_evaluation against disabled parents."""
10181
10182 @pytest.mark.asyncio
10183 async def test_skips_universal_creation_when_cached_parent_is_disabled(
10184 self, mock_mass: MagicMock
10185 ) -> None:
10186 """No universal player is created when the protocol's cached parent is disabled."""
10187 controller = PlayerController(mock_mass)
10188
10189 dlna_provider = MockProvider("dlna", mass=mock_mass)
10190 protocol_player = MockPlayer(
10191 dlna_provider,
10192 "dlna_orphan",
10193 "Orphan DLNA",
10194 player_type=PlayerType.PROTOCOL,
10195 )
10196 protocol_player.set_initialized()
10197 controller._players = {protocol_player.player_id: protocol_player}
10198
10199 cached_parent_id = "sonos_disabled"
10200
10201 def _config_get(key: str, default: object = None) -> object:
10202 if key.endswith("/protocol_parent_id"):
10203 return cached_parent_id
10204 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
10205 return {"enabled": False, "provider": "sonos"}
10206 return default
10207
10208 mock_mass.config.get = MagicMock(side_effect=_config_get)
10209
10210 with (
10211 patch.object(controller, "_try_link_to_existing_player", return_value=False),
10212 patch.object(controller, "_find_matching_universal_player", return_value=None),
10213 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
10214 ):
10215 await controller._delayed_protocol_evaluation(protocol_player.player_id)
10216
10217 mock_create_up.assert_not_called()
10218
10219 @pytest.mark.asyncio
10220 async def test_creates_universal_when_cached_parent_is_enabled_but_offline(
10221 self, mock_mass: MagicMock
10222 ) -> None:
10223 """Sanity check: an enabled-but-unregistered cached parent still falls through."""
10224 controller = PlayerController(mock_mass)
10225
10226 dlna_provider = MockProvider("dlna", mass=mock_mass)
10227 protocol_player = MockPlayer(
10228 dlna_provider,
10229 "dlna_orphan",
10230 "Orphan DLNA",
10231 player_type=PlayerType.PROTOCOL,
10232 )
10233 protocol_player.set_initialized()
10234 controller._players = {protocol_player.player_id: protocol_player}
10235
10236 cached_parent_id = "sonos_offline"
10237
10238 def _config_get(key: str, default: object = None) -> object:
10239 if key.endswith("/protocol_parent_id"):
10240 return cached_parent_id
10241 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
10242 return {"enabled": True, "provider": "sonos"}
10243 return default
10244
10245 mock_mass.config.get = MagicMock(side_effect=_config_get)
10246
10247 with (
10248 patch.object(controller, "_try_link_to_existing_player", return_value=False),
10249 patch.object(controller, "_find_matching_universal_player", return_value=None),
10250 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
10251 patch.object(
10252 controller,
10253 "_find_matching_protocol_players",
10254 return_value=[protocol_player],
10255 ),
10256 ):
10257 await controller._delayed_protocol_evaluation(protocol_player.player_id)
10258
10259 mock_create_up.assert_called_once()
10260
10261
10262class TestUniversalPlayerCurrentMedia:
10263 """Tests for current_media resolution while an output protocol is active."""
10264
10265 def test_current_media_surfaces_active_output_protocol(self, mock_mass: MagicMock) -> None:
10266 """
10267 While outputting via a protocol player, surface that player's raw current_media.
10268
10269 A sync group mirrors its leader's raw current_media to resolve the active queue
10270 item. When the leader is a universal player outputting via AirPlay, returning None
10271 here strands the group with a frozen current item (regression guard for grouped
10272 AirPlay live-metadata never refreshing).
10273 """
10274 protocol_provider = MockProvider("airplay", mass=mock_mass)
10275 protocol_player = MockPlayer(
10276 protocol_provider, "ap_1", "AirPlay", player_type=PlayerType.PROTOCOL
10277 )
10278 media = PlayerMedia(
10279 uri="http://x/flow/sess/queue_1/item_1/ap_1.flac", queue_item_id="item_1"
10280 )
10281 protocol_player._attr_current_media = media
10282
10283 universal = _create_universal_player(mock_mass, "up_1", "Universal", ["ap_1"])
10284 players = {"up_1": universal, "ap_1": protocol_player}
10285 mock_mass.players.get_player = MagicMock(side_effect=lambda pid: players.get(pid))
10286
10287 # without an active output protocol there is nothing to surface
10288 assert universal.current_media is None
10289
10290 universal.set_active_output_protocol("ap_1")
10291 assert universal.current_media is media
10292
10293 def test_current_media_none_for_native_output(self, mock_mass: MagicMock) -> None:
10294 """A native output protocol has no separate protocol player to surface."""
10295 universal = _create_universal_player(mock_mass, "up_1", "Universal", [])
10296 universal.set_active_output_protocol("native")
10297 assert universal.current_media is None
10298
10299
10300class TestSelfReferentialProtocolLinks:
10301 """
10302 Tests for the self-link bug when a Sendspin device changes player type.
10303
10304 A Sendspin device keeps a stable client_id (the MA player_id) across reflashes.
10305 When it first registers as a protocol player it gets wrapped in a universal
10306 player that caches that id as one of its protocols. If the device later
10307 reconnects as a non-protocol player (e.g. its roles changed to display-only)
10308 with the same id, the replace logic could link it to itself, hiding it.
10309 """
10310
10311 def test_add_protocol_link_refuses_self(self, mock_mass: MagicMock) -> None:
10312 """A player must never become its own protocol parent."""
10313 controller = PlayerController(mock_mass)
10314 provider = MockProvider("sendspin", mass=mock_mass)
10315 player = MockPlayer(provider, "esp_client", "ESP", player_type=PlayerType.PROTOCOL)
10316 mock_mass.players = controller
10317 controller._players = {"esp_client": player}
10318
10319 controller._add_protocol_link(player, player, "sendspin")
10320
10321 assert player.protocol_parent_id is None
10322 assert all(
10323 link.output_protocol_id != "esp_client" for link in player.linked_output_protocols
10324 )
10325
10326 def test_type_change_replaces_universal_without_self_link(self, mock_mass: MagicMock) -> None:
10327 """Replacing a universal player with the same-id native player must not self-link."""
10328 universal = _create_universal_player(
10329 mock_mass, "upespclient", "ESP", protocol_player_ids=["esp_client"]
10330 )
10331 display_provider = MockProvider("sendspin", mass=mock_mass)
10332 display_player = MockPlayer(
10333 display_provider, "esp_client", "ESP", player_type=PlayerType.DISPLAY
10334 )
10335
10336 store: dict[str, Any] = {"players/esp_client": {"enabled": True}}
10337 mock_mass.config.get.side_effect = lambda key, default=None: store.get(key, default)
10338 mock_mass.config.set.side_effect = lambda key, value: store.__setitem__(key, value)
10339 mock_mass.create_task = MagicMock()
10340 mock_mass.players = controller = PlayerController(mock_mass)
10341 controller._players = {"upespclient": universal, "esp_client": display_player}
10342
10343 controller._check_replace_universal_player(display_player)
10344
10345 assert display_player.protocol_parent_id is None
10346 assert store.get("players/esp_client/values/protocol_parent_id") != "esp_client"
10347 assert "esp_client" not in store.get("players/esp_client/values/linked_protocol_ids", [])
10348 # the obsolete universal player must not keep listing the native player,
10349 # or its permanent cleanup would re-evaluate it as an orphaned protocol
10350 assert "esp_client" not in universal._protocol_player_ids
10351
10352 async def test_migrate_clears_persisted_self_referential_link(self) -> None:
10353 """Config migration scrubs a self-referential link left by an older version."""
10354 config = ConfigController.__new__(ConfigController)
10355 config._data = {
10356 CONF_PLAYERS: {
10357 "esp_client": {
10358 "provider": "sendspin",
10359 "player_id": "esp_client",
10360 "values": {
10361 "protocol_parent_id": "esp_client",
10362 "linked_protocol_ids": ["esp_client"],
10363 },
10364 }
10365 }
10366 }
10367 await migrate(config._data)
10368
10369 values = config._data[CONF_PLAYERS]["esp_client"]["values"]
10370 assert values["protocol_parent_id"] is None
10371 assert "esp_client" not in values["linked_protocol_ids"]
10372
10373
10374class TestDerivedProtocolLinking:
10375 """
10376 Tests for derived protocol players (players with underlying_player_id set).
10377
10378 Derived protocol players (e.g. Sendspin bridges riding on an AirPlay player)
10379 resolve their parent deterministically via the underlying player instead of
10380 device identifier matching, and never seed universal players themselves.
10381 """
10382
10383 @staticmethod
10384 def _make_derived_player(
10385 mock_mass: MagicMock, player_id: str, underlying_player_id: str
10386 ) -> MockPlayer:
10387 """Create a derived protocol player without any device identifiers."""
10388 provider = MockProvider("sendspin", mass=mock_mass)
10389 player = MockPlayer(
10390 provider,
10391 player_id,
10392 f"Bridge {player_id}",
10393 player_type=PlayerType.PROTOCOL,
10394 )
10395 player._attr_underlying_player_id = underlying_player_id
10396 player.set_initialized()
10397 return player
10398
10399 def test_derived_links_to_parent_of_linked_underlying(self, mock_mass: MagicMock) -> None:
10400 """Test a derived player joins the parent of its (linked) underlying player."""
10401 controller = PlayerController(mock_mass)
10402 mock_mass.config.get = MagicMock(return_value=[])
10403
10404 native = MockPlayer(
10405 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
10406 )
10407 native.set_initialized()
10408 ap_player = MockPlayer(
10409 MockProvider("airplay", mass=mock_mass),
10410 "ap_1",
10411 "AVR AirPlay",
10412 player_type=PlayerType.PROTOCOL,
10413 )
10414 ap_player.set_initialized()
10415 controller._players = {"native_1": native, "ap_1": ap_player}
10416 controller._add_protocol_link(native, ap_player, "airplay")
10417
10418 # The derived player has NO identifiers - the edge alone must resolve it
10419 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
10420 controller._players["spb_1"] = derived
10421 controller._try_link_protocol_to_native(derived)
10422
10423 assert derived.protocol_parent_id == "native_1"
10424 # the derived link carries its base output, the non-derived link does not
10425 links = {link.output_protocol_id: link for link in native.linked_output_protocols}
10426 assert links["spb_1"].derived_from == "ap_1"
10427 assert links["ap_1"].derived_from is None
10428
10429 def test_waiting_derived_follows_when_underlying_links(self, mock_mass: MagicMock) -> None:
10430 """Test a derived player that registered early follows once its underlying links."""
10431 controller = PlayerController(mock_mass)
10432 mock_mass.config.get = MagicMock(return_value=[])
10433
10434 native = MockPlayer(
10435 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
10436 )
10437 native.set_initialized()
10438 ap_player = MockPlayer(
10439 MockProvider("airplay", mass=mock_mass),
10440 "ap_1",
10441 "AVR AirPlay",
10442 player_type=PlayerType.PROTOCOL,
10443 )
10444 ap_player.set_initialized()
10445 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
10446 controller._players = {"native_1": native, "ap_1": ap_player, "spb_1": derived}
10447
10448 # Underlying is not linked yet: derived stays unlinked
10449 controller._try_link_protocol_to_native(derived)
10450 assert derived.protocol_parent_id is None
10451
10452 # Linking the underlying player propagates to the waiting derived player
10453 controller._add_protocol_link(native, ap_player, "airplay")
10454 assert derived.protocol_parent_id == "native_1"
10455
10456 def test_derived_links_directly_to_non_protocol_underlying(self, mock_mass: MagicMock) -> None:
10457 """Test a derived player parents to the underlying player itself when not a protocol."""
10458 controller = PlayerController(mock_mass)
10459 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
10460
10461 local_player = MockPlayer(
10462 MockProvider("local_audio", mass=mock_mass),
10463 "local_1",
10464 "Dummy Output",
10465 player_type=PlayerType.PLAYER,
10466 )
10467 local_player.set_initialized()
10468 derived = self._make_derived_player(mock_mass, "spb_1", "local_1")
10469 controller._players = {"spb_1": derived, "local_1": local_player}
10470
10471 # The final derived pass of _try_link_protocols_to_native picks it up
10472 controller._try_link_protocols_to_native(local_player)
10473
10474 assert derived.protocol_parent_id == "local_1"
10475 # riding on the parent player itself normalizes the base output to "native"
10476 links = {link.output_protocol_id: link for link in local_player.linked_output_protocols}
10477 assert links["spb_1"].derived_from == "native"
10478
10479 @pytest.mark.asyncio
10480 async def test_derived_delayed_eval_never_creates_universal_player(
10481 self, mock_mass: MagicMock
10482 ) -> None:
10483 """Test delayed evaluation of a derived player never wraps it in a universal player."""
10484 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
10485 mock_mass.config.get = MagicMock(return_value=[])
10486
10487 derived = self._make_derived_player(mock_mass, "spb_1", "ap_missing")
10488 controller._players = {"spb_1": derived}
10489
10490 await controller._delayed_protocol_evaluation("spb_1")
10491
10492 assert derived.protocol_parent_id is None
10493 assert not any(pid.startswith("up") for pid in controller._players)
10494
10495 def test_derived_link_refused_on_domain_duplicate(self, mock_mass: MagicMock) -> None:
10496 """Test a derived player stays unlinked when the parent already has its domain."""
10497 controller = PlayerController(mock_mass)
10498 mock_mass.config.get = MagicMock(return_value=[])
10499
10500 native = MockPlayer(
10501 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
10502 )
10503 native.set_initialized()
10504 ap_player = MockPlayer(
10505 MockProvider("airplay", mass=mock_mass),
10506 "ap_1",
10507 "AVR AirPlay",
10508 player_type=PlayerType.PROTOCOL,
10509 )
10510 ap_player.set_initialized()
10511 other_sendspin = MockPlayer(
10512 MockProvider("sendspin", mass=mock_mass),
10513 "sendspin_native_client",
10514 "AVR Sendspin",
10515 player_type=PlayerType.PROTOCOL,
10516 )
10517 other_sendspin.set_initialized()
10518 controller._players = {
10519 "native_1": native,
10520 "ap_1": ap_player,
10521 "sendspin_native_client": other_sendspin,
10522 }
10523 controller._add_protocol_link(native, ap_player, "airplay")
10524 controller._add_protocol_link(native, other_sendspin, "sendspin")
10525
10526 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
10527 controller._players["spb_1"] = derived
10528 controller._try_link_protocol_to_native(derived)
10529
10530 assert derived.protocol_parent_id is None
10531
10532 def test_derived_joins_universal_parent(self, mock_mass: MagicMock) -> None:
10533 """Test a derived player joins the universal player of its underlying player."""
10534 controller = PlayerController(mock_mass)
10535 mock_mass.config.get = MagicMock(return_value=[])
10536
10537 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
10538 if hasattr(coro, "close"):
10539 coro.close()
10540
10541 mock_mass.create_task = MagicMock(side_effect=_close_coro)
10542
10543 universal = _create_universal_player(
10544 mock_mass,
10545 "up_aabbccddeeff",
10546 "Living Room Speaker",
10547 protocol_player_ids=["ap_1"],
10548 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10549 )
10550 ap_player = MockPlayer(
10551 MockProvider("airplay", mass=mock_mass),
10552 "ap_1",
10553 "AirPlay Speaker",
10554 player_type=PlayerType.PROTOCOL,
10555 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10556 )
10557 ap_player.set_initialized()
10558 controller._players = {"up_aabbccddeeff": universal, "ap_1": ap_player}
10559 controller._add_protocol_link(universal, ap_player, "airplay")
10560
10561 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
10562 controller._players["spb_1"] = derived
10563 controller._try_link_protocol_to_native(derived)
10564
10565 assert derived.protocol_parent_id == "up_aabbccddeeff"
10566 assert "spb_1" in universal._protocol_player_ids
10567
10568 @pytest.mark.parametrize("derived_first", [False, True])
10569 def test_derived_stays_with_refused_base_on_replacement(
10570 self, mock_mass: MagicMock, derived_first: bool
10571 ) -> None:
10572 """
10573 Keep a derived protocol with its base when the base's link is refused.
10574
10575 The native player absorbs the first universal player's cast protocol, so the
10576 second universal player's cast protocol is refused and stays behind. The
10577 sendspin bridge riding on it must stay behind too instead of moving on its own,
10578 whichever order the two links are listed in.
10579 """
10580 controller = PlayerController(mock_mass)
10581 up_provider = create_mock_universal_provider(mock_mass)
10582
10583 config_store: dict[str, Any] = {
10584 f"players/{player_id}": {"enabled": True}
10585 for player_id in ("native", "up_a", "up_b", "cc_1", "cc_2", "spb_2")
10586 }
10587 mock_mass.config.get = MagicMock(side_effect=config_store.get)
10588 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
10589 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
10590 mock_mass.players = controller
10591 mock_mass.player_queues = MagicMock()
10592 mock_mass.call_later = MagicMock()
10593 mock_mass.loop = MagicMock()
10594 mock_mass.create_task = MagicMock()
10595
10596 mac = "AA:BB:CC:DD:EE:FF"
10597 identifiers = {IdentifierType.MAC_ADDRESS: mac}
10598 universals = {}
10599 for universal_id, protocol_ids in (("up_a", ["cc_1"]), ("up_b", ["cc_2", "spb_2"])):
10600 universal = UniversalPlayer(
10601 provider=up_provider,
10602 player_id=universal_id,
10603 name=f"Soundbar ({universal_id})",
10604 device_info=DeviceInfo(model="Test", manufacturer="Test"),
10605 protocol_player_ids=protocol_ids,
10606 )
10607 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
10608 universal._cache.clear()
10609 universal.update_state(signal_event=False)
10610 universal.set_initialized()
10611 universals[universal_id] = universal
10612
10613 native = MockPlayer(
10614 MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
10615 )
10616 native.set_initialized()
10617
10618 cast_provider = MockProvider("chromecast", mass=mock_mass)
10619 cast_players = {}
10620 for cast_id in ("cc_1", "cc_2"):
10621 cast_player = MockPlayer(
10622 cast_provider,
10623 cast_id,
10624 f"Cast {cast_id}",
10625 player_type=PlayerType.PROTOCOL,
10626 identifiers=identifiers,
10627 )
10628 cast_player.set_initialized()
10629 cast_players[cast_id] = cast_player
10630
10631 derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
10632
10633 controller._players = {
10634 **universals,
10635 **cast_players,
10636 "native": native,
10637 "spb_2": derived,
10638 }
10639 controller._add_protocol_link(universals["up_a"], cast_players["cc_1"], "chromecast")
10640 if derived_first:
10641 controller._add_protocol_link(universals["up_b"], derived, "sendspin")
10642 controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
10643 else:
10644 controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
10645 controller._add_protocol_link(universals["up_b"], derived, "sendspin")
10646
10647 controller._check_replace_universal_player(native)
10648
10649 # the second universal player keeps the refused cast protocol and its bridge
10650 assert cast_players["cc_2"].protocol_parent_id == "up_b"
10651 assert derived.protocol_parent_id == "up_b"
10652 assert {link.output_protocol_id for link in universals["up_b"].linked_output_protocols} == {
10653 "cc_2",
10654 "spb_2",
10655 }
10656 # the native player only took over the protocol it could actually claim
10657 assert {link.output_protocol_id for link in native.linked_output_protocols} == {"cc_1"}
10658 assert "spb_2" not in (config_store.get("players/native/values/linked_protocol_ids") or [])
10659
10660 def test_base_stays_with_refused_derived_on_replacement(self, mock_mass: MagicMock) -> None:
10661 """
10662 Keep a base protocol with its derived protocol when the derived link is refused.
10663
10664 The native player already serves the sendspin domain, so the universal player's
10665 sendspin bridge cannot move. The cast protocol it rides on must stay behind with
10666 it instead of moving on its own.
10667 """
10668 controller = PlayerController(mock_mass)
10669 up_provider = create_mock_universal_provider(mock_mass)
10670
10671 config_store: dict[str, Any] = {
10672 f"players/{player_id}": {"enabled": True}
10673 for player_id in ("native", "up_b", "cc_2", "spb_2", "spb_native")
10674 }
10675 mock_mass.config.get = MagicMock(side_effect=config_store.get)
10676 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
10677 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
10678 mock_mass.players = controller
10679 mock_mass.player_queues = MagicMock()
10680 mock_mass.call_later = MagicMock()
10681 mock_mass.loop = MagicMock()
10682 mock_mass.create_task = MagicMock()
10683
10684 mac = "AA:BB:CC:DD:EE:FF"
10685 identifiers = {IdentifierType.MAC_ADDRESS: mac}
10686 universal = UniversalPlayer(
10687 provider=up_provider,
10688 player_id="up_b",
10689 name="Soundbar (Universal)",
10690 device_info=DeviceInfo(model="Test", manufacturer="Test"),
10691 protocol_player_ids=["cc_2", "spb_2"],
10692 )
10693 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
10694 universal._cache.clear()
10695 universal.update_state(signal_event=False)
10696 universal.set_initialized()
10697
10698 native = MockPlayer(
10699 MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
10700 )
10701 native.set_initialized()
10702 cast_player = MockPlayer(
10703 MockProvider("chromecast", mass=mock_mass),
10704 "cc_2",
10705 "Cast",
10706 player_type=PlayerType.PROTOCOL,
10707 identifiers=identifiers,
10708 )
10709 cast_player.set_initialized()
10710 derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
10711 sendspin_native = MockPlayer(
10712 MockProvider("sendspin", mass=mock_mass),
10713 "spb_native",
10714 "Soundbar (Sendspin)",
10715 player_type=PlayerType.PROTOCOL,
10716 identifiers=identifiers,
10717 )
10718 sendspin_native.set_initialized()
10719
10720 controller._players = {
10721 "up_b": universal,
10722 "native": native,
10723 "cc_2": cast_player,
10724 "spb_2": derived,
10725 "spb_native": sendspin_native,
10726 }
10727 controller._add_protocol_link(universal, cast_player, "chromecast")
10728 controller._add_protocol_link(universal, derived, "sendspin")
10729 controller._add_protocol_link(native, sendspin_native, "sendspin")
10730
10731 controller._check_replace_universal_player(native)
10732
10733 # the bridge could not move, so the cast protocol it rides on stays with it
10734 assert derived.protocol_parent_id == "up_b"
10735 assert cast_player.protocol_parent_id == "up_b"
10736 assert {link.output_protocol_id for link in universal.linked_output_protocols} == {
10737 "cc_2",
10738 "spb_2",
10739 }
10740 assert {link.output_protocol_id for link in native.linked_output_protocols} == {
10741 "spb_native"
10742 }
10743
10744 def test_save_underlying_player_id_persists_and_clears(self, mock_mass: MagicMock) -> None:
10745 """Test the derived edge is persisted, cleared when revoked and skipped otherwise."""
10746 controller = PlayerController(mock_mass)
10747 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
10748 conf_key = "players/spb_1/values/underlying_player_id"
10749 stored: dict[str, Any] = {"players/spb_1": {"values": {}}}
10750 mock_mass.config.get = MagicMock(
10751 side_effect=lambda key, default=None: stored.get(key, default)
10752 )
10753 mock_mass.config.set = MagicMock(side_effect=stored.__setitem__)
10754
10755 # derived player: edge gets persisted
10756 controller._save_underlying_player_id(derived)
10757 assert stored[conf_key] == "ap_1"
10758
10759 # edge revoked (e.g. bridge client turned web player): stale value is cleared
10760 derived._attr_underlying_player_id = None
10761 controller._save_underlying_player_id(derived)
10762 assert stored[conf_key] is None
10763
10764 # nothing persisted and no edge: no write at all
10765 mock_mass.config.set.reset_mock()
10766 del stored[conf_key]
10767 controller._save_underlying_player_id(derived)
10768 mock_mass.config.set.assert_not_called()
10769
10770 # player config gone: never create partial entries
10771 derived._attr_underlying_player_id = "ap_1"
10772 del stored["players/spb_1"]
10773 controller._save_underlying_player_id(derived)
10774 mock_mass.config.set.assert_not_called()
10775
10776
10777class TestLocalAudioPlayerPromotion:
10778 """
10779 Tests for the local_audio reshape: attribution stub -> regular native player.
10780
10781 The local_audio device player (bare device-uuid player_id) is a regular,
10782 visible PLAYER that parents its Sendspin bridge directly. On upgrade, the
10783 previously auto-created universal player wrapper (up<uuid-hex>) must be
10784 absorbed by the native player without re-wrapping it.
10785 """
10786
10787 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
10788 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
10789
10790 def test_native_local_player_absorbs_universal_wrapper(self, mock_mass: MagicMock) -> None:
10791 """The native player takes over the bridge protocol and the wrapper is emptied."""
10792 universal = _create_universal_player(
10793 mock_mass,
10794 self.UNIVERSAL_ID,
10795 "Dummy Output",
10796 protocol_player_ids=[self.DEVICE_UUID, "spb_1"],
10797 )
10798 native = MockPlayer(
10799 MockProvider("local_audio", mass=mock_mass),
10800 self.DEVICE_UUID,
10801 "Dummy Output",
10802 player_type=PlayerType.PLAYER,
10803 identifiers={IdentifierType.UUID: self.DEVICE_UUID},
10804 )
10805 native.set_initialized()
10806 spb = MockPlayer(
10807 MockProvider("sendspin", mass=mock_mass),
10808 "spb_1",
10809 "Dummy Output Sendspin",
10810 player_type=PlayerType.PROTOCOL,
10811 )
10812 spb._attr_underlying_player_id = self.DEVICE_UUID
10813 spb.set_initialized()
10814
10815 store: dict[str, Any] = {
10816 f"players/{self.DEVICE_UUID}": {"enabled": True},
10817 "players/spb_1": {"enabled": True},
10818 }
10819 mock_mass.config.get = MagicMock(
10820 side_effect=lambda key, default=None: store.get(key, default)
10821 )
10822 mock_mass.config.set = MagicMock(side_effect=store.__setitem__)
10823
10824 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
10825 if hasattr(coro, "close"):
10826 coro.close()
10827
10828 mock_mass.create_task = MagicMock(side_effect=_close_coro)
10829 mock_mass.players = controller = PlayerController(mock_mass)
10830 controller._players = {
10831 self.UNIVERSAL_ID: universal,
10832 self.DEVICE_UUID: native,
10833 "spb_1": spb,
10834 }
10835 controller._add_protocol_link(universal, spb, "sendspin")
10836
10837 controller._try_link_protocols_to_native(native)
10838
10839 assert spb.protocol_parent_id == self.DEVICE_UUID
10840 assert [link.output_protocol_id for link in native.linked_output_protocols] == ["spb_1"]
10841 # the obsolete wrapper keeps neither the bridge nor the native player itself,
10842 # so its permanent cleanup has no orphaned protocols to re-evaluate
10843 assert universal._protocol_player_ids == []
10844 assert store.get("players/spb_1/values/protocol_parent_id") == self.DEVICE_UUID
10845
10846 async def test_delayed_eval_skips_type_changed_player(self, mock_mass: MagicMock) -> None:
10847 """A pending evaluation for a player that is not (or no longer) a protocol is a no-op."""
10848 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
10849 mock_mass.config.get = MagicMock(return_value=[])
10850
10851 native = MockPlayer(
10852 MockProvider("local_audio", mass=mock_mass),
10853 self.DEVICE_UUID,
10854 "Dummy Output",
10855 player_type=PlayerType.PLAYER,
10856 )
10857 native.set_initialized()
10858 controller._players = {self.DEVICE_UUID: native}
10859
10860 await controller._delayed_protocol_evaluation(self.DEVICE_UUID)
10861
10862 assert native.protocol_parent_id is None
10863 assert not any(pid.startswith("up") for pid in controller._players)
10864
10865
10866class TestLocalAudioStubMigration:
10867 """Tests for the config migration that promotes local_audio stubs to regular players."""
10868
10869 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
10870 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
10871
10872 def _make_data(self) -> dict[str, Any]:
10873 """Build a settings dict as persisted by the previous local_audio setup."""
10874 return {
10875 CONF_PLAYERS: {
10876 self.DEVICE_UUID: {
10877 "player_id": self.DEVICE_UUID,
10878 "provider": "local_audio",
10879 "player_type": "protocol",
10880 "enabled": True,
10881 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
10882 },
10883 self.UNIVERSAL_ID: {
10884 "player_id": self.UNIVERSAL_ID,
10885 "provider": "universal_player",
10886 "player_type": "group",
10887 "enabled": False,
10888 "name": "Kitchen Output",
10889 "values": {
10890 # ghost_spb has no config anymore (device gone) - must not crash
10891 "linked_protocol_ids": [self.DEVICE_UUID, "spb_1", "ghost_spb"],
10892 "device_identifiers": {"uuid": self.DEVICE_UUID},
10893 "device_info": {"model": "Dummy Output", "manufacturer": "Local Audio"},
10894 "expose_to_ha": False,
10895 "hide_in_ui": True,
10896 },
10897 },
10898 "spb_1": {
10899 "player_id": "spb_1",
10900 "provider": "sendspin",
10901 "player_type": "protocol",
10902 "enabled": True,
10903 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
10904 },
10905 "syncgroup_1": {
10906 "player_id": "syncgroup_1",
10907 "provider": "sync_group",
10908 "player_type": "group",
10909 "enabled": True,
10910 "values": {
10911 "group_members": [self.UNIVERSAL_ID, "other_player"],
10912 "allowed_members": [self.UNIVERSAL_ID],
10913 },
10914 },
10915 },
10916 CONF_PLAYER_QUEUES: {
10917 self.UNIVERSAL_ID: {
10918 "queue_id": self.UNIVERSAL_ID,
10919 "values": {"autoplay_mode": "smart"},
10920 },
10921 },
10922 CONF_PLAYER_DSP: {
10923 self.UNIVERSAL_ID: {"enabled": True},
10924 },
10925 }
10926
10927 async def test_stub_promoted_and_universal_absorbed(self) -> None:
10928 """The stub becomes a regular player carrying the universal player's settings."""
10929 data = self._make_data()
10930
10931 assert await migrate(data) is True
10932
10933 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
10934 assert stub_cfg["player_type"] == "player"
10935 # the universal player was the device toggle the user operated
10936 assert stub_cfg["enabled"] is False
10937 assert stub_cfg["name"] == "Kitchen Output"
10938 values = stub_cfg["values"]
10939 assert "protocol_parent_id" not in values
10940 assert values["expose_to_ha"] is False
10941 assert values["hide_in_ui"] is True
10942 assert values["linked_protocol_ids"] == ["spb_1", "ghost_spb"]
10943 assert "device_identifiers" not in values
10944 assert "device_info" not in values
10945
10946 # the wrapper is gone and the bridge protocol re-parented
10947 assert self.UNIVERSAL_ID not in data[CONF_PLAYERS]
10948 assert data[CONF_PLAYERS]["spb_1"]["values"]["protocol_parent_id"] == self.DEVICE_UUID
10949 assert data[CONF_PLAYERS]["spb_1"]["player_type"] == "protocol"
10950
10951 # queue settings, DSP config and group memberships follow the new player_id
10952 queue_cfg = data[CONF_PLAYER_QUEUES][self.DEVICE_UUID]
10953 assert queue_cfg["queue_id"] == self.DEVICE_UUID
10954 assert queue_cfg["values"]["autoplay_mode"] == "smart"
10955 assert self.UNIVERSAL_ID not in data[CONF_PLAYER_QUEUES]
10956 assert data[CONF_PLAYER_DSP] == {self.DEVICE_UUID: {"enabled": True}}
10957 group_values = data[CONF_PLAYERS]["syncgroup_1"]["values"]
10958 assert group_values["group_members"] == [self.DEVICE_UUID, "other_player"]
10959 assert group_values["allowed_members"] == [self.DEVICE_UUID]
10960
10961 async def test_stub_without_universal_wrapper(self) -> None:
10962 """A stub without a universal player wrapper is still promoted."""
10963 data = self._make_data()
10964 del data[CONF_PLAYERS][self.UNIVERSAL_ID]
10965
10966 assert await migrate(data) is True
10967
10968 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
10969 assert stub_cfg["player_type"] == "player"
10970 assert stub_cfg["enabled"] is True
10971 assert "protocol_parent_id" not in stub_cfg["values"]
10972
10973 async def test_migration_is_idempotent(self) -> None:
10974 """A second run finds nothing left to migrate."""
10975 data = self._make_data()
10976
10977 assert await migrate(data) is True
10978 assert await migrate(data) is False
10979
10980
10981class _LockedMockPlayer(MockPlayer):
10982 """A player that keeps its group read-only (e.g. an externally-created mixed group)."""
10983
10984 @property
10985 def grouping_locked(self) -> bool:
10986 """Suppress grouping in the exposed state."""
10987 return True
10988
10989
10990class TestGroupingLockedPolicy:
10991 """A grouping-locked player withholds SET_MEMBERS and group targets in its final state."""
10992
10993 def test_locked_player_withholds_set_members(self, mock_mass: MagicMock) -> None:
10994 """A locked player drops SET_MEMBERS from its exposed features."""
10995 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
10996 player = _LockedMockPlayer(provider, "shell_1", "Shell")
10997 player._attr_supported_features = {PlayerFeature.SET_MEMBERS, PlayerFeature.VOLUME_SET}
10998 player._cache.clear()
10999 player.update_state(signal_event=False)
11000 assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
11001 assert PlayerFeature.VOLUME_SET in player.state.supported_features
11002
11003 def test_locked_player_withholds_protocol_derived_set_members(
11004 self, mock_mass: MagicMock
11005 ) -> None:
11006 """Even a linked protocol player's SET_MEMBERS is suppressed while locked."""
11007 controller = PlayerController(mock_mass)
11008 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11009 player = _LockedMockPlayer(provider, "shell_1", "Shell")
11010 player._attr_supported_features = set() # no native grouping of its own
11011 protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
11012 protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
11013 protocol._cache.clear()
11014 protocol.set_protocol_parent_id("shell_1")
11015 player.set_linked_output_protocols(
11016 [
11017 LinkedOutputProtocol(
11018 output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
11019 )
11020 ]
11021 )
11022 mock_mass.players = controller
11023 controller._players = {"shell_1": player, "airplay_p": protocol}
11024 protocol.refresh_state(signal_event=False)
11025 player.refresh_state(signal_event=False)
11026 assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
11027
11028 def test_locked_player_offers_no_group_targets(self, mock_mass: MagicMock) -> None:
11029 """A locked player exposes no group targets, even an otherwise groupable peer."""
11030 controller = PlayerController(mock_mass)
11031 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11032 player = _LockedMockPlayer(provider, "shell_1", "Shell")
11033 player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
11034 player._attr_can_group_with = {"peer_1"}
11035 peer = MockPlayer(provider, "peer_1", "Peer")
11036 mock_mass.players = controller
11037 controller._players = {"shell_1": player, "peer_1": peer}
11038 player._cache.clear()
11039 peer.refresh_state(signal_event=False)
11040 player.refresh_state(signal_event=False)
11041 assert player.state.can_group_with == set()
11042
11043 def test_unlocked_player_keeps_grouping(self, mock_mass: MagicMock) -> None:
11044 """A normal (unlocked) player keeps SET_MEMBERS and its group targets."""
11045 controller = PlayerController(mock_mass)
11046 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11047 player = MockPlayer(provider, "shell_2", "Shell2")
11048 player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
11049 player._attr_can_group_with = {"peer_2"}
11050 peer = MockPlayer(provider, "peer_2", "Peer2")
11051 mock_mass.players = controller
11052 controller._players = {"shell_2": player, "peer_2": peer}
11053 player._cache.clear()
11054 peer.refresh_state(signal_event=False)
11055 player.refresh_state(signal_event=False)
11056 assert PlayerFeature.SET_MEMBERS in player.state.supported_features
11057 assert "peer_2" in player.state.can_group_with
11058
11059 def test_locked_peer_excluded_from_group_targets(self, mock_mass: MagicMock) -> None:
11060 """An unlocked leader drops a locked peer but keeps an unlocked one as a target."""
11061 controller = PlayerController(mock_mass)
11062 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11063 leader = MockPlayer(provider, "shell_1", "Shell")
11064 leader._attr_supported_features = {PlayerFeature.SET_MEMBERS}
11065 leader._attr_can_group_with = {"locked_peer", "open_peer"}
11066 locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
11067 open_peer = MockPlayer(provider, "open_peer", "Open Peer")
11068 mock_mass.players = controller
11069 controller._players = {
11070 "shell_1": leader,
11071 "locked_peer": locked_peer,
11072 "open_peer": open_peer,
11073 }
11074 leader._cache.clear()
11075 for player in (locked_peer, open_peer, leader):
11076 player.refresh_state(signal_event=False)
11077 # the locked peer keeps its own group read-only, so it is not offered as a target,
11078 # while the ordinary peer still is
11079 assert "locked_peer" not in leader.state.can_group_with
11080 assert "open_peer" in leader.state.can_group_with
11081
11082 def test_linked_protocol_cannot_reintroduce_locked_member(self, mock_mass: MagicMock) -> None:
11083 """A locked member offered by a linked protocol is still kept out of the targets."""
11084 controller = PlayerController(mock_mass)
11085 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11086 leader = MockPlayer(provider, "shell_1", "Shell")
11087 leader._attr_supported_features = set() # no native grouping of its own
11088 protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
11089 protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
11090 protocol._attr_can_group_with = {"locked_peer", "open_peer"}
11091 protocol._cache.clear()
11092 protocol.set_protocol_parent_id("shell_1")
11093 leader.set_linked_output_protocols(
11094 [
11095 LinkedOutputProtocol(
11096 output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
11097 )
11098 ]
11099 )
11100 locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
11101 open_peer = MockPlayer(provider, "open_peer", "Open Peer")
11102 mock_mass.players = controller
11103 controller._players = {
11104 "shell_1": leader,
11105 "airplay_p": protocol,
11106 "locked_peer": locked_peer,
11107 "open_peer": open_peer,
11108 }
11109 for player in (locked_peer, open_peer, protocol):
11110 player.refresh_state(signal_event=False)
11111 leader._cache.clear()
11112 leader.refresh_state(signal_event=False)
11113 # the linked protocol lists the locked peer, but the final filter still excludes it
11114 assert "locked_peer" not in leader.state.can_group_with
11115 assert "open_peer" in leader.state.can_group_with
11116
11117
11118class _NativeGroupingMockPlayer(MockPlayer):
11119 """A player that runs its own multiroom and prefers native grouping."""
11120
11121 @property
11122 def prefer_native_grouping(self) -> bool:
11123 """Prefer native grouping over any linked protocol."""
11124 return True
11125
11126
11127class _RejectsNativeGroupingMockPlayer(MockPlayer):
11128 """A parent that never accepts another player as a native group peer."""
11129
11130 def is_native_group_compatible(self, other: Player) -> bool:
11131 """Reject every peer as native-incompatible (e.g. a cross-backend device)."""
11132 return False
11133
11134
11135class TestPreferNativeGrouping:
11136 """A player that prefers native grouping uses it before its preferred output protocol."""
11137
11138 def test_prefer_native_uses_native_before_child_preferred(self, mock_mass: MagicMock) -> None:
11139 """A prefer-native child groups natively even with an explicit protocol preference."""
11140 controller = PlayerController(mock_mass)
11141 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11142 parent = MockPlayer(provider, "shell_parent", "Parent")
11143 child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
11144 # an explicit child output-protocol preference that would otherwise win
11145 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="airplay_child")
11146 mock_mass.players = controller
11147 controller._players = {"shell_parent": parent, "shell_child": child}
11148 native_members: list[str] = []
11149 protocol_members: list[str] = []
11150 controller._select_grouping_for_member(
11151 child, parent, None, None, True, protocol_members, native_members
11152 )
11153 assert native_members == ["shell_child"]
11154 assert protocol_members == []
11155
11156 def test_prefer_native_falls_through_same_instance_not_native_peer(
11157 self, mock_mass: MagicMock
11158 ) -> None:
11159 """A same-instance pair the parent rejects as a native peer is not forced native."""
11160 controller = PlayerController(mock_mass)
11161 # official + generic LinkPlay share one wiim provider instance, but a cross-backend
11162 # peer is not native-group compatible, so the seam must not force native grouping.
11163 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11164 parent = _RejectsNativeGroupingMockPlayer(provider, "shell_parent", "Parent")
11165 child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
11166 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
11167 mock_mass.players = controller
11168 controller._players = {"shell_parent": parent, "shell_child": child}
11169 native_members: list[str] = []
11170 protocol_members: list[str] = []
11171 controller._select_grouping_for_member(
11172 child, parent, None, None, True, protocol_members, native_members
11173 )
11174 assert native_members == []
11175
11176 def test_prefer_native_falls_through_when_incompatible(self, mock_mass: MagicMock) -> None:
11177 """When native grouping is not possible (cross-backend), the seam does not force it."""
11178 controller = PlayerController(mock_mass)
11179 parent_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11180 child_provider = MockProvider("other", instance_id="other_instance", mass=mock_mass)
11181 parent = MockPlayer(parent_provider, "shell_parent", "Parent")
11182 parent._attr_can_group_with = set() # does not accept the cross-backend child
11183 child = _NativeGroupingMockPlayer(child_provider, "other_child", "Child")
11184 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
11185 mock_mass.players = controller
11186 controller._players = {"shell_parent": parent, "other_child": child}
11187 native_members: list[str] = []
11188 protocol_members: list[str] = []
11189 controller._select_grouping_for_member(
11190 child, parent, None, None, True, protocol_members, native_members
11191 )
11192 # the preferred-native seam did not force a cross-backend native group
11193 assert native_members == []
11194
11195 def test_default_player_keeps_protocol_first_ordering(self, mock_mass: MagicMock) -> None:
11196 """An ordinary player (no native preference) is unaffected by the seam."""
11197 controller = PlayerController(mock_mass)
11198 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
11199 parent = MockPlayer(provider, "shell_parent", "Parent")
11200 child = MockPlayer(provider, "plain_child", "Child")
11201 assert child.prefer_native_grouping is False
11202 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
11203 mock_mass.players = controller
11204 controller._players = {"shell_parent": parent, "plain_child": child}
11205 native_members: list[str] = []
11206 protocol_members: list[str] = []
11207 controller._select_grouping_for_member(
11208 child, parent, None, None, True, protocol_members, native_members
11209 )
11210 # with no preferred/active/common protocol, the default player still reaches
11211 # native grouping via the normal priority (Priority 3), not the preferred seam
11212 assert native_members == ["plain_child"]
11213