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