/
/
1"""
2Tests for PlayerController high-level operations.
3
4This module tests:
5- cmd_set_members validation and execution
6- Group/ungroup commands
7- Player state management
8- Cache invalidation after grouping operations
9"""
10
11from __future__ import annotations
12
13import asyncio
14import contextlib
15import time
16from collections.abc import AsyncIterator, Callable, Iterator
17from types import SimpleNamespace
18from typing import Any, NamedTuple, cast
19from unittest.mock import ANY, AsyncMock, MagicMock, PropertyMock, call, patch
20
21import pytest
22from music_assistant_models.auth import User, UserRole
23from music_assistant_models.config_entries import ConfigEntry, CoreConfig, PlayerConfig
24from music_assistant_models.constants import (
25 PLAYER_CONTROL_FAKE,
26 PLAYER_CONTROL_NATIVE,
27 PLAYER_CONTROL_NONE,
28)
29from music_assistant_models.enums import (
30 ConfigEntryType,
31 EventType,
32 MediaType,
33 PlaybackState,
34 PlayerFeature,
35 PlayerType,
36)
37from music_assistant_models.errors import (
38 InvalidDataError,
39 MusicAssistantError,
40 PlayerCommandFailed,
41 UnsupportedFeaturedException,
42)
43from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
44from music_assistant_models.player_control import PlayerControl
45from music_assistant_models.player_queue import PlayerQueue
46
47from music_assistant.constants import (
48 ANNOUNCE_ALERT_FILE,
49 ATTR_FAKE_MUTE,
50 ATTR_MUTE_LOCK,
51 ATTR_PREVIOUS_VOLUME,
52 CONF_AUTO_PLAY,
53 CONF_ENTRY_TTS_PRE_ANNOUNCE,
54 CONF_ICON,
55 CONF_MAX_VOLUME,
56 CONF_MIN_VOLUME,
57 CONF_MUTE_CONTROL,
58 CONF_OUTPUT_CODEC,
59 CONF_PLAYERS,
60 CONF_POWER_CONTROL,
61 CONF_PROTOCOL_PARENT_ID,
62 CONF_VOLUME_CONTROL,
63 CONF_VOLUME_STEP,
64)
65from music_assistant.controllers.players import PlayerController
66from music_assistant.controllers.players import controller as players_controller
67from music_assistant.controllers.players.announcements import ANNOUNCEMENT_TTS_TIMEOUT
68from music_assistant.controllers.webserver.helpers.auth_middleware import current_user
69from music_assistant.helpers.tts import TTS_QUERY_TIMEOUT_SECONDS, TTSLanguageNotSupportedError
70from music_assistant.models.player import LinkedOutputProtocol, Player
71from music_assistant.models.player_provider import PlayerProvider
72from music_assistant.providers.universal_player.player import UniversalPlayer
73from tests.common import MockPlayer, MockProvider, create_mock_config, use_real_create_task
74
75
76def _player_config_stub(
77 values: dict[str, object] | None = None,
78 *,
79 min_volume: int = 0,
80 max_volume: int = 100,
81) -> Callable[..., object]:
82 """
83 Build a ``get_raw_player_config_value`` side effect.
84
85 :param values: Extra config keys to answer, e.g. ``{CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE}``.
86 :param min_volume: Value returned for the ``min_volume`` key.
87 :param max_volume: Value returned for the ``max_volume`` key.
88 """
89 config: dict[str, object] = {
90 CONF_MIN_VOLUME: min_volume,
91 CONF_MAX_VOLUME: max_volume,
92 **(values or {}),
93 }
94
95 def _conf(_player_id: str, key: str, default: object = None) -> object:
96 if key in config:
97 return config[key]
98 return default
99
100 return _conf
101
102
103def _stub_queue(queue_id: str) -> PlayerQueue:
104 """Build an empty PlayerQueue for the given id."""
105 return PlayerQueue(
106 queue_id=queue_id,
107 active=False,
108 display_name=queue_id,
109 available=True,
110 items=0,
111 )
112
113
114def _announcement() -> PlayerMedia:
115 """Return the announcement to play."""
116 return PlayerMedia(
117 uri="http://ma/announcement/player_1.mp3",
118 media_type=MediaType.ANNOUNCEMENT,
119 title="Announcement",
120 duration=3,
121 )
122
123
124def _volume_step_config(step: int | None) -> CoreConfig:
125 """
126 Build a "players" CoreConfig carrying the given ``volume_step`` value.
127
128 Pass ``None`` to leave the entry unresolved and genuinely exercise the default
129 (``CoreConfig.get_value`` returns the entry's ``value`` verbatim, not its
130 ``default_value``, so a real "not configured" state is ``value=None``).
131 """
132 return CoreConfig(
133 domain="players",
134 values={
135 CONF_VOLUME_STEP: ConfigEntry(
136 key=CONF_VOLUME_STEP,
137 type=ConfigEntryType.INTEGER,
138 default_value=0,
139 value=step,
140 )
141 },
142 )
143
144
145@pytest.fixture
146def running_background_tasks(mock_mass: MagicMock) -> Iterator[None]:
147 """
148 Really run the tasks that ``mass.create_task`` is handed, and raise what they raise.
149
150 The real implementation only logs the exception of a background task, which would
151 leave a test that dispatches its work through the TaskManager passing regardless.
152 """
153 tasks: list[asyncio.Task[Any]] = []
154 use_real_create_task(mock_mass)
155 real_create_task = mock_mass.create_task
156
157 def _create_task(target: Any, *args: Any, **kwargs: Any) -> Any:
158 task = real_create_task(target, *args, **kwargs)
159 if isinstance(task, asyncio.Task):
160 tasks.append(task)
161 return task
162
163 mock_mass.create_task = MagicMock(side_effect=_create_task)
164 yield
165 errors = [
166 err
167 for task in tasks
168 if task.done() and not task.cancelled() and (err := task.exception()) is not None
169 ]
170 if len(errors) == 1:
171 raise errors[0]
172 if errors:
173 raise BaseExceptionGroup("background tasks failed", errors)
174
175
176def _mute_natively(player: MockPlayer) -> AsyncMock:
177 """Give the player a native mute control, mute it and return its mute handler."""
178 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
179 player._attr_volume_muted = True
180 mute_mock = AsyncMock(side_effect=lambda muted: setattr(player, "_attr_volume_muted", muted))
181 player.volume_mute = mute_mock # type: ignore[method-assign]
182 player._cache.clear()
183 player.update_state(force_update=True, signal_event=False)
184 assert player.state.volume_muted is True
185 return mute_mock
186
187
188@pytest.fixture
189def mock_mass() -> MagicMock:
190 """Create a mock MusicAssistant instance."""
191 mass = MagicMock()
192 mass.closing = False
193 mass.loop = None
194 mass.config = MagicMock()
195 mass.config.get = MagicMock(return_value=[])
196 mass.config.get_raw_player_config_value = MagicMock(side_effect=_player_config_stub())
197 # Return "GLOBAL" for log level config (standard default)
198 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
199 mass.config.set = MagicMock()
200 mass.signal_event = MagicMock()
201 mass.get_providers = MagicMock(return_value=[])
202 return mass
203
204
205@pytest.fixture
206def controller(mock_mass: MagicMock) -> PlayerController:
207 """Create a PlayerController instance."""
208 return PlayerController(mock_mass)
209
210
211class TestSetMembersValidation:
212 """Test cmd_set_members validation logic."""
213
214 def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
215 """Test that set_members requires SET_MEMBERS feature."""
216 controller = PlayerController(mock_mass)
217 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
218
219 leader = MockPlayer(provider, "leader", "Leader")
220 # Note: NOT adding SET_MEMBERS feature
221
222 member = MockPlayer(provider, "member", "Member")
223
224 controller._players = {"leader": leader, "member": member}
225 mock_mass.players = controller
226
227 # Should raise exception because leader doesn't support SET_MEMBERS
228 with pytest.raises(UnsupportedFeaturedException):
229 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
230
231 def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
232 """Test that incompatible players cannot be grouped."""
233 controller = PlayerController(mock_mass)
234 provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
235 provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
236
237 player_a = MockPlayer(provider_a, "player_a", "Player A")
238 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
239 player_a._attr_can_group_with = {"provider_a"} # Only same provider
240
241 player_b = MockPlayer(provider_b, "player_b", "Player B")
242
243 controller._players = {"player_a": player_a, "player_b": player_b}
244 mock_mass.players = controller
245
246 # Should raise exception because players are incompatible
247 with pytest.raises(UnsupportedFeaturedException):
248 asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
249
250
251class TestCacheInvalidationAfterGrouping:
252 """Test that caches are invalidated after grouping operations."""
253
254 async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
255 """
256 Test that all players' caches are cleared after set_members.
257
258 Regression test for: Stale can_group_with cache after grouping changes.
259 """
260 controller = PlayerController(mock_mass)
261 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
262
263 leader = MockPlayer(provider, "leader", "Leader")
264 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
265 leader._attr_can_group_with = {"test"}
266 leader._attr_group_members = []
267
268 member = MockPlayer(provider, "member", "Member")
269
270 other = MockPlayer(provider, "other", "Other")
271 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
272 other._attr_can_group_with = {"test"}
273
274 controller._players = {"leader": leader, "member": member, "other": other}
275 mock_mass.players = controller
276
277 # Populate caches
278 _ = leader.state.can_group_with
279 _ = other.state.can_group_with
280
281 # Simulate grouping (normally done by provider's set_members implementation)
282 leader._attr_group_members = ["leader", "member"]
283
284 # Call set_members to trigger cache invalidation
285 await controller._handle_set_members_with_protocols(
286 leader, player_ids_to_add=["member"], player_ids_to_remove=[]
287 )
288
289 # Note: The actual cache clearing happens via trigger_player_update
290 # which schedules update_state to be called later
291 # In a real scenario, this would clear all players' caches
292
293
294class TestNativeSetMembersGuard:
295 """Test the SET_MEMBERS feature guard on native set_members forwarding."""
296
297 async def test_native_set_members_skipped_without_feature(self, mock_mass: MagicMock) -> None:
298 """
299 Test that set_members is not called on a player without SET_MEMBERS support.
300
301 Regression test for: NotImplementedError raised from
302 _cleanup_player_memberships when removing a member from a native player
303 whose group membership is managed externally (e.g. a Google Cast group,
304 which never advertises SET_MEMBERS).
305 """
306 controller = PlayerController(mock_mass)
307 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
308
309 # a native group-like player WITHOUT SET_MEMBERS in supported_features,
310 # whose set_members behaves like the Player base class (raises)
311 parent = MockPlayer(provider, "cast_group", "Cast Group")
312 parent._attr_group_members = ["cast_group", "member"]
313 parent.set_members = AsyncMock( # type: ignore[method-assign]
314 side_effect=NotImplementedError(
315 "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
316 )
317 )
318 member = MockPlayer(provider, "member", "Member")
319
320 controller._players = {"cast_group": parent, "member": member}
321 mock_mass.players = controller
322
323 # must complete without raising NotImplementedError
324 await controller._handle_set_members_with_protocols(
325 parent, player_ids_to_add=[], player_ids_to_remove=["member"]
326 )
327 parent.set_members.assert_not_called()
328
329
330class TestGroupUngroup:
331 """Test group and ungroup commands."""
332
333 async def test_group_command(self, mock_mass: MagicMock) -> None:
334 """Test the group command (cmd_group)."""
335 controller = PlayerController(mock_mass)
336 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
337
338 leader = MockPlayer(provider, "leader", "Leader")
339 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
340 leader._attr_can_group_with = {"member"} # Leader can group with member
341
342 member = MockPlayer(provider, "member", "Member")
343 # Make sure member is already powered on to skip power handling
344 member._attr_powered = True
345
346 controller._players = {"leader": leader, "member": member}
347 mock_mass.players = controller
348
349 # Update state after modifying attributes and registering with controller
350 leader.update_state(signal_event=False)
351 member.update_state(signal_event=False)
352
353 # Track if set_members was called
354 set_members_called = False
355 original_set_members = leader.set_members
356
357 async def mock_set_members(
358 player_ids_to_add: list[str] | None = None,
359 player_ids_to_remove: list[str] | None = None,
360 ) -> None:
361 nonlocal set_members_called
362 set_members_called = True
363 # Call the original to update group_members
364 await original_set_members(player_ids_to_add, player_ids_to_remove)
365
366 leader.set_members = mock_set_members # type: ignore[method-assign]
367
368 # Mock power handling to skip power control (focus is on grouping logic)
369 async def mock_handle_cmd_power(
370 player_id: str, powered: bool, skip_auto_play: bool = False
371 ) -> None:
372 pass
373
374 controller._handle_cmd_power = mock_handle_cmd_power # type: ignore[method-assign]
375
376 # Execute group command
377 await controller.cmd_group("member", "leader")
378
379 # Verify set_members was called
380 assert set_members_called
381 # Verify member was added to leader's group
382 assert "member" in leader._attr_group_members
383
384 async def test_remove_accepted_on_native_synced_child(self, mock_mass: MagicMock) -> None:
385 """
386 A removal addressed at the native sync leader survives a stale member list.
387
388 The child is natively synced to the leader while the leader's state
389 snapshot does not (yet) list it; the removal must still reach the
390 leader's set_members instead of being dropped.
391 """
392 controller = PlayerController(mock_mass)
393 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
394
395 universal = MockPlayer(provider, "universal_parent", "Universal")
396 universal_child = MockPlayer(provider, "universal_child", "Universal Child")
397 leader = MockPlayer(provider, "proto_leader", "Leader", player_type=PlayerType.PROTOCOL)
398 leader.set_protocol_parent_id("universal_parent")
399 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
400 child = MockPlayer(provider, "proto_child", "Child", player_type=PlayerType.PROTOCOL)
401 child.set_protocol_parent_id("universal_child")
402
403 controller._players = {p.player_id: p for p in (universal, universal_child, leader, child)}
404 mock_mass.players = controller
405 for p in (universal, universal_child, leader, child):
406 p.set_initialized()
407 p.update_state(signal_event=False)
408
409 # the membership exists live but the leader's state snapshot is stale,
410 # and the child's exposed synced_to points at the visible parent
411 leader._attr_group_members = ["proto_leader", "proto_child"]
412 child.update_state(force_update=True, signal_event=False)
413 assert "proto_child" not in leader.state.group_members
414 assert child.synced_to == "proto_leader"
415 assert child.state.synced_to == "universal_parent"
416
417 leader.set_members = AsyncMock() # type: ignore[method-assign]
418 await controller._handle_set_members(leader, player_ids_to_remove=["proto_child"])
419
420 leader.set_members.assert_awaited_once_with(
421 player_ids_to_add=None, player_ids_to_remove=["proto_child"]
422 )
423
424
425class TestPlayerAvailability:
426 """Test player availability checks in grouping."""
427
428 def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
429 """Test that unavailable players are rejected when grouping."""
430 controller = PlayerController(mock_mass)
431 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
432
433 leader = MockPlayer(provider, "leader", "Leader")
434 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
435 leader._attr_can_group_with = {"test"}
436
437 member = MockPlayer(provider, "member", "Member")
438 member._attr_available = False # Mark as unavailable
439
440 controller._players = {"leader": leader, "member": member}
441 mock_mass.players = controller
442
443 # Attempting to group with unavailable player should be handled
444 # (either silently ignored or raise exception depending on implementation)
445 # This should either skip the unavailable player or raise an exception
446 with contextlib.suppress(Exception):
447 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
448
449
450def _group_with_member(
451 mock_mass: MagicMock, *, initialize_group: bool = True
452) -> tuple[PlayerController, MockPlayer, MockPlayer]:
453 """
454 Build a controller holding one group player with a single member.
455
456 :param mock_mass: The mocked MusicAssistant instance to attach the controller to.
457 :param initialize_group: Whether the group player is marked as fully registered.
458
459 :return: The controller, the group player and its member.
460 """
461 controller = PlayerController(mock_mass)
462 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
463
464 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
465 member = MockPlayer(provider, "member", "Member")
466
467 controller._players = {"group": group_player, "member": member}
468 mock_mass.players = controller
469
470 group_player._attr_group_members = ["member"]
471 member.initialized.set()
472 if initialize_group:
473 group_player.initialized.set()
474 for player in (group_player, member):
475 player.update_state(signal_event=False)
476 return controller, group_player, member
477
478
479@contextlib.contextmanager
480def _restricted_user(visible_player_ids: list[str]) -> Iterator[None]:
481 """
482 Run the wrapped block as a non-admin user that may only see the given players.
483
484 :param visible_player_ids: The player ids the user is allowed to see.
485 """
486 # the contextvar is copied into the task an API command runs in, so it stays
487 # live for everything that command reaches, internal bookkeeping included
488 token = current_user.set(
489 User(
490 user_id="user_1",
491 username="restricted",
492 role=UserRole.USER,
493 player_filter=visible_player_ids,
494 )
495 )
496 try:
497 yield
498 finally:
499 current_user.reset(token)
500
501
502class TestStateForwarding:
503 """Test forwarding of player state changes to related players."""
504
505 def test_sync_leader_updates_are_forwarded_to_sync_children(self, mock_mass: MagicMock) -> None:
506 """A regular sync leader must notify children via the sync-parent callback."""
507 controller = PlayerController(mock_mass)
508 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
509
510 leader = MockPlayer(provider, "leader", "Leader")
511 child = MockPlayer(provider, "child", "Child")
512
513 controller._players = {"leader": leader, "child": child}
514 mock_mass.players = controller
515
516 leader._attr_group_members = ["leader", "child"]
517 leader.update_state(signal_event=False)
518 child.update_state(signal_event=False)
519
520 with (
521 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
522 patch.object(child, "on_group_updated") as on_group_updated,
523 ):
524 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
525 controller._forward_state_update(leader, changed_values)
526
527 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
528 on_group_updated.assert_not_called()
529
530 def test_group_updates_are_forwarded_to_children_via_group_callback(
531 self, mock_mass: MagicMock
532 ) -> None:
533 """A group player must continue to notify children via the group callback."""
534 controller = PlayerController(mock_mass)
535 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
536
537 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
538 child = MockPlayer(provider, "child", "Child")
539
540 controller._players = {"group": group_player, "child": child}
541 mock_mass.players = controller
542
543 group_player._attr_group_members = ["group", "child"]
544 group_player.update_state(signal_event=False)
545 child.update_state(signal_event=False)
546
547 with (
548 patch.object(child, "on_group_updated") as on_group_updated,
549 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
550 ):
551 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
552 controller._forward_state_update(group_player, changed_values)
553
554 on_group_updated.assert_called_once_with(
555 group_player,
556 changed_values,
557 )
558 on_sync_parent_updated.assert_not_called()
559
560 def test_sync_leader_updates_also_reach_its_group_player(self, mock_mass: MagicMock) -> None:
561 """A sync leader must notify its group player as well as its own sync children."""
562 controller = PlayerController(mock_mass)
563 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
564
565 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
566 leader = MockPlayer(provider, "leader", "Leader")
567 follower = MockPlayer(provider, "follower", "Follower")
568
569 controller._players = {"group": group_player, "leader": leader, "follower": follower}
570 mock_mass.players = controller
571
572 group_player._attr_group_members = ["leader", "follower"]
573 leader._attr_group_members = ["leader", "follower"]
574 for player in (group_player, leader, follower):
575 # _get_player_groups only considers players the controller finished registering
576 player.initialized.set()
577 player.update_state(signal_event=False)
578
579 with (
580 patch.object(group_player, "on_group_member_updated") as on_group_member_updated,
581 patch.object(follower, "on_sync_parent_updated") as on_sync_parent_updated,
582 ):
583 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
584 controller._forward_state_update(leader, changed_values)
585
586 on_group_member_updated.assert_called_once_with(leader, changed_values)
587 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
588
589 def test_group_player_is_notified_under_restricted_user_context(
590 self, mock_mass: MagicMock
591 ) -> None:
592 """The fan-out must not be narrowed by the user that triggered the update."""
593 controller, group_player, member = _group_with_member(mock_mass)
594
595 with _restricted_user(["member"]):
596 assert group_player not in controller.all_players()
597 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
598 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
599 controller._forward_state_update(member, changed_values)
600
601 on_group_member_updated.assert_called_once_with(member, changed_values)
602
603 def test_unavailable_group_player_is_still_notified(self, mock_mass: MagicMock) -> None:
604 """A group player mirrors its members, so it must update while unavailable too."""
605 controller, group_player, member = _group_with_member(mock_mass)
606 group_player._attr_available = False
607 group_player.update_state(signal_event=False)
608 assert group_player.state.available is False
609
610 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
611 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
612 controller._forward_state_update(member, changed_values)
613
614 on_group_member_updated.assert_called_once_with(member, changed_values)
615
616 def test_disabled_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
617 """A disabled player takes no part, unlike one that is merely unavailable."""
618 controller, group_player, member = _group_with_member(mock_mass)
619 group_player._config.enabled = False
620 group_player.update_state(signal_event=False, force_update=True)
621 assert group_player.state.enabled is False
622
623 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
624 controller._forward_state_update(
625 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
626 )
627
628 on_group_member_updated.assert_not_called()
629
630 def test_uninitialized_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
631 """A player the controller is still registering is not fully set up yet."""
632 controller, group_player, member = _group_with_member(mock_mass, initialize_group=False)
633
634 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
635 controller._forward_state_update(
636 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
637 )
638
639 on_group_member_updated.assert_not_called()
640
641
642class TestUnscopedPlayerLookups:
643 """Derived player state must not depend on who triggered the recalculation."""
644
645 def test_iter_players_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
646 """iter_players is the internal view: every registered player, no user filter."""
647 controller, group_player, member = _group_with_member(mock_mass)
648
649 with _restricted_user(["member"]):
650 assert controller.all_players() == [member]
651 assert set(controller.iter_players()) == {group_player, member}
652
653 def test_provider_filter_still_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
654 """A lookup scoped to one provider must still ignore the user filter."""
655 controller, group_player, member = _group_with_member(mock_mass)
656
657 with _restricted_user(["member"]):
658 found = set(controller.iter_players(provider_filter="test"))
659
660 assert found == {group_player, member}
661
662 def test_provider_sees_all_of_its_own_players(self, mock_mass: MagicMock) -> None:
663 """A provider owns its players, so a user filter must never hide them from it."""
664 controller = PlayerController(mock_mass)
665 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
666
667 plain = MockPlayer(provider, "plain", "Plain")
668 # a protocol player id is picked from a list that hides protocol players, so it
669 # is never in a user's filter - the whole set used to disappear for any non-admin
670 protocol = MockPlayer(provider, "protocol", "Protocol", player_type=PlayerType.PROTOCOL)
671 controller._players = {"plain": plain, "protocol": protocol}
672 mock_mass.players = controller
673 for player in (plain, protocol):
674 player.initialized.set()
675 player.update_state(signal_event=False)
676
677 with _restricted_user(["plain"]):
678 owned = set(PlayerProvider.players.fget(provider)) # type: ignore[attr-defined]
679
680 assert owned == {plain, protocol}
681
682 def test_active_group_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
683 """A member must still know its group when the triggering user cannot see it."""
684 _controller, group_player, member = _group_with_member(mock_mass)
685 group_player._attr_group_members = ["member"]
686 group_player._attr_powered = True
687 group_player.update_state(signal_event=False)
688
689 with _restricted_user(["member"]):
690 member.update_state(signal_event=False, force_update=True)
691 assert member.state.active_group == "group"
692
693 def test_synced_to_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
694 """A follower must still resolve its sync leader across a user filter."""
695 controller = PlayerController(mock_mass)
696 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
697
698 leader = MockPlayer(provider, "leader", "Leader")
699 follower = MockPlayer(provider, "follower", "Follower")
700 controller._players = {"leader": leader, "follower": follower}
701 mock_mass.players = controller
702
703 leader._attr_group_members = ["leader", "follower"]
704 for player in (leader, follower):
705 player.initialized.set()
706 player.update_state(signal_event=False)
707
708 with _restricted_user(["follower"]):
709 follower.update_state(signal_event=False, force_update=True)
710 assert follower.state.synced_to == "leader"
711
712 def test_can_group_with_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
713 """Expanding a provider id into players must not drop players the user cannot see."""
714 controller = PlayerController(mock_mass)
715 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
716 mock_mass.get_provider.return_value = provider
717
718 player_a = MockPlayer(provider, "player_a", "Player A")
719 player_b = MockPlayer(provider, "player_b", "Player B")
720 controller._players = {"player_a": player_a, "player_b": player_b}
721 mock_mass.players = controller
722
723 # a provider instance id stands for "every player of that provider"
724 player_a._attr_can_group_with = {"test"}
725 for player in (player_a, player_b):
726 player.initialized.set()
727 player.update_state(signal_event=False)
728
729 with _restricted_user(["player_a"]):
730 player_a.update_state(signal_event=False, force_update=True)
731 assert "player_b" in player_a.state.can_group_with
732
733
734class TestSleepTimer:
735 """Test native sleep timer handling."""
736
737 def test_set_and_clear_sleep_timer(self, mock_mass: MagicMock) -> None:
738 """Setting a sleep timer exposes state and schedules the stop callback."""
739 controller = PlayerController(mock_mass)
740 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
741 player = MockPlayer(provider, "player_1", "Player 1")
742 controller._players = {"player_1": player}
743 mock_mass.players = controller
744
745 expires_at = controller.set_sleep_timer("player_1", 30)
746
747 assert controller.get_sleep_timer("player_1") == expires_at
748 assert player.sleep_timer_expires_at == expires_at
749 mock_mass.call_later.assert_called_with(
750 30,
751 controller._handle_sleep_timer_expired,
752 "player_1",
753 task_id="player_sleep_timer_player_1",
754 )
755 mock_mass.signal_event.assert_any_call(
756 EventType.PLAYER_SLEEP_TIMER_UPDATED,
757 object_id="player_1",
758 data=expires_at,
759 )
760
761 controller.clear_sleep_timer("player_1")
762
763 # get_sleep_timer reads the model field directly, so this also asserts it cleared
764 assert controller.get_sleep_timer("player_1") is None
765 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
766 mock_mass.signal_event.assert_any_call(
767 EventType.PLAYER_SLEEP_TIMER_UPDATED,
768 object_id="player_1",
769 data=None,
770 )
771
772 async def test_sleep_timer_removed_when_player_unregistered(self, mock_mass: MagicMock) -> None:
773 """Unregistering a player cancels and clears its sleep timer."""
774 controller = PlayerController(mock_mass)
775 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
776 player = MockPlayer(provider, "player_1", "Player 1")
777 controller._players = {"player_1": player}
778 player.set_sleep_timer_expires_at(123.0)
779
780 await controller.unregister("player_1")
781
782 assert player.sleep_timer_expires_at is None
783 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
784
785 async def test_sleep_timer_expiry_stops_player(self, mock_mass: MagicMock) -> None:
786 """An expired sleep timer clears its state and stops playback."""
787 controller = PlayerController(mock_mass)
788 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
789 player = MockPlayer(provider, "player_1", "Player 1")
790 controller._players = {"player_1": player}
791 player.set_sleep_timer_expires_at(123.0)
792 controller.cmd_stop = AsyncMock() # type: ignore[method-assign]
793
794 await controller._handle_sleep_timer_expired("player_1")
795
796 assert controller.get_sleep_timer("player_1") is None
797 assert player.sleep_timer_expires_at is None
798 controller.cmd_stop.assert_awaited_once_with("player_1")
799 mock_mass.signal_event.assert_any_call(
800 EventType.PLAYER_SLEEP_TIMER_UPDATED,
801 object_id="player_1",
802 data=None,
803 )
804
805 def test_set_sleep_timer_rejects_invalid_duration(self, mock_mass: MagicMock) -> None:
806 """A non-positive or float-overflowing duration raises and schedules nothing."""
807 controller = PlayerController(mock_mass)
808 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
809 player = MockPlayer(provider, "player_1", "Player 1")
810 controller._players = {"player_1": player}
811
812 # 0/-30 are non-positive; 10**400 exceeds the float range for the expiry math
813 for invalid in (0, -30, 10**400):
814 with pytest.raises(InvalidDataError):
815 controller.set_sleep_timer("player_1", invalid)
816
817 assert controller.get_sleep_timer("player_1") is None
818 mock_mass.call_later.assert_not_called()
819
820
821class TestUnregisterCleanup:
822 """Test that unregister cleans up leaked internal state."""
823
824 def test_command_locks_removed(self, mock_mass: MagicMock) -> None:
825 """Unregistering a player removes its command lock entries."""
826 controller = PlayerController(mock_mass)
827 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
828 player = MockPlayer(provider, "player_1", "Player 1")
829
830 controller._players = {"player_1": player}
831 controller._player_command_locks = {
832 "playback_player_1": asyncio.Lock(),
833 "volume_player_1": asyncio.Lock(),
834 }
835
836 asyncio.run(controller.unregister("player_1"))
837
838 assert "playback_player_1" not in controller._player_command_locks
839 assert "volume_player_1" not in controller._player_command_locks
840
841 def test_other_players_state_untouched(self, mock_mass: MagicMock) -> None:
842 """Unregistering one player does not affect another player's state."""
843 controller = PlayerController(mock_mass)
844 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
845 player_a = MockPlayer(provider, "player_a", "Player A")
846 player_b = MockPlayer(provider, "player_b", "Player B")
847
848 controller._players = {"player_a": player_a, "player_b": player_b}
849 controller._player_command_locks = {
850 "playback_player_a": asyncio.Lock(),
851 "playback_player_b": asyncio.Lock(),
852 }
853
854 asyncio.run(controller.unregister("player_a"))
855
856 assert "playback_player_b" in controller._player_command_locks
857 assert "playback_player_a" not in controller._player_command_locks
858
859 def test_suffix_player_id_not_over_matched(self, mock_mass: MagicMock) -> None:
860 """Removing player 'b' must not remove locks for player 'a_b' (no suffix matching)."""
861 controller = PlayerController(mock_mass)
862 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
863 player_b = MockPlayer(provider, "b", "Player B")
864 player_a_b = MockPlayer(provider, "a_b", "Player A_B")
865
866 controller._players = {"b": player_b, "a_b": player_a_b}
867 controller._player_command_locks = {
868 "playback_b": asyncio.Lock(),
869 "playback_a_b": asyncio.Lock(),
870 }
871
872 asyncio.run(controller.unregister("b"))
873
874 assert "playback_a_b" in controller._player_command_locks
875 assert "playback_b" not in controller._player_command_locks
876
877 def test_pending_protocol_evaluation_cancelled(self, mock_mass: MagicMock) -> None:
878 """Unregistering a player cancels and removes its pending protocol evaluation."""
879 controller = PlayerController(mock_mass)
880 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
881 player = MockPlayer(provider, "player_1", "Player 1")
882
883 mock_handle = MagicMock()
884 controller._players = {"player_1": player}
885 controller._pending_protocol_evaluations = {"player_1": mock_handle}
886
887 asyncio.run(controller.unregister("player_1"))
888
889 mock_handle.cancel.assert_called_once()
890 assert "player_1" not in controller._pending_protocol_evaluations
891
892 def test_unregister_nonexistent_player_is_noop(self, mock_mass: MagicMock) -> None:
893 """Unregistering a player that doesn't exist is silently ignored."""
894 controller = PlayerController(mock_mass)
895 controller._player_command_locks = {"set_members_other": asyncio.Lock()}
896
897 asyncio.run(controller.unregister("nonexistent"))
898
899 assert "set_members_other" in controller._player_command_locks
900
901
902class TestRegisterUnregisterRace:
903 """Test registration that is interrupted by an unregister of the same player."""
904
905 @staticmethod
906 def _stub_register_calls(mock_mass: MagicMock) -> None:
907 """Stub the awaited mass calls made during register/unregister."""
908 # registration reads config keys with differently typed defaults (mapping for the
909 # player config store, str | None for the cached MAC addresses)
910 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
911 mock_mass.cache.get = AsyncMock(return_value=None)
912 mock_mass.config.get_player_config = AsyncMock(return_value=create_mock_config("Player 1"))
913 mock_mass.player_queues.on_player_register = AsyncMock()
914 mock_mass.player_queues.on_player_remove = MagicMock()
915
916 @staticmethod
917 def _player_added_signalled(mock_mass: MagicMock) -> bool:
918 """Return True if a PLAYER_ADDED event was signalled."""
919 return any(
920 call_args.args and call_args.args[0] == EventType.PLAYER_ADDED
921 for call_args in mock_mass.signal_event.call_args_list
922 )
923
924 async def test_register_aborts_when_unregistered_during_config_load(
925 self, mock_mass: MagicMock
926 ) -> None:
927 """A player unregistered while its config loads is not announced as added."""
928 controller = PlayerController(mock_mass)
929 self._stub_register_calls(mock_mass)
930 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
931 player = MockPlayer(provider, "player_1", "Player 1")
932
933 async def _unregister_midway(player_id: str) -> MagicMock:
934 # stands in for a provider unload or device disconnect landing
935 # while register awaits the player config
936 await controller.unregister(player_id)
937 return create_mock_config("Player 1")
938
939 mock_mass.config.get_player_config = AsyncMock(side_effect=_unregister_midway)
940 config_hook = AsyncMock()
941
942 with (
943 patch(
944 "music_assistant.controllers.players.controller.enrich_device_mac_address",
945 AsyncMock(),
946 ),
947 patch.object(player, "on_config_updated", config_hook),
948 ):
949 await controller.register(player)
950
951 assert "player_1" not in controller._players
952 assert not player.initialized.is_set()
953 # setup stops right away: the provider hook must not run on a player
954 # whose on_unload already ran
955 config_hook.assert_not_called()
956 mock_mass.player_queues.on_player_register.assert_not_called()
957 assert not self._player_added_signalled(mock_mass)
958
959 async def test_register_aborts_when_unregistered_during_config_hook(
960 self, mock_mass: MagicMock
961 ) -> None:
962 """A player unregistered while its on_config_updated hook runs is not announced."""
963 controller = PlayerController(mock_mass)
964 self._stub_register_calls(mock_mass)
965 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
966 player = MockPlayer(provider, "player_1", "Player 1")
967
968 async def _unregister_midway() -> None:
969 await controller.unregister("player_1")
970
971 with (
972 patch(
973 "music_assistant.controllers.players.controller.enrich_device_mac_address",
974 AsyncMock(),
975 ),
976 patch.object(player, "on_config_updated", AsyncMock(side_effect=_unregister_midway)),
977 ):
978 await controller.register(player)
979
980 assert "player_1" not in controller._players
981 assert not player.initialized.is_set()
982 mock_mass.player_queues.on_player_register.assert_not_called()
983 assert not self._player_added_signalled(mock_mass)
984
985 async def test_register_drops_queue_recreated_after_unregister(
986 self, mock_mass: MagicMock
987 ) -> None:
988 """A queue restored after the unregister already removed it is dropped again."""
989 controller = PlayerController(mock_mass)
990 self._stub_register_calls(mock_mass)
991 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
992 player = MockPlayer(provider, "player_1", "Player 1")
993
994 events: list[str] = []
995
996 async def _unregister_midway(registering_player: MockPlayer) -> None:
997 # on_player_register restores the queue from cache before storing it, so an
998 # unregister can land in between and have its cleanup undone
999 await controller.unregister(registering_player.player_id)
1000 events.append("queue_created")
1001
1002 def _track_removal(*_args: object, **_kwargs: object) -> None:
1003 events.append("queue_removed")
1004
1005 mock_mass.player_queues.on_player_remove = MagicMock(side_effect=_track_removal)
1006 mock_mass.player_queues.on_player_register = AsyncMock(side_effect=_unregister_midway)
1007
1008 with patch(
1009 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1010 AsyncMock(),
1011 ):
1012 await controller.register(player)
1013
1014 assert "player_1" not in controller._players
1015 # the queue recreated for the removed player must be cleaned up again
1016 assert events == ["queue_removed", "queue_created", "queue_removed"]
1017
1018 async def test_register_or_update_marks_replacement_initialized(
1019 self, mock_mass: MagicMock
1020 ) -> None:
1021 """Replacing a registered player carries the initialized state to the new object."""
1022 controller = PlayerController(mock_mass)
1023 self._stub_register_calls(mock_mass)
1024 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1025 existing = MockPlayer(provider, "player_1", "Player 1")
1026 existing.set_initialized()
1027 controller._players = {"player_1": existing}
1028 replacement = MockPlayer(provider, "player_1", "Player 1")
1029
1030 await controller.register_or_update(replacement)
1031
1032 assert controller._players["player_1"] is replacement
1033 assert replacement.initialized.is_set()
1034
1035 async def test_register_or_update_hands_config_to_replacement(
1036 self, mock_mass: MagicMock
1037 ) -> None:
1038 """A replacement instance inherits the config of the player it takes over."""
1039 controller = PlayerController(mock_mass)
1040 self._stub_register_calls(mock_mass)
1041 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1042 existing = MockPlayer(provider, "player_1", "Player 1")
1043 resolved_config = create_mock_config("Player 1")
1044 existing.set_config(resolved_config)
1045 existing.set_initialized()
1046 controller._players = {"player_1": existing}
1047 replacement = MockPlayer(provider, "player_1", "Player 1")
1048 config_hook = AsyncMock()
1049
1050 with patch.object(replacement, "on_config_updated", config_hook):
1051 await controller.register_or_update(replacement)
1052
1053 # without the resolved config the replacement would read defaults for every
1054 # config backed setting (group members, flow mode, visibility, ...)
1055 assert replacement.config is resolved_config
1056 config_hook.assert_awaited_once()
1057
1058 async def test_register_or_update_aborts_when_replacement_is_unregistered(
1059 self, mock_mass: MagicMock
1060 ) -> None:
1061 """A replacement unregistered while its config hook runs is not marked ready."""
1062 controller = PlayerController(mock_mass)
1063 self._stub_register_calls(mock_mass)
1064 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1065 existing = MockPlayer(provider, "player_1", "Player 1")
1066 existing.set_config(create_mock_config("Player 1"))
1067 existing.set_initialized()
1068 controller._players = {"player_1": existing}
1069 replacement = MockPlayer(provider, "player_1", "Player 1")
1070
1071 async def _unregister_midway() -> None:
1072 await controller.unregister("player_1")
1073
1074 with patch.object(
1075 replacement, "on_config_updated", AsyncMock(side_effect=_unregister_midway)
1076 ):
1077 await controller.register_or_update(replacement)
1078
1079 assert "player_1" not in controller._players
1080 assert not replacement.initialized.is_set()
1081
1082 async def test_register_or_update_leaves_same_instance_untouched(
1083 self, mock_mass: MagicMock
1084 ) -> None:
1085 """Re-announcing the same instance does not re-run its config hook."""
1086 controller = PlayerController(mock_mass)
1087 self._stub_register_calls(mock_mass)
1088 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1089 player = MockPlayer(provider, "player_1", "Player 1")
1090 player.set_initialized()
1091 controller._players = {"player_1": player}
1092 config_hook = AsyncMock()
1093
1094 with patch.object(player, "on_config_updated", config_hook):
1095 await controller.register_or_update(player)
1096
1097 assert controller._players["player_1"] is player
1098 config_hook.assert_not_called()
1099
1100 async def test_register_or_update_waits_for_inflight_register(
1101 self, mock_mass: MagicMock
1102 ) -> None:
1103 """A player is never swapped out while register() is still setting it up."""
1104 controller = PlayerController(mock_mass)
1105 self._stub_register_calls(mock_mass)
1106 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1107 player = MockPlayer(provider, "player_1", "Player 1")
1108 replacement = MockPlayer(provider, "player_1", "Player 1")
1109 release = asyncio.Event()
1110 registering = asyncio.Event()
1111
1112 async def _blocked_config(*_args: object) -> MagicMock:
1113 registering.set()
1114 await release.wait()
1115 return create_mock_config("Player 1")
1116
1117 mock_mass.config.get_player_config = AsyncMock(side_effect=_blocked_config)
1118
1119 with patch(
1120 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1121 AsyncMock(),
1122 ):
1123 register_task = asyncio.create_task(controller.register(player))
1124 # only proceed once register() is provably inside its critical section
1125 await registering.wait()
1126 update_task = asyncio.create_task(controller.register_or_update(replacement))
1127 await _yield_to_loop()
1128
1129 # register() is still in flight, so the replacement must not be swapped in yet
1130 assert controller._players["player_1"] is player
1131 assert not update_task.done()
1132
1133 release.set()
1134 await register_task
1135 await update_task
1136
1137 assert controller._players["player_1"] is replacement
1138 assert player.initialized.is_set()
1139 assert replacement.initialized.is_set()
1140
1141
1142async def _yield_to_loop() -> None:
1143 """Give other pending tasks a chance to run up to their next suspension point."""
1144 for _ in range(5):
1145 await asyncio.sleep(0)
1146
1147
1148def _set_play_media_override(mock_mass: MagicMock, value: bool) -> None:
1149 """
1150 Configure get_raw_player_config_value to return ``value`` for the play-media override key.
1151
1152 Other keys keep the existing defaults from the shared fixture. Use this in
1153 tests for ``play_media`` override behavior so the legacy/new branch is
1154 selected deterministically.
1155 """
1156 original = mock_mass.config.get_raw_player_config_value.side_effect
1157
1158 def _side_effect(player_id: str, key: str, default: object = None) -> object:
1159 if key == "play_media_overrides_group":
1160 return value
1161 if callable(original):
1162 return original(player_id, key, default)
1163 return default
1164
1165 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_side_effect)
1166
1167
1168class TestRegisterFailureRollback:
1169 """Test registration that fails while setting the player up."""
1170
1171 @staticmethod
1172 def _stub_register_calls(mock_mass: MagicMock) -> None:
1173 """Stub the awaited mass calls made during register."""
1174 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
1175 mock_mass.cache.get = AsyncMock(return_value=None)
1176 mock_mass.config.get_player_config = AsyncMock(return_value=create_mock_config("Player 1"))
1177 mock_mass.player_queues.on_player_register = AsyncMock()
1178 mock_mass.player_queues.on_player_remove = MagicMock()
1179
1180 async def test_failed_config_load_leaves_no_stale_registration(
1181 self, mock_mass: MagicMock
1182 ) -> None:
1183 """A player whose config fails to load can be registered again afterwards."""
1184 controller = PlayerController(mock_mass)
1185 self._stub_register_calls(mock_mass)
1186 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1187 player = MockPlayer(provider, "player_1", "Player 1")
1188 mock_mass.config.get_player_config = AsyncMock(side_effect=KeyError("no config"))
1189
1190 with (
1191 patch(
1192 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1193 AsyncMock(),
1194 ),
1195 pytest.raises(KeyError),
1196 ):
1197 await controller.register(player)
1198
1199 assert "player_1" not in controller._players
1200
1201 # the provider retries with a fresh player object once the device reappears
1202 self._stub_register_calls(mock_mass)
1203 retried = MockPlayer(provider, "player_1", "Player 1")
1204 with patch(
1205 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1206 AsyncMock(),
1207 ):
1208 await controller.register(retried)
1209
1210 assert controller._players["player_1"] is retried
1211 assert retried.initialized.is_set()
1212
1213 async def test_failed_config_hook_leaves_no_stale_registration(
1214 self, mock_mass: MagicMock
1215 ) -> None:
1216 """A player whose on_config_updated hook fails can be registered again afterwards."""
1217 controller = PlayerController(mock_mass)
1218 self._stub_register_calls(mock_mass)
1219 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1220 player = MockPlayer(provider, "player_1", "Player 1")
1221
1222 with (
1223 patch(
1224 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1225 AsyncMock(),
1226 ),
1227 patch.object(
1228 player, "on_config_updated", AsyncMock(side_effect=BrokenPipeError("device gone"))
1229 ),
1230 pytest.raises(BrokenPipeError),
1231 ):
1232 await controller.register(player)
1233
1234 assert "player_1" not in controller._players
1235
1236 retried = MockPlayer(provider, "player_1", "Player 1")
1237 with patch(
1238 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1239 AsyncMock(),
1240 ):
1241 await controller.register(retried)
1242
1243 assert controller._players["player_1"] is retried
1244 assert retried.initialized.is_set()
1245
1246 async def test_failed_setup_after_unregister_reports_the_real_error(
1247 self, mock_mass: MagicMock
1248 ) -> None:
1249 """The setup error survives an unregister that already dropped the player."""
1250 controller = PlayerController(mock_mass)
1251 self._stub_register_calls(mock_mass)
1252 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1253 player = MockPlayer(provider, "player_1", "Player 1")
1254
1255 async def _unregister_then_fail() -> None:
1256 await controller.unregister("player_1")
1257 raise BrokenPipeError("device gone")
1258
1259 with (
1260 patch(
1261 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1262 AsyncMock(),
1263 ),
1264 patch.object(player, "on_config_updated", AsyncMock(side_effect=_unregister_then_fail)),
1265 pytest.raises(BrokenPipeError),
1266 ):
1267 await controller.register(player)
1268
1269 assert "player_1" not in controller._players
1270
1271 async def test_failure_after_the_player_was_added_keeps_it_registered(
1272 self, mock_mass: MagicMock
1273 ) -> None:
1274 """A failure after the player was announced leaves it registered."""
1275 controller = PlayerController(mock_mass)
1276 self._stub_register_calls(mock_mass)
1277 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1278 player = MockPlayer(provider, "player_1", "Player 1")
1279 mock_mass.player_queues.on_player_register = AsyncMock(side_effect=RuntimeError("boom"))
1280
1281 with (
1282 patch(
1283 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1284 AsyncMock(),
1285 ),
1286 pytest.raises(RuntimeError),
1287 ):
1288 await controller.register(player)
1289
1290 # PLAYER_ADDED was already signalled, so dropping the player now would
1291 # remove it from every listing without a matching PLAYER_REMOVED
1292 assert controller._players["player_1"] is player
1293 assert player.initialized.is_set()
1294
1295 async def test_cancelled_setup_leaves_no_stale_registration(self, mock_mass: MagicMock) -> None:
1296 """A registration cancelled while it runs can be registered again afterwards."""
1297 controller = PlayerController(mock_mass)
1298 self._stub_register_calls(mock_mass)
1299 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1300 player = MockPlayer(provider, "player_1", "Player 1")
1301 setup_running = asyncio.Event()
1302
1303 async def _block_forever() -> None:
1304 setup_running.set()
1305 await asyncio.sleep(60)
1306
1307 with (
1308 patch(
1309 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1310 AsyncMock(),
1311 ),
1312 patch.object(player, "on_config_updated", AsyncMock(side_effect=_block_forever)),
1313 ):
1314 # stands in for a re-triggered provider discovery aborting the running task
1315 task = asyncio.create_task(controller.register(player))
1316 await setup_running.wait()
1317 task.cancel()
1318 with pytest.raises(asyncio.CancelledError):
1319 await task
1320
1321 assert "player_1" not in controller._players
1322
1323 async def test_failed_setup_releases_the_player_resources(self, mock_mass: MagicMock) -> None:
1324 """A player dropped after a failed setup is unloaded so it releases its resources."""
1325 controller = PlayerController(mock_mass)
1326 self._stub_register_calls(mock_mass)
1327 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1328 player = MockPlayer(provider, "player_1", "Player 1")
1329 unload = AsyncMock()
1330
1331 with (
1332 patch(
1333 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1334 AsyncMock(),
1335 ),
1336 patch.object(
1337 player, "on_config_updated", AsyncMock(side_effect=BrokenPipeError("device gone"))
1338 ),
1339 patch.object(player, "on_unload", unload),
1340 pytest.raises(BrokenPipeError),
1341 ):
1342 await controller.register(player)
1343
1344 unload.assert_awaited_once()
1345
1346 async def test_failing_teardown_keeps_the_setup_error(self, mock_mass: MagicMock) -> None:
1347 """A teardown that fails does not hide why the registration failed."""
1348 controller = PlayerController(mock_mass)
1349 self._stub_register_calls(mock_mass)
1350 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1351 player = MockPlayer(provider, "player_1", "Player 1")
1352
1353 with (
1354 patch(
1355 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1356 AsyncMock(),
1357 ),
1358 patch.object(
1359 player, "on_config_updated", AsyncMock(side_effect=BrokenPipeError("device gone"))
1360 ),
1361 patch.object(player, "on_unload", AsyncMock(side_effect=RuntimeError("teardown"))),
1362 pytest.raises(BrokenPipeError),
1363 ):
1364 await controller.register(player)
1365
1366 assert "player_1" not in controller._players
1367
1368
1369class TestRegisterOrUpdateTypeTransition:
1370 """Tests for a registered player moving in or out of the protocol role."""
1371
1372 queue_ids: set[str]
1373
1374 def _prepare(self, mock_mass: MagicMock) -> PlayerController:
1375 """Build a controller with the calls a re-registration makes stubbed out."""
1376 mock_mass.loop = MagicMock()
1377 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
1378 # the queue registry is what a re-registration reads the role change off,
1379 # so it is modelled instead of mocked out
1380 self.queue_ids = set()
1381 mock_mass.player_queues.get = MagicMock(
1382 side_effect=lambda queue_id: MagicMock() if queue_id in self.queue_ids else None
1383 )
1384 mock_mass.player_queues.on_player_register = AsyncMock(
1385 side_effect=lambda player: self.queue_ids.add(player.player_id)
1386 )
1387 mock_mass.player_queues.on_player_remove = MagicMock(
1388 side_effect=lambda player_id, **_kwargs: self.queue_ids.discard(player_id)
1389 )
1390 controller = PlayerController(mock_mass)
1391 mock_mass.players = controller
1392 return controller
1393
1394 def _register(
1395 self,
1396 controller: PlayerController,
1397 provider: MockProvider,
1398 player_id: str,
1399 type_: PlayerType,
1400 ) -> MockPlayer:
1401 """Add a player to the registry with its state calculated for the given type."""
1402 player = MockPlayer(provider, player_id, player_id, type_)
1403 player.set_initialized()
1404 controller._players[player_id] = player
1405 if type_ != PlayerType.PROTOCOL:
1406 # register() gives every non-protocol player a queue
1407 self.queue_ids.add(player_id)
1408 # MockPlayer assigns the type after Player.__init__ built the initial state,
1409 # so the state only reports it once it is recalculated
1410 player.update_state(signal_event=False)
1411 assert player.state.type is type_
1412 return player
1413
1414 @staticmethod
1415 def _signalled(mock_mass: MagicMock, event: EventType) -> bool:
1416 """Return True if the given event was signalled."""
1417 return any(
1418 call_args.args and call_args.args[0] == event
1419 for call_args in mock_mass.signal_event.call_args_list
1420 )
1421
1422 async def test_protocol_to_player_registers_queue(self, mock_mass: MagicMock) -> None:
1423 """A protocol player that becomes a standalone player gets a queue and is announced."""
1424 controller = self._prepare(mock_mass)
1425 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1426 player = self._register(controller, provider, "player_1", PlayerType.PROTOCOL)
1427
1428 player._attr_type = PlayerType.PLAYER
1429 await controller.register_or_update(player)
1430
1431 assert player.state.type is PlayerType.PLAYER
1432 # without a queue the player is registered but cannot play anything
1433 mock_mass.player_queues.on_player_register.assert_awaited_once_with(player)
1434 assert self._signalled(mock_mass, EventType.PLAYER_ADDED)
1435
1436 async def test_protocol_to_player_keeps_state_pipeline_intact(
1437 self, mock_mass: MagicMock
1438 ) -> None:
1439 """The regular state update still runs for the tick that changes the type."""
1440 controller = self._prepare(mock_mass)
1441 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1442 player = self._register(controller, provider, "player_1", PlayerType.PROTOCOL)
1443 updates: list[dict[str, tuple[Any, Any]]] = []
1444 controller.subscribe_player_state_update(lambda _player, changed: updates.append(changed))
1445
1446 player._attr_type = PlayerType.PLAYER
1447 await controller.register_or_update(player)
1448
1449 # the bridges and wait_for_player_update hang off this dispatch, and the changed
1450 # values are consumed by it: a suppressed update is never replayed by a later one
1451 assert any("type" in changed for changed in updates)
1452
1453 async def test_player_to_protocol_removes_queue(self, mock_mass: MagicMock) -> None:
1454 """A player that becomes a protocol child loses its queue and is announced removed."""
1455 controller = self._prepare(mock_mass)
1456 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1457 player = self._register(controller, provider, "player_1", PlayerType.PLAYER)
1458
1459 player._attr_type = PlayerType.PROTOCOL
1460 await controller.register_or_update(player)
1461
1462 assert player.state.type is PlayerType.PROTOCOL
1463 mock_mass.player_queues.on_player_remove.assert_called_once_with(
1464 "player_1", permanent=False
1465 )
1466 assert self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1467
1468 async def test_leaving_protocol_role_survives_a_state_update_landing_first(
1469 self, mock_mass: MagicMock
1470 ) -> None:
1471 """A state update between the provider's type change and this call is harmless."""
1472 controller = self._prepare(mock_mass)
1473 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1474 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1475 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1476 child.set_protocol_parent_id("parent")
1477 parent.set_linked_output_protocols(
1478 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1479 )
1480
1481 child._attr_type = PlayerType.PLAYER
1482 # a debounced state update can fire while this call waits for the register lock,
1483 # which makes the state report the new type before the transition is handled
1484 child.update_state(signal_event=False)
1485 assert child.state.type is PlayerType.PLAYER
1486
1487 await controller.register_or_update(child)
1488
1489 mock_mass.player_queues.on_player_register.assert_awaited_once_with(child)
1490 assert self._signalled(mock_mass, EventType.PLAYER_ADDED)
1491 assert parent.linked_output_protocols == []
1492
1493 async def test_entering_protocol_role_survives_a_state_update_landing_first(
1494 self, mock_mass: MagicMock
1495 ) -> None:
1496 """The reverse transition is handled with the state already reporting the new type."""
1497 controller = self._prepare(mock_mass)
1498 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1499 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1500 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1501 child.set_protocol_parent_id("parent")
1502 parent.set_linked_output_protocols(
1503 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1504 )
1505
1506 parent._attr_type = PlayerType.PROTOCOL
1507 parent.update_state(signal_event=False)
1508 assert parent.state.type is PlayerType.PROTOCOL
1509
1510 await controller.register_or_update(parent)
1511
1512 mock_mass.player_queues.on_player_remove.assert_called_once_with("parent", permanent=False)
1513 assert self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1514 assert parent.linked_output_protocols == []
1515 assert child.protocol_parent_id is None
1516
1517 async def test_unchanged_type_leaves_queue_alone(self, mock_mass: MagicMock) -> None:
1518 """Re-registering a player without a type change does not touch its queue."""
1519 controller = self._prepare(mock_mass)
1520 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1521 player = self._register(controller, provider, "player_1", PlayerType.PLAYER)
1522
1523 await controller.register_or_update(player)
1524
1525 mock_mass.player_queues.on_player_register.assert_not_awaited()
1526 mock_mass.player_queues.on_player_remove.assert_not_called()
1527 assert not self._signalled(mock_mass, EventType.PLAYER_ADDED)
1528 assert not self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1529
1530 async def test_unchanged_protocol_type_leaves_queue_alone(self, mock_mass: MagicMock) -> None:
1531 """Re-registering a protocol player does not hand it a queue."""
1532 controller = self._prepare(mock_mass)
1533 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1534 player = self._register(controller, provider, "player_1", PlayerType.PROTOCOL)
1535
1536 await controller.register_or_update(player)
1537
1538 mock_mass.player_queues.on_player_register.assert_not_awaited()
1539 mock_mass.player_queues.on_player_remove.assert_not_called()
1540 assert not self._signalled(mock_mass, EventType.PLAYER_ADDED)
1541 assert not self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1542
1543 async def test_group_type_change_is_not_a_role_change(self, mock_mass: MagicMock) -> None:
1544 """A player that turns into a group keeps its queue (Chromecast reports both)."""
1545 controller = self._prepare(mock_mass)
1546 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1547 player = self._register(controller, provider, "player_1", PlayerType.PLAYER)
1548
1549 player._attr_type = PlayerType.GROUP
1550 await controller.register_or_update(player)
1551
1552 assert player.state.type is PlayerType.GROUP
1553 mock_mass.player_queues.on_player_register.assert_not_awaited()
1554 mock_mass.player_queues.on_player_remove.assert_not_called()
1555 assert not self._signalled(mock_mass, EventType.PLAYER_ADDED)
1556 assert not self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1557
1558 async def test_protocol_to_player_unlinks_from_parent(self, mock_mass: MagicMock) -> None:
1559 """A protocol player that becomes standalone is released by its parent."""
1560 controller = self._prepare(mock_mass)
1561 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1562 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1563 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1564 child.set_protocol_parent_id("parent")
1565 parent.set_linked_output_protocols(
1566 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1567 )
1568
1569 child._attr_type = PlayerType.PLAYER
1570 await controller.register_or_update(child)
1571
1572 # a parent that keeps the link would still route audio to a player that is
1573 # now standalone
1574 assert parent.linked_output_protocols == []
1575 assert child.protocol_parent_id is None
1576
1577 async def test_protocol_to_player_unlinks_parent_cleared_ahead(
1578 self, mock_mass: MagicMock
1579 ) -> None:
1580 """The parent is released even when the provider already dropped the live link."""
1581 controller = self._prepare(mock_mass)
1582 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1583 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1584 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1585 parent.set_linked_output_protocols(
1586 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1587 )
1588 # Sendspin clears protocol_parent_id before it announces the new type, so only
1589 # the persisted link is left to find the parent by
1590 cached_key = f"{CONF_PLAYERS}/child/values/{CONF_PROTOCOL_PARENT_ID}"
1591 mock_mass.config.get = MagicMock(
1592 side_effect=lambda key, default=None: "parent" if key == cached_key else default
1593 )
1594 assert child.protocol_parent_id is None
1595
1596 child._attr_type = PlayerType.PLAYER
1597 await controller.register_or_update(child)
1598
1599 assert parent.linked_output_protocols == []
1600 assert child.protocol_parent_id is None
1601
1602 async def test_protocol_to_player_clears_persisted_parent_link(
1603 self, mock_mass: MagicMock
1604 ) -> None:
1605 """The persisted parent link is dropped so a restart cannot restore the old role."""
1606 controller = self._prepare(mock_mass)
1607 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1608 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1609 parent_key = f"{CONF_PLAYERS}/child/values/{CONF_PROTOCOL_PARENT_ID}"
1610
1611 def _config_get(key: str, default: object = None) -> object:
1612 if key == parent_key:
1613 return "parent"
1614 # the parent id is only cleared for a player that still has a config
1615 return {"provider": "test"} if key == f"{CONF_PLAYERS}/child" else default
1616
1617 mock_mass.config.get = MagicMock(side_effect=_config_get)
1618
1619 child._attr_type = PlayerType.PLAYER
1620 await controller.register_or_update(child)
1621
1622 # a leftover parent id makes the startup repair pass heal the type back to protocol
1623 mock_mass.config.set.assert_any_call(parent_key, None)
1624
1625 async def test_universal_parent_hands_over_to_the_promoted_player(
1626 self, mock_mass: MagicMock
1627 ) -> None:
1628 """A universal parent hands its config to the player that replaces it."""
1629 controller = self._prepare(mock_mass)
1630 # tasks start eagerly, so a removal scheduled while the player is released runs
1631 # before the handover does and would take the wrapper out from under it
1632 use_real_create_task(mock_mass)
1633 provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
1634 universal_provider = MockProvider(
1635 "universal_player", instance_id="universal_player", mass=mock_mass
1636 )
1637 mock_mass.config.get_base_player_config.return_value = create_mock_config("Universal")
1638 universal = UniversalPlayer(
1639 cast("Any", universal_provider), "universal_1", "Universal", DeviceInfo(), ["child"]
1640 )
1641 universal.set_initialized()
1642 controller._players["universal_1"] = universal
1643 universal.update_state(signal_event=False)
1644 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1645 child.set_protocol_parent_id("universal_1")
1646 universal.set_linked_output_protocols(
1647 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1648 )
1649 migrated: list[tuple[str, str]] = []
1650
1651 with (
1652 patch.object(
1653 controller,
1654 "_migrate_universal_player_config",
1655 side_effect=lambda old, new: migrated.append((old, new)),
1656 ),
1657 patch.object(controller, "unregister", wraps=controller.unregister) as mock_unregister,
1658 ):
1659 child._attr_type = PlayerType.PLAYER
1660 await controller.register_or_update(child)
1661
1662 # the wrapper is on its way out, so the user's settings have to move to the
1663 # player that replaces it, which is left standalone rather than a child of it
1664 assert migrated == [("universal_1", "child")]
1665 assert child.protocol_parent_id is None
1666 # an earlier removal never gets that far: it leaves the wrapper's config behind
1667 # and the handover no longer finds the wrapper to carry anything over
1668 mock_unregister.assert_awaited_once_with(
1669 "universal_1", permanent=True, replacement_player_id="child"
1670 )
1671
1672 async def test_player_to_protocol_detaches_its_children(self, mock_mass: MagicMock) -> None:
1673 """A player that becomes a protocol child releases the protocols it owned."""
1674 controller = self._prepare(mock_mass)
1675 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1676 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1677 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1678 child.set_protocol_parent_id("parent")
1679 parent.set_linked_output_protocols(
1680 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1681 )
1682
1683 parent._attr_type = PlayerType.PROTOCOL
1684 await controller.register_or_update(parent)
1685
1686 # a protocol player cannot own protocol players of its own
1687 assert parent.linked_output_protocols == []
1688 assert child.protocol_parent_id is None
1689
1690
1691class TestCmdUngroupNewBranches:
1692 """
1693 Regression tests for the post-refactor cmd_ungroup flow.
1694
1695 The refactor changed two things:
1696
1697 - ``cmd_ungroup`` on a group player no longer calls ``cmd_set_members``
1698 (which would hit the "Cannot remove static member" guard); instead it
1699 stops or powers off the group entirely.
1700 - ``cmd_ungroup`` on a static member of a group recurses to ungroup the
1701 group, because static members cannot be released individually.
1702 """
1703
1704 @pytest.mark.asyncio
1705 async def test_ungroup_group_player_with_power_uses_power_off(
1706 self, mock_mass: MagicMock
1707 ) -> None:
1708 """Ungroup on a group with explicit power control routes through cmd_power(False)."""
1709 controller = PlayerController(mock_mass)
1710 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1711 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1712 group._attr_powered = True
1713 group._attr_group_members = ["member"]
1714 group._attr_supported_features = {PlayerFeature.POWER}
1715
1716 # ensure power_control resolves to NATIVE so cmd_ungroup uses the power path
1717 mock_mass.config.get_raw_player_config_value = MagicMock(
1718 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
1719 )
1720
1721 controller._players = {"g1": group}
1722 mock_mass.players = controller
1723
1724 # populate state.type / state.power_control / state.group_members
1725 group.set_initialized()
1726 group._cache.clear()
1727 group.update_state(signal_event=False)
1728
1729 called: dict[str, bool | str] = {}
1730
1731 async def _power(
1732 player_id: str,
1733 powered: bool,
1734 skip_auto_play: bool = False, # noqa: ARG001
1735 ) -> None:
1736 called["player_id"] = player_id
1737 called["powered"] = powered
1738
1739 controller._handle_cmd_power = _power # type: ignore[method-assign]
1740
1741 await controller.cmd_ungroup("g1")
1742
1743 assert called == {"player_id": "g1", "powered": False}
1744
1745 @pytest.mark.asyncio
1746 async def test_ungroup_powerless_group_calls_stop(self, mock_mass: MagicMock) -> None:
1747 """Ungroup on a powerless group falls through to _handle_cmd_stop."""
1748 controller = PlayerController(mock_mass)
1749 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1750 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1751 group._attr_powered = None # no power control
1752 group._attr_group_members = ["member"]
1753 # no POWER feature → power_control auto-selects to NONE
1754
1755 controller._players = {"g1": group}
1756 mock_mass.players = controller
1757
1758 group.set_initialized()
1759 group._cache.clear()
1760 group.update_state(signal_event=False)
1761
1762 stop_called: list[str] = []
1763
1764 async def _stop(player_id: str) -> None:
1765 stop_called.append(player_id)
1766
1767 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1768 # also stub power to make sure we did NOT go down that branch
1769 power_called: list[str] = []
1770
1771 async def _power(
1772 player_id: str,
1773 powered: bool, # noqa: ARG001
1774 skip_auto_play: bool = False, # noqa: ARG001
1775 ) -> None:
1776 power_called.append(player_id)
1777
1778 controller._handle_cmd_power = _power # type: ignore[method-assign]
1779
1780 await controller.cmd_ungroup("g1")
1781
1782 assert stop_called == ["g1"]
1783 assert power_called == [] # powerless group → never goes through cmd_power
1784
1785
1786class TestPowerOffEndsTheQueue:
1787 """
1788 Tests for how powering a player off ends what it was playing.
1789
1790 A power off has to clean up exactly as a stop command does. Stopping only the
1791 player leaves the queue's session open, so its preloading keeps pulling audio
1792 and a provider serving a live session (Spotify) stays tethered to Music
1793 Assistant for another track or two.
1794 """
1795
1796 @staticmethod
1797 def _powered_playing_player(
1798 mock_mass: MagicMock,
1799 player_type: PlayerType = PlayerType.PLAYER,
1800 ) -> tuple[PlayerController, MockPlayer, AsyncMock, AsyncMock]:
1801 """
1802 Build a powered, playing player that reaches the power-off stop branch.
1803
1804 :param mock_mass: The mock MusicAssistant instance to build it on.
1805 :param player_type: The type to register the player as.
1806 :return: The controller, the player, its stubbed player stop and queue stop.
1807 """
1808 controller = PlayerController(mock_mass)
1809 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
1810 player = MockPlayer(provider, "player_1", "Player 1", player_type=player_type)
1811 # no power control, so the command returns right after the stop - which is
1812 # the only part of it these tests are about
1813 mock_mass.config.get_raw_player_config_value = MagicMock(
1814 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NONE})
1815 )
1816 player._attr_powered = True
1817 player._attr_playback_state = PlaybackState.PLAYING
1818 controller._players = {"player_1": player}
1819 mock_mass.players = controller
1820 player.set_initialized()
1821 player._cache.clear()
1822 player.update_state(signal_event=False)
1823 player_stop = AsyncMock()
1824 controller._handle_cmd_stop = player_stop # type: ignore[method-assign]
1825 queue_stop = AsyncMock()
1826 mock_mass.player_queues._handle_stop = queue_stop
1827
1828 @contextlib.asynccontextmanager
1829 async def _no_wait(*_args: Any, **_kwargs: Any) -> AsyncIterator[None]:
1830 """Stand in for the real wait, which burns its full timeout here."""
1831 yield
1832
1833 controller.wait_for_player_update = _no_wait # type: ignore[method-assign]
1834 return controller, player, player_stop, queue_stop
1835
1836 @pytest.mark.asyncio
1837 async def test_power_off_stops_the_queue_the_player_is_playing(
1838 self, mock_mass: MagicMock
1839 ) -> None:
1840 """Powering off a player playing its own queue ends the queue, not just the device."""
1841 controller, _player, player_stop, queue_stop = self._powered_playing_player(mock_mass)
1842 mock_mass.player_queues.get = MagicMock(return_value=_stub_queue("player_1"))
1843
1844 await controller._handle_cmd_power("player_1", False)
1845
1846 queue_stop.assert_awaited_once_with("player_1")
1847 # the queue stop issues the player stop itself
1848 player_stop.assert_not_awaited()
1849
1850 @pytest.mark.asyncio
1851 async def test_power_off_playing_a_live_source_stops_the_player(
1852 self, mock_mass: MagicMock
1853 ) -> None:
1854 """A player playing a live external source has no queue to end."""
1855 controller, player, player_stop, queue_stop = self._powered_playing_player(mock_mass)
1856 # a live source publishes its own uri as the active source, which is not a queue id
1857 player._attr_active_source = "spotify_connect--abc://audio_source/main"
1858 player.update_state(signal_event=False)
1859 mock_mass.player_queues.get = MagicMock(return_value=None)
1860
1861 await controller._handle_cmd_power("player_1", False)
1862
1863 player_stop.assert_awaited_once_with("player_1")
1864 queue_stop.assert_not_awaited()
1865
1866 @pytest.mark.asyncio
1867 async def test_powering_off_a_protocol_player_leaves_its_parents_queue_alone(
1868 self, mock_mass: MagicMock
1869 ) -> None:
1870 """
1871 A protocol player must not reach for the queue get_active_queue resolves it to.
1872
1873 _handle_cmd_stop powers a protocol player off when it supports POWER, so
1874 ending its parent's queue here would stop that parent again and come
1875 straight back - forever.
1876 """
1877 controller, player, player_stop, queue_stop = self._powered_playing_player(
1878 mock_mass, player_type=PlayerType.PROTOCOL
1879 )
1880 player.set_protocol_parent_id("parent_player")
1881 player.update_state(signal_event=False)
1882 parent_queue = _stub_queue("parent_player")
1883 mock_mass.player_queues.get = MagicMock(
1884 side_effect=lambda queue_id: parent_queue if queue_id == "parent_player" else None
1885 )
1886 parent = MockPlayer(
1887 MockProvider("test_provider", instance_id="test_prov", mass=mock_mass),
1888 "parent_player",
1889 "Parent",
1890 )
1891 parent.set_initialized()
1892 parent.update_state(signal_event=False)
1893 controller._players["parent_player"] = parent
1894
1895 await controller._handle_cmd_power("player_1", False)
1896
1897 # get_active_queue resolved to the parent's queue, and it was left alone
1898 assert controller.get_active_queue(player) is parent_queue
1899 player_stop.assert_awaited_once_with("player_1")
1900 queue_stop.assert_not_awaited()
1901
1902 @pytest.mark.asyncio
1903 async def test_powering_off_a_group_member_does_not_end_the_groups_queue(
1904 self, mock_mass: MagicMock
1905 ) -> None:
1906 """A member of a group is ungrouped instead of stopped, so the group plays on."""
1907 mock_mass.config.get_raw_player_config_value = MagicMock(
1908 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NONE})
1909 )
1910 controller, group_player, member = _group_with_member(mock_mass)
1911 group_player._attr_powered = True
1912 member._attr_powered = True
1913 member._attr_playback_state = PlaybackState.PLAYING
1914 for player in (group_player, member):
1915 player.update_state(signal_event=False, force_update=True)
1916 assert member.state.active_group == "group"
1917 mock_mass.player_queues.get = MagicMock(return_value=_stub_queue("group"))
1918 player_stop = AsyncMock()
1919 controller._handle_cmd_stop = player_stop # type: ignore[method-assign]
1920 queue_stop = AsyncMock()
1921 mock_mass.player_queues._handle_stop = queue_stop
1922 ungrouped: list[str] = []
1923
1924 async def _ungroup(player_id: str) -> None:
1925 ungrouped.append(player_id)
1926
1927 controller.cmd_ungroup = _ungroup # type: ignore[method-assign]
1928
1929 await controller._handle_cmd_power("member", False)
1930
1931 assert ungrouped == ["member"]
1932 queue_stop.assert_not_awaited()
1933 player_stop.assert_not_awaited()
1934
1935
1936class TestExternalPowerOffUnsync:
1937 """
1938 Tests for unsyncing a player when its power is turned off outside of MA.
1939
1940 When a player's (final) power state flips on->off because its linked power
1941 control was switched off directly - rather than via an MA power command -
1942 the player must be removed from any (sync)group it is part of.
1943 """
1944
1945 def _make_synced_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
1946 """Build a controller with a player synced to a registered leader."""
1947 controller = PlayerController(mock_mass)
1948 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1949 leader = MockPlayer(provider, "leader", "Leader")
1950 leader._attr_group_members = ["leader", "p1"]
1951 player = MockPlayer(provider, "p1", "Player")
1952 controller._players = {"leader": leader, "p1": player}
1953 mock_mass.players = controller
1954 for _player in (leader, player):
1955 _player.set_initialized()
1956 _player._cache.clear()
1957 _player.update_state(signal_event=False)
1958 # isolate the unsync branch from the unrelated state-forwarding machinery
1959 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1960 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1961 return controller, player
1962
1963 def test_power_off_unsyncs_synced_player(self, mock_mass: MagicMock) -> None:
1964 """An on->off power transition ungroups a synced player."""
1965 controller, player = self._make_synced_player(mock_mass)
1966 assert player.state.synced_to == "leader"
1967
1968 controller.signal_player_state_update(player, {"powered": (True, False)})
1969
1970 controller.cmd_ungroup.assert_called_once_with("p1") # type: ignore[attr-defined]
1971
1972 def test_power_on_does_not_unsync(self, mock_mass: MagicMock) -> None:
1973 """An off->on power transition leaves the player synced."""
1974 controller, player = self._make_synced_player(mock_mass)
1975
1976 controller.signal_player_state_update(player, {"powered": (False, True)})
1977
1978 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1979
1980 def test_no_power_control_is_ignored(self, mock_mass: MagicMock) -> None:
1981 """A None->off transition (player without power control) is ignored."""
1982 controller, player = self._make_synced_player(mock_mass)
1983
1984 controller.signal_player_state_update(player, {"powered": (None, False)})
1985
1986 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1987
1988 def test_power_off_ungrouped_player_is_noop(self, mock_mass: MagicMock) -> None:
1989 """Powering off a player that is not in any group does nothing."""
1990 controller = PlayerController(mock_mass)
1991 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1992 player = MockPlayer(provider, "p1", "Player")
1993 controller._players = {"p1": player}
1994 mock_mass.players = controller
1995 player.set_initialized()
1996 player._cache.clear()
1997 player.update_state(signal_event=False)
1998 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1999 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
2000
2001 controller.signal_player_state_update(player, {"powered": (True, False)})
2002
2003 controller.cmd_ungroup.assert_not_called()
2004
2005
2006class TestPlayMediaOverride:
2007 """
2008 Tests for the new CONF_PLAY_MEDIA_OVERRIDES_GROUP behavior.
2009
2010 When a captured child player receives an explicit play_media command, the
2011 default behavior is to *release* it from the active group/sync and play
2012 directly on the targeted player. The legacy behavior (forward to the
2013 leader) is preserved via the per-player config opt-out.
2014 """
2015
2016 @pytest.mark.asyncio
2017 async def test_override_disabled_redirects_to_group(self, mock_mass: MagicMock) -> None:
2018 """With override disabled, play_media on a captured child redirects to the group."""
2019 controller = PlayerController(mock_mass)
2020 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
2021 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
2022
2023 class _SessionedGroup(MockPlayer):
2024 @property
2025 def is_active_session(self) -> bool:
2026 return True
2027
2028 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
2029 group._attr_powered = None
2030 group._attr_group_members = ["member"]
2031
2032 member = MockPlayer(member_provider, "member", "Member")
2033
2034 controller._players = {"g1": group, "member": member}
2035 mock_mass.players = controller
2036
2037 group.set_initialized()
2038 member.set_initialized()
2039 group.update_state(signal_event=False)
2040 member.update_state(signal_event=False)
2041 # sanity: the member is captured by the group
2042 assert member.state.active_group == "g1"
2043
2044 _set_play_media_override(mock_mass, False)
2045
2046 played_on: list[str] = []
2047
2048 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
2049 played_on.append(player_id)
2050
2051 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
2052 # the play_media wrapper acquires a playback lock; stub it out
2053 controller._player_command_locks = {}
2054
2055 media = MagicMock(uri="x", source_id="src")
2056 await controller.play_media("member", media)
2057
2058 # legacy behavior: redirected to the group leader
2059 assert played_on == ["g1"]
2060
2061 @pytest.mark.asyncio
2062 async def test_override_releases_dynamic_member(self, mock_mass: MagicMock) -> None:
2063 """With override enabled, play_media on a dynamic group member releases it first."""
2064 controller = PlayerController(mock_mass)
2065 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
2066 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
2067
2068 class _SessionedGroup(MockPlayer):
2069 @property
2070 def is_active_session(self) -> bool:
2071 return True
2072
2073 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
2074 group._attr_powered = None
2075 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2076 group._attr_group_members = ["member"]
2077 # NOT a static member ⇒ dynamic — can be removed via set_members
2078 group._attr_static_group_members = []
2079
2080 member = MockPlayer(member_provider, "member", "Member")
2081
2082 controller._players = {"g1": group, "member": member}
2083 mock_mass.players = controller
2084
2085 group.set_initialized()
2086 member.set_initialized()
2087 group.update_state(signal_event=False)
2088 member.update_state(signal_event=False)
2089 assert member.state.active_group == "g1"
2090
2091 # default: override enabled
2092 _set_play_media_override(mock_mass, True)
2093
2094 set_members_calls: list[dict[str, object]] = []
2095
2096 async def _cmd_set_members(
2097 target_player: str,
2098 player_ids_to_add: list[str] | None = None, # noqa: ARG001
2099 player_ids_to_remove: list[str] | None = None,
2100 ) -> None:
2101 set_members_calls.append(
2102 {"player_id": target_player, "remove": player_ids_to_remove or []}
2103 )
2104
2105 controller.cmd_set_members = _cmd_set_members # type: ignore[method-assign]
2106
2107 played_on: list[str] = []
2108
2109 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
2110 played_on.append(player_id)
2111
2112 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
2113 controller._player_command_locks = {}
2114
2115 media = MagicMock(uri="x", source_id="src")
2116 with patch.object(
2117 controller,
2118 "wait_for_player_update",
2119 _skip_player_update_wait,
2120 ):
2121 await controller.play_media("member", media)
2122
2123 # the member was removed from the group ...
2124 assert set_members_calls == [{"player_id": "g1", "remove": ["member"]}]
2125 # ... and then play_media was issued directly on the member, NOT on the group
2126 assert played_on == ["member"]
2127
2128 @pytest.mark.asyncio
2129 async def test_override_stops_static_group(self, mock_mass: MagicMock) -> None:
2130 """With override enabled, play_media on a STATIC group member stops the group."""
2131 controller = PlayerController(mock_mass)
2132 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
2133 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
2134
2135 class _SessionedGroup(MockPlayer):
2136 @property
2137 def is_active_session(self) -> bool:
2138 return True
2139
2140 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
2141 group._attr_powered = None # no power control ⇒ stop, not power-off
2142 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2143 group._attr_group_members = ["member"]
2144 # static member - cannot be removed individually
2145 group._attr_static_group_members = ["member"]
2146
2147 member = MockPlayer(member_provider, "member", "Member")
2148
2149 controller._players = {"g1": group, "member": member}
2150 mock_mass.players = controller
2151
2152 group.set_initialized()
2153 member.set_initialized()
2154 group.update_state(signal_event=False)
2155 member.update_state(signal_event=False)
2156 assert member.state.active_group == "g1"
2157
2158 _set_play_media_override(mock_mass, True)
2159
2160 stop_calls: list[str] = []
2161 power_calls: list[tuple[str, bool]] = []
2162
2163 async def _stop(player_id: str) -> None:
2164 stop_calls.append(player_id)
2165
2166 async def _power(
2167 player_id: str,
2168 powered: bool,
2169 skip_auto_play: bool = False, # noqa: ARG001
2170 ) -> None:
2171 power_calls.append((player_id, powered))
2172
2173 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
2174 controller._handle_cmd_power = _power # type: ignore[method-assign]
2175
2176 played_on: list[str] = []
2177
2178 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
2179 played_on.append(player_id)
2180
2181 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
2182 controller._player_command_locks = {}
2183
2184 media = MagicMock(uri="x", source_id="src")
2185 with patch.object(
2186 controller,
2187 "wait_for_player_update",
2188 _skip_player_update_wait,
2189 ):
2190 await controller.play_media("member", media)
2191
2192 # powerless group + static member: we should have stopped the group ...
2193 assert stop_calls == ["g1"]
2194 # ... not powered it off ...
2195 assert power_calls == []
2196 # ... and play_media was issued directly on the member
2197 assert played_on == ["member"]
2198
2199
2200class TestExternalSourcePlayPause:
2201 """Pause/play handling for externally-initiated sources (no active output protocol)."""
2202
2203 @staticmethod
2204 def _make_external_source_player(
2205 provider: MockProvider,
2206 controller: PlayerController,
2207 mock_mass: MagicMock,
2208 *,
2209 playback_state: PlaybackState,
2210 can_play_pause: bool = True,
2211 supports_pause: bool = True,
2212 ) -> MockPlayer:
2213 """Build a player playing a passive external source, with no active output protocol."""
2214 player = MockPlayer(provider, "player_1", "Test Player")
2215 player._attr_supported_features = {PlayerFeature.PAUSE} if supports_pause else set()
2216 player._attr_source_list = [
2217 PlayerSource(
2218 id="spotify",
2219 name="Spotify",
2220 passive=True,
2221 can_play_pause=can_play_pause,
2222 can_next_previous=True,
2223 can_seek=True,
2224 )
2225 ]
2226 player._attr_active_source = "spotify"
2227 player._attr_playback_state = playback_state
2228 player._cache.clear()
2229 controller._players = {"player_1": player}
2230 mock_mass.players = controller
2231 mock_mass.player_queues = MagicMock()
2232 mock_mass.player_queues.get = MagicMock(return_value=None)
2233 player.update_state(signal_event=False)
2234 return player
2235
2236 def test_pause_external_source_forwards_to_player(
2237 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
2238 ) -> None:
2239 """Pausing a pausable external source forwards to the player, not STOP."""
2240 player = self._make_external_source_player(
2241 provider, controller, mock_mass, playback_state=PlaybackState.PLAYING
2242 )
2243 player.pause = AsyncMock() # type: ignore[method-assign]
2244 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2245
2246 asyncio.run(controller._handle_cmd_pause("player_1"))
2247
2248 player.pause.assert_awaited_once()
2249 controller._handle_cmd_stop.assert_not_called()
2250
2251 def test_play_external_source_unpauses_player(
2252 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
2253 ) -> None:
2254 """Unpausing a paused external source forwards to the player, not a restart."""
2255 player = self._make_external_source_player(
2256 provider, controller, mock_mass, playback_state=PlaybackState.PAUSED
2257 )
2258 player.play = AsyncMock() # type: ignore[method-assign]
2259 player.play_media = AsyncMock() # type: ignore[method-assign]
2260 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
2261
2262 asyncio.run(controller._handle_cmd_play("player_1"))
2263
2264 player.play.assert_awaited_once()
2265 player.play_media.assert_not_called()
2266 controller._handle_select_source.assert_not_called()
2267
2268 def test_pause_falls_back_to_stop_without_pause_support(
2269 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
2270 ) -> None:
2271 """A player that cannot pause natively still falls back to STOP."""
2272 player = self._make_external_source_player(
2273 provider,
2274 controller,
2275 mock_mass,
2276 playback_state=PlaybackState.PLAYING,
2277 supports_pause=False,
2278 )
2279 player.pause = AsyncMock() # type: ignore[method-assign]
2280 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2281
2282 asyncio.run(controller._handle_cmd_pause("player_1"))
2283
2284 controller._handle_cmd_stop.assert_awaited_once()
2285 player.pause.assert_not_called()
2286
2287
2288class TestProtocolOutputPlayPause:
2289 """Play/pause on a player rendering through a linked output protocol."""
2290
2291 @staticmethod
2292 def _make_player_on_protocol(
2293 mock_mass: MagicMock,
2294 controller: PlayerController,
2295 *,
2296 playback_state: PlaybackState,
2297 ) -> MockPlayer:
2298 """Build a player playing the MA queue through a protocol that cannot pause."""
2299 native_provider = MockProvider("chromecast", mass=mock_mass)
2300 player = MockPlayer(native_provider, "player_1", "Test Player")
2301 player._attr_supported_features.add(PlayerFeature.PAUSE)
2302 player._attr_playback_state = playback_state
2303
2304 protocol_provider = MockProvider("sendspin", mass=mock_mass)
2305 protocol_player = MockPlayer(
2306 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
2307 )
2308 protocol_player._attr_playback_state = playback_state
2309
2310 controller._players = {"player_1": player, "proto_1": protocol_player}
2311 mock_mass.players = controller
2312 mock_mass.player_queues = MagicMock()
2313 # a non-empty queue, so the MA queue source advertises play/pause support
2314 queue = MagicMock()
2315 queue.items = [MagicMock()]
2316 mock_mass.player_queues.get = MagicMock(return_value=queue)
2317 player.set_linked_output_protocols(
2318 [
2319 LinkedOutputProtocol(
2320 output_protocol_id="proto_1",
2321 protocol_domain="sendspin",
2322 priority=40,
2323 )
2324 ]
2325 )
2326 player.set_active_output_protocol("proto_1")
2327 player.set_active_mass_source("player_1")
2328 protocol_player.update_state(signal_event=False)
2329 player.refresh_state(signal_event=False)
2330 return player
2331
2332 async def test_pause_on_protocol_without_pause_falls_back_to_stop(
2333 self, mock_mass: MagicMock, controller: PlayerController
2334 ) -> None:
2335 """The native transport has no session to pause while a protocol renders the audio."""
2336 player = self._make_player_on_protocol(
2337 mock_mass, controller, playback_state=PlaybackState.PLAYING
2338 )
2339 player.pause = AsyncMock() # type: ignore[method-assign]
2340 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2341
2342 await controller._handle_cmd_pause("player_1")
2343
2344 player.pause.assert_not_called()
2345 # STOP goes to the visible player, not the protocol player
2346 controller._handle_cmd_stop.assert_awaited_once_with("player_1")
2347
2348 async def test_play_on_protocol_without_pause_does_not_unpause_natively(
2349 self, mock_mass: MagicMock, controller: PlayerController
2350 ) -> None:
2351 """Unpausing must not hit the native transport either; the source is restarted."""
2352 player = self._make_player_on_protocol(
2353 mock_mass, controller, playback_state=PlaybackState.PAUSED
2354 )
2355 player.play = AsyncMock() # type: ignore[method-assign]
2356 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
2357
2358 await controller._handle_cmd_play("player_1")
2359
2360 player.play.assert_not_called()
2361 # the MA queue source is restarted, not some other source
2362 controller._handle_select_source.assert_awaited_once_with("player_1", "player_1")
2363
2364
2365class TestMirrorsParentMedia:
2366 """Tests for _mirrors_parent_media (palette-fetch gating for grouped players)."""
2367
2368 @staticmethod
2369 def _fake_player(
2370 *,
2371 player_id: str = "p1",
2372 active_group: str | None = None,
2373 synced_to: str | None = None,
2374 player_type: PlayerType = PlayerType.PLAYER,
2375 protocol_parent_id: str | None = None,
2376 ) -> SimpleNamespace:
2377 return SimpleNamespace(
2378 player_id=player_id,
2379 state=SimpleNamespace(active_group=active_group, synced_to=synced_to, type=player_type),
2380 protocol_parent_id=protocol_parent_id,
2381 )
2382
2383 def test_standalone_player_owns_media(self, controller: PlayerController) -> None:
2384 """A standalone player resolves its own media (and palette)."""
2385 assert controller._mirrors_parent_media(self._fake_player()) is False # type: ignore[arg-type]
2386
2387 def test_group_member_mirrors(self, controller: PlayerController) -> None:
2388 """A group member borrows its parent's media."""
2389 assert controller._mirrors_parent_media(self._fake_player(active_group="g1")) is True # type: ignore[arg-type]
2390
2391 def test_synced_member_mirrors(self, controller: PlayerController) -> None:
2392 """A synced member borrows its leader's media."""
2393 assert controller._mirrors_parent_media(self._fake_player(synced_to="leader")) is True # type: ignore[arg-type]
2394
2395 def test_protocol_child_mirrors(self, controller: PlayerController) -> None:
2396 """A protocol child borrows its parent's media."""
2397 player = self._fake_player(player_type=PlayerType.PROTOCOL, protocol_parent_id="parent")
2398 assert controller._mirrors_parent_media(player) is True # type: ignore[arg-type]
2399
2400 def test_protocol_player_without_parent_owns_media(self, controller: PlayerController) -> None:
2401 """A protocol player with no parent resolves its own media."""
2402 player = self._fake_player(player_type=PlayerType.PROTOCOL)
2403 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
2404
2405 def test_self_referential_parent_owns_media(self, controller: PlayerController) -> None:
2406 """A self-referential active_group/synced_to is not a real parent, so resolve locally."""
2407 player = self._fake_player(player_id="p1", synced_to="p1", active_group="p1")
2408 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
2409
2410
2411class TestVolumeScalingOnRedirect:
2412 """min/max volume scaling must survive a redirect to a protocol player or external control."""
2413
2414 @staticmethod
2415 def _volume_player(
2416 player_id: str,
2417 volume_control: str,
2418 volume_set: AsyncMock | None = None,
2419 ) -> SimpleNamespace:
2420 return SimpleNamespace(
2421 player_id=player_id,
2422 type=PlayerType.PLAYER,
2423 protocol_parent_id=None,
2424 extra_data={},
2425 volume_control=volume_control,
2426 mute_control=PLAYER_CONTROL_NONE,
2427 volume_set=volume_set or AsyncMock(),
2428 update_state=MagicMock(),
2429 provider=MagicMock(),
2430 state=SimpleNamespace(
2431 name=player_id,
2432 volume_control=volume_control,
2433 volume_muted=False,
2434 mute_control=PLAYER_CONTROL_NONE,
2435 ),
2436 )
2437
2438 @pytest.mark.asyncio
2439 async def test_protocol_redirect_forwards_scaled_volume(
2440 self, controller: PlayerController, mock_mass: MagicMock
2441 ) -> None:
2442 """A volume command redirected to a protocol player honors the user-facing max_volume."""
2443
2444 def _conf(player_id: str, key: str, default: object = None) -> object:
2445 if key == "min_volume":
2446 return 0
2447 if key == "max_volume":
2448 # user-facing player caps at 50, the protocol player has no limits of its own
2449 return 50 if player_id == "user_player" else 100
2450 return default
2451
2452 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2453
2454 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
2455 user = self._volume_player("user_player", "protocol_player")
2456 players = {"user_player": user, "protocol_player": protocol}
2457
2458 with (
2459 patch.object(controller, "get_player", side_effect=players.get),
2460 patch.object(controller, "_get_active_audio_source", return_value=None),
2461 ):
2462 controller._controls = {}
2463 await controller._handle_cmd_volume_set("user_player", 100)
2464
2465 # logical 100 with a max_volume of 50 must reach the protocol player as 50, not the raw 100
2466 protocol.volume_set.assert_awaited_once_with(50)
2467
2468 @pytest.mark.asyncio
2469 async def test_protocol_redirect_applies_the_limits_only_once(
2470 self, controller: PlayerController, mock_mass: MagicMock
2471 ) -> None:
2472 """Limits configured on the protocol player do not scale the command a second time."""
2473
2474 def _conf(_player_id: str, key: str, default: object = None) -> object:
2475 if key == "min_volume":
2476 return 0
2477 if key == "max_volume":
2478 # both players carry a limit; only the addressed one may apply
2479 return 50
2480 return default
2481
2482 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2483
2484 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
2485 user = self._volume_player("user_player", "protocol_player")
2486 players = {"user_player": user, "protocol_player": protocol}
2487
2488 with (
2489 patch.object(controller, "get_player", side_effect=players.get),
2490 patch.object(controller, "_get_active_audio_source", return_value=None),
2491 ):
2492 controller._controls = {}
2493 await controller._handle_cmd_volume_set("user_player", 100)
2494
2495 protocol.volume_set.assert_awaited_once_with(50)
2496
2497 @pytest.mark.asyncio
2498 async def test_external_control_redirect_forwards_scaled_volume(
2499 self, controller: PlayerController, mock_mass: MagicMock
2500 ) -> None:
2501 """A volume command redirected to an external control honors the user-facing max_volume."""
2502 mock_mass.config.get_raw_player_config_value = MagicMock(
2503 side_effect=_player_config_stub(max_volume=50)
2504 )
2505
2506 volume_set = AsyncMock()
2507 control = PlayerControl(
2508 id="ext_control",
2509 provider="test",
2510 name="External Amp",
2511 supports_volume=True,
2512 volume_set=volume_set,
2513 )
2514 user = self._volume_player("user_player", "ext_control")
2515 players = {"user_player": user}
2516
2517 with (
2518 patch.object(controller, "get_player", side_effect=players.get),
2519 patch.object(controller, "_get_active_audio_source", return_value=None),
2520 ):
2521 controller._controls = {"ext_control": control}
2522 await controller._handle_cmd_volume_set("user_player", 100)
2523
2524 volume_set.assert_awaited_once_with(50)
2525
2526 @pytest.mark.asyncio
2527 async def test_external_control_without_volume_support_raises(
2528 self, controller: PlayerController, mock_mass: MagicMock
2529 ) -> None:
2530 """A volume command redirected to a control lacking volume support is rejected."""
2531 mock_mass.config.get_raw_player_config_value = MagicMock(
2532 side_effect=_player_config_stub(max_volume=50)
2533 )
2534
2535 volume_set = AsyncMock()
2536 control = PlayerControl(
2537 id="ext_control",
2538 provider="test",
2539 name="External Amp",
2540 supports_volume=False,
2541 volume_set=volume_set,
2542 )
2543 user = self._volume_player("user_player", "ext_control")
2544 players = {"user_player": user}
2545
2546 with (
2547 patch.object(controller, "get_player", side_effect=players.get),
2548 patch.object(controller, "_get_active_audio_source", return_value=None),
2549 ):
2550 controller._controls = {"ext_control": control}
2551 with pytest.raises(UnsupportedFeaturedException):
2552 await controller._handle_cmd_volume_set("user_player", 100)
2553
2554 volume_set.assert_not_awaited()
2555
2556
2557class TestExternalPowerControl:
2558 """Power commands redirected to an external PlayerControl must forward and gate correctly."""
2559
2560 def _make_player(
2561 self, mock_mass: MagicMock, control: PlayerControl
2562 ) -> tuple[PlayerController, MockPlayer]:
2563 """Build a controller with a single player whose power control is the given control."""
2564 mock_mass.config.get_raw_player_config_value = MagicMock(
2565 side_effect=_player_config_stub({CONF_POWER_CONTROL: control.id})
2566 )
2567 controller = PlayerController(mock_mass)
2568 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2569 player = MockPlayer(provider, "player_1", "Player 1")
2570 controller._controls = {control.id: control}
2571 controller._players = {"player_1": player}
2572 mock_mass.players = controller
2573 # auto-play would otherwise resume the (mocked) player queue on power on
2574 config_get_value = player.config.get_value
2575 player.config.get_value = MagicMock( # type: ignore[method-assign]
2576 side_effect=lambda key, *args, **kwargs: (
2577 False if key == CONF_AUTO_PLAY else config_get_value(key, *args, **kwargs)
2578 )
2579 )
2580 player.set_initialized()
2581 player.update_state(signal_event=False)
2582 return controller, player
2583
2584 async def test_power_on_forwards_to_control(self, mock_mass: MagicMock) -> None:
2585 """Powering on a player redirects to its external control's power_on callback."""
2586 power_on = AsyncMock()
2587 power_off = AsyncMock()
2588 control = PlayerControl(
2589 id="ext_power",
2590 provider="test",
2591 name="External Power",
2592 supports_power=True,
2593 power_on=power_on,
2594 power_off=power_off,
2595 )
2596
2597 def _report_powered_on() -> None:
2598 control.power_state = True
2599
2600 # the control only reports on once switched on, which releases wait_for_power_on
2601 power_on.side_effect = _report_powered_on
2602 controller, player = self._make_player(mock_mass, control)
2603 assert player.state.powered is False
2604
2605 await controller._handle_cmd_power("player_1", True)
2606
2607 power_on.assert_awaited_once()
2608 power_off.assert_not_awaited()
2609
2610 async def test_power_on_waits_on_the_control(self, mock_mass: MagicMock) -> None:
2611 """Powering on waits for the control to report on, not for the player itself."""
2612 control = PlayerControl(
2613 id="ext_power",
2614 provider="test",
2615 name="External Power",
2616 supports_power=True,
2617 power_on=AsyncMock(),
2618 power_off=AsyncMock(),
2619 )
2620 controller, player = self._make_player(mock_mass, control)
2621 assert player.state.powered is False
2622
2623 with patch(
2624 "music_assistant.controllers.players.controller.wait_for_power_on", AsyncMock()
2625 ) as wait_for_power_on:
2626 await controller._handle_cmd_power("player_1", True)
2627
2628 wait_for_power_on.assert_awaited_once()
2629 assert wait_for_power_on.await_args is not None
2630 assert wait_for_power_on.await_args.args[2] is control
2631
2632 async def test_power_off_forwards_to_control(self, mock_mass: MagicMock) -> None:
2633 """Powering off a player redirects to its external control's power_off callback."""
2634 power_on = AsyncMock()
2635 power_off = AsyncMock()
2636 control = PlayerControl(
2637 id="ext_power",
2638 provider="test",
2639 name="External Power",
2640 supports_power=True,
2641 power_state=True,
2642 power_on=power_on,
2643 power_off=power_off,
2644 )
2645 controller, player = self._make_player(mock_mass, control)
2646 assert player.state.powered is True
2647
2648 await controller._handle_cmd_power("player_1", False)
2649
2650 power_off.assert_awaited_once()
2651 power_on.assert_not_awaited()
2652
2653 async def test_control_without_power_support_raises(self, mock_mass: MagicMock) -> None:
2654 """A power command redirected to a control lacking power support is rejected."""
2655 power_on = AsyncMock()
2656 power_off = AsyncMock()
2657 control = PlayerControl(
2658 id="ext_power",
2659 provider="test",
2660 name="External Power",
2661 supports_power=False,
2662 power_on=power_on,
2663 power_off=power_off,
2664 )
2665 controller, player = self._make_player(mock_mass, control)
2666 assert player.state.powered is False
2667
2668 with pytest.raises(UnsupportedFeaturedException):
2669 await controller._handle_cmd_power("player_1", True)
2670
2671 power_on.assert_not_awaited()
2672 power_off.assert_not_awaited()
2673
2674
2675class TestEnforceVolumeLimits:
2676 """External volume changes outside the min/max range must be corrected."""
2677
2678 @staticmethod
2679 def _set_limits(mock_mass: MagicMock, min_volume: int, max_volume: int) -> None:
2680 mock_mass.config.get_raw_player_config_value = MagicMock(
2681 side_effect=_player_config_stub(min_volume=min_volume, max_volume=max_volume)
2682 )
2683
2684 @staticmethod
2685 def _player(logical_volume: int | None) -> SimpleNamespace:
2686 return SimpleNamespace(
2687 player_id="user_player",
2688 state=SimpleNamespace(volume_level=logical_volume),
2689 )
2690
2691 def test_out_of_range_volume_is_corrected(
2692 self, controller: PlayerController, mock_mass: MagicMock
2693 ) -> None:
2694 """A device volume above max_volume (logical > 100) is clamped back to logical 100."""
2695 self._set_limits(mock_mass, 0, 80)
2696 # device volume 100 with max 80 resolves to logical 125
2697 player = self._player(125)
2698 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2699 controller._enforce_volume_limits(cast("MockPlayer", player))
2700 cmd.assert_called_once_with("user_player", 100)
2701 mock_mass.create_task.assert_called_once()
2702
2703 def test_below_min_volume_is_corrected(
2704 self, controller: PlayerController, mock_mass: MagicMock
2705 ) -> None:
2706 """A device volume below min_volume (logical < 0) is clamped back to logical 0."""
2707 self._set_limits(mock_mass, 20, 100)
2708 # device volume 10 with min 20 resolves to a negative logical volume
2709 player = self._player(-13)
2710 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2711 controller._enforce_volume_limits(cast("MockPlayer", player))
2712 cmd.assert_called_once_with("user_player", 0)
2713
2714 def test_in_range_volume_is_untouched(
2715 self, controller: PlayerController, mock_mass: MagicMock
2716 ) -> None:
2717 """A logical volume within 0-100 needs no correction."""
2718 self._set_limits(mock_mass, 0, 80)
2719 player = self._player(100)
2720 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2721 controller._enforce_volume_limits(cast("MockPlayer", player))
2722 cmd.assert_not_called()
2723
2724 def test_no_limits_configured_is_noop(
2725 self, controller: PlayerController, mock_mass: MagicMock
2726 ) -> None:
2727 """Default 0-100 limits skip enforcement entirely."""
2728 self._set_limits(mock_mass, 0, 100)
2729 player = self._player(100)
2730 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2731 controller._enforce_volume_limits(cast("MockPlayer", player))
2732 cmd.assert_not_called()
2733
2734 def test_unknown_volume_is_noop(
2735 self, controller: PlayerController, mock_mass: MagicMock
2736 ) -> None:
2737 """A player without a resolved volume level is left alone."""
2738 self._set_limits(mock_mass, 0, 80)
2739 player = self._player(None)
2740 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2741 controller._enforce_volume_limits(cast("MockPlayer", player))
2742 cmd.assert_not_called()
2743
2744
2745class TestFakeMuteControl:
2746 """Fake mute must report the muted state and restore the volume on unmute."""
2747
2748 def _make_player(
2749 self, mock_mass: MagicMock, volume_level: int | None = 40
2750 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
2751 """
2752 Build a controller with a single player using fake mute control.
2753
2754 :param mock_mass: the mocked MusicAssistant instance.
2755 :param volume_level: initial volume level of the player, None if unknown.
2756 """
2757 mock_mass.config.get_raw_player_config_value = MagicMock(
2758 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
2759 )
2760 controller = PlayerController(mock_mass)
2761 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2762 player = MockPlayer(provider, "player_1", "Player 1")
2763 controller._players = {"player_1": player}
2764 mock_mass.players = controller
2765 mock_mass.player_queues.get = MagicMock(return_value=None)
2766 player.set_initialized()
2767 player._attr_volume_level = volume_level
2768 # let the mocked native volume control behave like a real device
2769 volume_set = AsyncMock(
2770 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2771 )
2772 player.volume_set = volume_set # type: ignore[method-assign]
2773 player.update_state(signal_event=False)
2774 return controller, player, volume_set
2775
2776 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
2777 """Muting reports volume_muted=True and unmuting restores the previous volume."""
2778 controller, player, volume_set = self._make_player(mock_mass)
2779
2780 await controller.cmd_volume_mute("player_1", True)
2781 muted_state = player.state
2782 assert muted_state.volume_muted is True
2783 assert muted_state.volume_level == 0
2784 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2785
2786 await controller.cmd_volume_mute("player_1", False)
2787 volume_set.assert_awaited_with(40)
2788 # simulate the device reporting back its state after the volume command
2789 player.update_state()
2790 unmuted_state = player.state
2791 assert unmuted_state.volume_muted is False
2792 assert unmuted_state.volume_level == 40
2793
2794 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
2795 """A repeated mute command must not overwrite the stored volume with 0."""
2796 controller, player, volume_set = self._make_player(mock_mass)
2797
2798 await controller.cmd_volume_mute("player_1", True)
2799 await controller.cmd_volume_mute("player_1", True)
2800 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2801 assert player.state.volume_muted is True
2802
2803 await controller.cmd_volume_mute("player_1", False)
2804 volume_set.assert_awaited_with(40)
2805
2806 async def test_unmute_with_unknown_previous_volume(self, mock_mass: MagicMock) -> None:
2807 """Unmuting a player whose volume was unknown at mute time restores a low volume."""
2808 controller, player, volume_set = self._make_player(mock_mass, volume_level=None)
2809
2810 await controller.cmd_volume_mute("player_1", True)
2811 assert player.extra_data[ATTR_PREVIOUS_VOLUME] is None
2812
2813 await controller.cmd_volume_mute("player_1", False)
2814 volume_set.assert_awaited_with(1)
2815 player.update_state()
2816 assert player.state.volume_muted is False
2817
2818 async def test_unmute_of_unmuted_player_keeps_volume(self, mock_mass: MagicMock) -> None:
2819 """An unmute command for a player that is not muted may not touch the volume."""
2820 controller, player, volume_set = self._make_player(mock_mass, volume_level=50)
2821
2822 await controller.cmd_volume_mute("player_1", False)
2823 volume_set.assert_not_awaited()
2824 assert player.state.volume_level == 50
2825
2826 async def test_unmute_restores_a_stored_zero_volume(self, mock_mass: MagicMock) -> None:
2827 """A player that was already silent stays silent after mute and unmute."""
2828 controller, _player, volume_set = self._make_player(mock_mass, volume_level=0)
2829
2830 await controller.cmd_volume_mute("player_1", True)
2831 await controller.cmd_volume_mute("player_1", False)
2832 volume_set.assert_awaited_with(0)
2833
2834 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
2835 """A regular volume change while fake muted implies an unmute."""
2836 controller, player, _volume_set = self._make_player(mock_mass)
2837
2838 await controller.cmd_volume_mute("player_1", True)
2839 muted_state = player.state
2840 assert muted_state.volume_muted is True
2841
2842 await controller.cmd_volume_set("player_1", 25)
2843 # simulate the device reporting back its state after the volume command
2844 player.update_state()
2845 unmuted_state = player.state
2846 assert unmuted_state.volume_muted is False
2847 assert unmuted_state.volume_level == 25
2848
2849
2850class TestVolumeStep:
2851 """The volume_step core config setting controls the size of a single volume nudge."""
2852
2853 def _make_player(
2854 self, mock_mass: MagicMock, step: int | None, volume_level: int
2855 ) -> tuple[PlayerController, MockPlayer]:
2856 """Build a controller with a single player and the given volume_step config."""
2857 controller = PlayerController(mock_mass)
2858 controller.config = _volume_step_config(step)
2859 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2860 player = MockPlayer(provider, "player_1", "Player 1")
2861 player._attr_volume_level = volume_level
2862 # let the mocked native volume control behave like a real device
2863 player.volume_set = AsyncMock( # type: ignore[method-assign]
2864 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2865 )
2866 controller._players = {"player_1": player}
2867 mock_mass.players = controller
2868 mock_mass.player_queues.get = MagicMock(return_value=None)
2869 player.set_initialized()
2870 player.update_state(signal_event=False)
2871 return controller, player
2872
2873 def _make_synced_pair(
2874 self, mock_mass: MagicMock, step: int | None
2875 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2876 """Build a leader synced to one member, both at volume 50, with a volume_step config."""
2877 controller = PlayerController(mock_mass)
2878 controller.config = _volume_step_config(step)
2879 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2880 players: dict[str, MockPlayer] = {}
2881 for player_id in ("leader", "member"):
2882 player = MockPlayer(provider, player_id, player_id.title())
2883 player._attr_volume_level = 50
2884 # let the mocked native volume control behave like a real device
2885 player.volume_set = AsyncMock( # type: ignore[method-assign]
2886 side_effect=lambda volume, _player=player: setattr(
2887 _player, "_attr_volume_level", volume
2888 )
2889 )
2890 players[player_id] = player
2891 players["leader"]._attr_group_members = ["member"]
2892 controller._players = dict(players)
2893 mock_mass.players = controller
2894 mock_mass.player_queues.get = MagicMock(return_value=None)
2895 for player in players.values():
2896 player.set_initialized()
2897 player._cache.clear()
2898 player.update_state(signal_event=False)
2899 # a second, forced pass: update_state() only recalculates when a player's own
2900 # attributes changed, so the leader must be forced to re-derive its group_volume
2901 # from the now-initialized member.
2902 for player in players.values():
2903 player.update_state(force_update=True, signal_event=False)
2904 return controller, players
2905
2906 @pytest.mark.parametrize(
2907 ("start", "expected"),
2908 [(5, 6), (20, 22), (50, 53), (80, 82), (95, 96)],
2909 )
2910 async def test_default_step_up_matches_the_adaptive_ladder(
2911 self, mock_mass: MagicMock, start: int, expected: int
2912 ) -> None:
2913 """With volume_step at its default (0), volume_up keeps today's adaptive ladder."""
2914 controller, player = self._make_player(mock_mass, None, start)
2915
2916 await controller.cmd_volume_up("player_1")
2917
2918 player.update_state()
2919 assert player.state.volume_level == expected
2920
2921 @pytest.mark.parametrize(
2922 ("start", "expected"),
2923 [(5, 4), (20, 18), (50, 47), (80, 78), (95, 94)],
2924 )
2925 async def test_default_step_down_matches_the_adaptive_ladder(
2926 self, mock_mass: MagicMock, start: int, expected: int
2927 ) -> None:
2928 """With volume_step at its default (0), volume_down keeps today's adaptive ladder."""
2929 controller, player = self._make_player(mock_mass, None, start)
2930
2931 await controller.cmd_volume_down("player_1")
2932
2933 player.update_state()
2934 assert player.state.volume_level == expected
2935
2936 async def test_configured_step_moves_up_by_a_flat_amount_mid_range(
2937 self, mock_mass: MagicMock
2938 ) -> None:
2939 """A configured flat step of 5 moves by exactly 5 in the middle of the range."""
2940 controller, player = self._make_player(mock_mass, 5, 50)
2941
2942 await controller.cmd_volume_up("player_1")
2943
2944 player.update_state()
2945 assert player.state.volume_level == 55
2946
2947 async def test_configured_step_moves_up_by_a_flat_amount_near_the_extreme(
2948 self, mock_mass: MagicMock
2949 ) -> None:
2950 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2951 controller, player = self._make_player(mock_mass, 5, 5)
2952
2953 await controller.cmd_volume_up("player_1")
2954
2955 player.update_state()
2956 assert player.state.volume_level == 10
2957
2958 async def test_configured_step_moves_down_by_a_flat_amount_mid_range(
2959 self, mock_mass: MagicMock
2960 ) -> None:
2961 """A configured flat step of 5 moves down by exactly 5 in the middle of the range."""
2962 controller, player = self._make_player(mock_mass, 5, 50)
2963
2964 await controller.cmd_volume_down("player_1")
2965
2966 player.update_state()
2967 assert player.state.volume_level == 45
2968
2969 async def test_configured_step_moves_down_by_a_flat_amount_near_the_extreme(
2970 self, mock_mass: MagicMock
2971 ) -> None:
2972 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2973 controller, player = self._make_player(mock_mass, 5, 95)
2974
2975 await controller.cmd_volume_down("player_1")
2976
2977 player.update_state()
2978 assert player.state.volume_level == 90
2979
2980 async def test_large_configured_step_clamps_up_at_the_maximum(
2981 self, mock_mass: MagicMock
2982 ) -> None:
2983 """A large configured step clamps volume_up at 100."""
2984 controller, player = self._make_player(mock_mass, 10, 95)
2985
2986 await controller.cmd_volume_up("player_1")
2987
2988 player.update_state()
2989 assert player.state.volume_level == 100
2990
2991 async def test_large_configured_step_clamps_down_at_zero(self, mock_mass: MagicMock) -> None:
2992 """A large configured step clamps volume_down at 0."""
2993 controller, player = self._make_player(mock_mass, 10, 5)
2994
2995 await controller.cmd_volume_down("player_1")
2996
2997 player.update_state()
2998 assert player.state.volume_level == 0
2999
3000 async def test_group_volume_up_with_default_step_uses_the_ladder(
3001 self, mock_mass: MagicMock
3002 ) -> None:
3003 """cmd_group_volume_up honours the default (0) adaptive ladder too."""
3004 controller, players = self._make_synced_pair(mock_mass, None)
3005
3006 await controller.cmd_group_volume_up("leader")
3007
3008 for player in players.values():
3009 player.update_state()
3010 assert player.state.volume_level == 53
3011
3012 async def test_group_volume_down_with_default_step_uses_the_ladder(
3013 self, mock_mass: MagicMock
3014 ) -> None:
3015 """cmd_group_volume_down honours the default (0) adaptive ladder too."""
3016 controller, players = self._make_synced_pair(mock_mass, None)
3017
3018 await controller.cmd_group_volume_down("leader")
3019
3020 for player in players.values():
3021 player.update_state()
3022 assert player.state.volume_level == 47
3023
3024 async def test_group_volume_up_with_configured_step(self, mock_mass: MagicMock) -> None:
3025 """cmd_group_volume_up honours a configured flat step."""
3026 controller, players = self._make_synced_pair(mock_mass, 5)
3027
3028 await controller.cmd_group_volume_up("leader")
3029
3030 for player in players.values():
3031 player.update_state()
3032 assert player.state.volume_level == 55
3033
3034 async def test_group_volume_down_with_configured_step(self, mock_mass: MagicMock) -> None:
3035 """cmd_group_volume_down honours a configured flat step."""
3036 controller, players = self._make_synced_pair(mock_mass, 5)
3037
3038 await controller.cmd_group_volume_down("leader")
3039
3040 for player in players.values():
3041 player.update_state()
3042 assert player.state.volume_level == 45
3043
3044 async def test_config_entry_exposes_default_and_range(
3045 self, controller: PlayerController
3046 ) -> None:
3047 """get_config_entries returns the volume_step entry with its default and range."""
3048 entries = await controller.get_config_entries()
3049
3050 entry = next(entry for entry in entries if entry.key == CONF_VOLUME_STEP)
3051 assert entry.type == ConfigEntryType.INTEGER
3052 assert entry.default_value == 0
3053 assert entry.range == (0, 10)
3054
3055
3056class SlowDevice(NamedTuple):
3057 """A mocked device that takes its time to answer its first volume command."""
3058
3059 player_id: str
3060 reached: asyncio.Event
3061
3062
3063class TestGroupVolumeOrdering:
3064 """Group volume commands that overlap are handled one after the other."""
3065
3066 def _make_synced_pair(
3067 self,
3068 mock_mass: MagicMock,
3069 slow_device: SlowDevice | None = None,
3070 volumes: dict[str, int] | None = None,
3071 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3072 """
3073 Build a mute capable leader synced to one member.
3074
3075 :param slow_device: When given, the named player takes its time to answer its
3076 first volume command and reports as soon as it received that command.
3077 :param volumes: Volume level per player, defaults to 50 for both.
3078 """
3079 controller = PlayerController(mock_mass)
3080 controller.config = _volume_step_config(5)
3081 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3082 players: dict[str, MockPlayer] = {}
3083 for player_id in ("leader", "member"):
3084 player = MockPlayer(provider, player_id, player_id.title())
3085 player._attr_supported_features = {
3086 PlayerFeature.VOLUME_SET,
3087 PlayerFeature.VOLUME_MUTE,
3088 }
3089 player._attr_volume_level = (volumes or {}).get(player_id, 50)
3090
3091 # let the mocked native volume control behave like a real device, that may
3092 # take long enough to answer for a later command to overtake it
3093 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
3094 if (
3095 slow_device is not None
3096 and slow_device.player_id == _player.player_id
3097 and not slow_device.reached.is_set()
3098 ):
3099 slow_device.reached.set()
3100 await asyncio.sleep(0.2)
3101 _player._attr_volume_level = volume
3102
3103 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
3104 player.volume_mute = AsyncMock( # type: ignore[method-assign]
3105 side_effect=lambda muted, _player=player: setattr(
3106 _player, "_attr_volume_muted", muted
3107 )
3108 )
3109 players[player_id] = player
3110 players["leader"]._attr_group_members = ["member"]
3111 controller._players = dict(players)
3112 mock_mass.players = controller
3113 mock_mass.player_queues.get = MagicMock(return_value=None)
3114 for player in players.values():
3115 player.set_initialized()
3116 player._cache.clear()
3117 player.update_state(signal_event=False)
3118 # a second, forced pass so the leader derives its group volume from the member
3119 for player in players.values():
3120 player.update_state(force_update=True, signal_event=False)
3121 return controller, players
3122
3123 async def test_the_last_command_decides_the_group_volume(self, mock_mass: MagicMock) -> None:
3124 """A slow command may not overrule the volume of a later, faster one."""
3125 slow_leader = SlowDevice("leader", asyncio.Event())
3126 controller, players = self._make_synced_pair(mock_mass, slow_leader)
3127
3128 first = asyncio.create_task(controller.cmd_group_volume("leader", 80))
3129 # only send the second command once the first one reached the device
3130 await slow_leader.reached.wait()
3131 second = asyncio.create_task(controller.cmd_group_volume("leader", 30))
3132 await asyncio.gather(first, second)
3133
3134 for player in players.values():
3135 player.update_state()
3136 assert player.state.volume_level == 30
3137
3138 async def test_a_command_for_a_member_waits_for_one_for_its_leader(
3139 self, mock_mass: MagicMock
3140 ) -> None:
3141 """Addressing the same group by member or by leader may not overlap."""
3142 slow_leader = SlowDevice("leader", asyncio.Event())
3143 controller, players = self._make_synced_pair(mock_mass, slow_leader)
3144 in_flight = 0
3145 overlapped = False
3146 set_group_volume = controller.set_group_volume
3147
3148 async def _track_overlap(group_player: Player, volume_level: int) -> None:
3149 nonlocal in_flight, overlapped
3150 in_flight += 1
3151 overlapped = overlapped or in_flight > 1
3152 try:
3153 await set_group_volume(group_player, volume_level)
3154 finally:
3155 in_flight -= 1
3156
3157 controller.set_group_volume = _track_overlap # type: ignore[method-assign]
3158
3159 first = asyncio.create_task(controller.cmd_group_volume("leader", 80))
3160 await slow_leader.reached.wait()
3161 # a synced member adjusts the very same group as its leader
3162 second = asyncio.create_task(controller.cmd_group_volume("member", 30))
3163 await asyncio.gather(first, second)
3164
3165 assert overlapped is False
3166 for player in players.values():
3167 player.update_state()
3168 assert player.state.volume_level == 30
3169
3170 async def test_a_nudge_from_a_member_steps_the_volume_of_the_group(
3171 self, mock_mass: MagicMock
3172 ) -> None:
3173 """A group nudge addressed to a member steps the group, not the member itself."""
3174 controller, players = self._make_synced_pair(mock_mass, volumes={"member": 40})
3175 # the group sits at the volume of its loudest member, the member at its own
3176 assert players["leader"].state.group_volume == 50
3177 assert players["member"].state.group_volume == 40
3178
3179 await controller.cmd_group_volume_up("member")
3180
3181 for player in players.values():
3182 player.update_state()
3183 assert players["leader"].state.volume_level == 55
3184
3185 async def test_an_individual_volume_command_waits_for_the_group(
3186 self, mock_mass: MagicMock
3187 ) -> None:
3188 """A member's own volume command may not be overtaken by a group change."""
3189 slow_member = SlowDevice("member", asyncio.Event())
3190 controller, players = self._make_synced_pair(mock_mass, slow_member)
3191
3192 group = asyncio.create_task(controller.cmd_group_volume("leader", 80))
3193 await slow_member.reached.wait()
3194 individual = asyncio.create_task(controller.cmd_volume_set("member", 10))
3195 await asyncio.gather(group, individual)
3196
3197 for player in players.values():
3198 player.update_state()
3199 assert players["member"].state.volume_level == 10
3200 assert players["leader"].state.volume_level == 80
3201
3202 async def test_a_muted_leader_keeps_its_mute_on_a_group_volume_change(
3203 self, mock_mass: MagicMock
3204 ) -> None:
3205 """A muted sync leader keeps its mute through a group volume change without blocking."""
3206 controller, players = self._make_synced_pair(mock_mass)
3207 players["leader"]._attr_volume_muted = True
3208 players["leader"].update_state(force_update=True, signal_event=False)
3209
3210 # a sync leader is a member of its own group, so the fan-out sets the volume
3211 # of the very player the group command is running for. This must not deadlock
3212 # on a nested cmd_volume_mute call under the group's own volume lock.
3213 async with asyncio.timeout(5):
3214 await controller.cmd_group_volume("leader", 30)
3215
3216 players["leader"].update_state()
3217 assert players["leader"].state.volume_muted is True
3218 assert players["leader"].state.volume_level == 30
3219
3220
3221class LateReportingDevice:
3222 """A mocked device that only reports the volume it was given back when told to."""
3223
3224 def __init__(self, command_delay: float = 0) -> None:
3225 """
3226 Initialize the mocked device.
3227
3228 :param command_delay: Seconds a single volume command takes to reach the device.
3229 """
3230 self.commands: list[int] = []
3231 self._command_delay = command_delay
3232 self._pending: dict[MockPlayer, int] = {}
3233
3234 def bind(self, player: MockPlayer) -> None:
3235 """Make the given player answer its volume commands like this device."""
3236
3237 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
3238 self.commands.append(volume)
3239 if self._command_delay:
3240 await asyncio.sleep(self._command_delay)
3241 self._pending[_player] = volume
3242
3243 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
3244
3245 def report(self) -> None:
3246 """Report the volume of every command received so far back to the player state."""
3247 for player, volume in self._pending.items():
3248 player._attr_volume_level = volume
3249 player.update_state(force_update=True, signal_event=False)
3250 self._pending.clear()
3251
3252
3253class TestVolumeNudgeTarget:
3254 """Volume nudges step from the level last commanded, not from a lagging report."""
3255
3256 def _make_player(
3257 self, mock_mass: MagicMock, command_delay: float = 0
3258 ) -> tuple[PlayerController, MockPlayer, LateReportingDevice]:
3259 """
3260 Build a single player at volume 50 whose device reports back on request.
3261
3262 :param command_delay: Seconds a single volume command takes to reach the device.
3263 """
3264 controller = PlayerController(mock_mass)
3265 controller.config = _volume_step_config(5)
3266 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3267 player = MockPlayer(provider, "player_1", "Player 1")
3268 player._attr_volume_level = 50
3269 device = LateReportingDevice(command_delay)
3270 device.bind(player)
3271 controller._players = {"player_1": player}
3272 mock_mass.players = controller
3273 mock_mass.player_queues.get = MagicMock(return_value=None)
3274 player.set_initialized()
3275 player.update_state(signal_event=False)
3276 # a second, forced pass so the player derives its group volume from its own state
3277 player.update_state(force_update=True, signal_event=False)
3278 return controller, player, device
3279
3280 def _make_synced_pair(
3281 self, mock_mass: MagicMock, command_delay: float = 0
3282 ) -> tuple[PlayerController, dict[str, MockPlayer], LateReportingDevice]:
3283 """
3284 Build a leader synced to one member, both at volume 50, both late reporting.
3285
3286 :param command_delay: Seconds a single volume command takes to reach the device.
3287 """
3288 controller = PlayerController(mock_mass)
3289 controller.config = _volume_step_config(5)
3290 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3291 device = LateReportingDevice(command_delay)
3292 players: dict[str, MockPlayer] = {}
3293 for player_id in ("leader", "member"):
3294 player = MockPlayer(provider, player_id, player_id.title())
3295 player._attr_volume_level = 50
3296 device.bind(player)
3297 players[player_id] = player
3298 players["leader"]._attr_group_members = ["member"]
3299 controller._players = dict(players)
3300 mock_mass.players = controller
3301 mock_mass.player_queues.get = MagicMock(return_value=None)
3302 for player in players.values():
3303 player.set_initialized()
3304 player._cache.clear()
3305 player.update_state(signal_event=False)
3306 # a second, forced pass so the leader derives its group volume from the member
3307 for player in players.values():
3308 player.update_state(force_update=True, signal_event=False)
3309 return controller, players, device
3310
3311 async def test_nudges_up_stack_before_the_player_reports_back(
3312 self, mock_mass: MagicMock
3313 ) -> None:
3314 """Three volume ups in a row climb, even with no report in between."""
3315 controller, _player, device = self._make_player(mock_mass)
3316
3317 for _ in range(3):
3318 await controller.cmd_volume_up("player_1")
3319
3320 assert device.commands == [55, 60, 65]
3321
3322 async def test_nudges_down_stack_before_the_player_reports_back(
3323 self, mock_mass: MagicMock
3324 ) -> None:
3325 """Three volume downs in a row descend, even with no report in between."""
3326 controller, _player, device = self._make_player(mock_mass)
3327
3328 for _ in range(3):
3329 await controller.cmd_volume_down("player_1")
3330
3331 assert device.commands == [45, 40, 35]
3332
3333 async def test_nudges_that_overlap_each_get_their_own_step(self, mock_mass: MagicMock) -> None:
3334 """Volume ups that arrive while an earlier one is still on its way all count."""
3335 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
3336
3337 await asyncio.gather(*(controller.cmd_volume_up("player_1") for _ in range(3)))
3338
3339 assert device.commands == [55, 60, 65]
3340
3341 async def test_a_queued_nudge_does_not_undo_the_level_a_later_one_claimed(
3342 self, mock_mass: MagicMock
3343 ) -> None:
3344 """A nudge waiting for the volume lock may not take the level back down."""
3345 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
3346 tasks = [asyncio.create_task(controller.cmd_volume_up("player_1")) for _ in range(3)]
3347 # let all three claim their level; the second and third then queue on the lock
3348 await asyncio.sleep(0)
3349 # wait for the second one to reach the device, so a nudge sent now reads whatever
3350 # that (by then oldest) command left behind
3351 while len(device.commands) < 2:
3352 await asyncio.sleep(0.005)
3353 tasks.append(asyncio.create_task(controller.cmd_volume_up("player_1")))
3354
3355 await asyncio.gather(*tasks)
3356
3357 assert device.commands == [55, 60, 65, 70]
3358
3359 async def test_a_group_nudge_moves_every_member_up(self, mock_mass: MagicMock) -> None:
3360 """A group nudge up may not send a member the other way."""
3361 controller, players, device = self._make_synced_pair(mock_mass)
3362 # the loudest member is the one that was just turned down on its own, so the
3363 # level it still reports sits above the level the group is being stepped to
3364 players["member"]._attr_volume_level = 80
3365 for _ in range(3):
3366 for player in players.values():
3367 player._cache.clear()
3368 player.update_state(force_update=True, signal_event=False)
3369
3370 await controller.cmd_volume_set("member", 40)
3371 await controller.cmd_group_volume_up("leader")
3372
3373 assert device.commands == [40, 55, 46]
3374
3375 async def test_a_group_nudge_on_an_ungrouped_player_steps_its_own_volume(
3376 self, mock_mass: MagicMock
3377 ) -> None:
3378 """A group nudge falls back to the player itself, which has no group to step."""
3379 controller, _player, device = self._make_player(mock_mass)
3380
3381 for _ in range(3):
3382 await controller.cmd_group_volume_up("player_1")
3383
3384 assert device.commands == [55, 60, 65]
3385
3386 async def test_a_nudge_steps_from_the_level_the_slider_was_left_at(
3387 self, mock_mass: MagicMock
3388 ) -> None:
3389 """A nudge right after a volume set steps from that set level."""
3390 controller, _player, device = self._make_player(mock_mass)
3391
3392 await controller.cmd_volume_set("player_1", 20)
3393 await controller.cmd_volume_up("player_1")
3394
3395 assert device.commands == [20, 25]
3396
3397 async def test_a_change_on_the_device_wins_once_the_last_command_ages_out(
3398 self, mock_mass: MagicMock
3399 ) -> None:
3400 """A volume turned down on the device itself is the base of the next nudge."""
3401 controller, player, device = self._make_player(mock_mass)
3402 await controller.cmd_volume_up("player_1")
3403 device.report()
3404 # the volume is turned down on the device itself, well after that command
3405 player._attr_volume_level = 20
3406 player.update_state(force_update=True, signal_event=False)
3407
3408 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
3409 await controller.cmd_volume_up("player_1")
3410
3411 assert device.commands[-1] == 25
3412
3413 async def test_group_nudges_up_stack_before_the_players_report_back(
3414 self, mock_mass: MagicMock
3415 ) -> None:
3416 """Three group volume ups in a row climb, even with no report in between."""
3417 controller, _players, device = self._make_synced_pair(mock_mass)
3418
3419 for _ in range(3):
3420 await controller.cmd_group_volume_up("leader")
3421
3422 assert device.commands == [55, 55, 60, 60, 65, 65]
3423
3424 async def test_group_nudges_down_stack_before_the_players_report_back(
3425 self, mock_mass: MagicMock
3426 ) -> None:
3427 """Three group volume downs in a row descend, even with no report in between."""
3428 controller, _players, device = self._make_synced_pair(mock_mass)
3429
3430 for _ in range(3):
3431 await controller.cmd_group_volume_down("leader")
3432
3433 assert device.commands == [45, 45, 40, 40, 35, 35]
3434
3435 async def test_a_group_nudge_after_a_member_was_set_on_its_own_keeps_the_step(
3436 self, mock_mass: MagicMock
3437 ) -> None:
3438 """Setting one member does not send the group back to the level it reports."""
3439 controller, _players, device = self._make_synced_pair(mock_mass)
3440
3441 await controller.cmd_group_volume_up("leader")
3442 await controller.cmd_volume_set("member", 10)
3443 await controller.cmd_group_volume_up("leader")
3444
3445 # the loudest member was commanded to 55, so the group steps from there, and the
3446 # member that was just turned down keeps its share of the group volume
3447 assert device.commands == [55, 55, 10, 60, 20]
3448
3449 async def test_a_group_volume_beyond_the_range_does_not_pin_the_next_nudge(
3450 self, mock_mass: MagicMock
3451 ) -> None:
3452 """An out of range group volume leaves the members at 100, not above it."""
3453 controller, _players, device = self._make_synced_pair(mock_mass)
3454
3455 await controller.cmd_group_volume("leader", 200)
3456 await controller.cmd_group_volume_down("leader")
3457
3458 assert device.commands == [100, 100, 95, 95]
3459
3460 async def test_a_group_nudge_from_a_member_shares_the_target_of_its_leader(
3461 self, mock_mass: MagicMock
3462 ) -> None:
3463 """Group nudges addressed to a member and to its leader step the same group."""
3464 controller, _players, device = self._make_synced_pair(mock_mass)
3465
3466 await controller.cmd_group_volume_up("leader")
3467 await controller.cmd_group_volume_up("member")
3468
3469 assert device.commands == [55, 55, 60, 60]
3470
3471
3472class TestGroupVolumeReference:
3473 """A group volume adjustment interpolates from the levels its members are really at."""
3474
3475 def _make_group(
3476 self, mock_mass: MagicMock, first_volume: int = 50, second_volume: int = 50
3477 ) -> tuple[PlayerController, dict[str, MockPlayer], dict[str, list[int]]]:
3478 """
3479 Build a group player with two members, at the given volumes.
3480
3481 :param first_volume: Volume level the first member starts at.
3482 :param second_volume: Volume level the second member starts at.
3483 :return: The controller, the players by id and the volumes commanded per member.
3484 """
3485 controller = PlayerController(mock_mass)
3486 controller.config = _volume_step_config(5)
3487 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3488 commands: dict[str, list[int]] = {}
3489 members: dict[str, MockPlayer] = {}
3490 for player_id, volume in (("member_1", first_volume), ("member_2", second_volume)):
3491 member = MockPlayer(provider, player_id, player_id.title())
3492 member._attr_volume_level = volume
3493 commands[player_id] = []
3494
3495 async def _volume_set(volume: int, _recorded: list[int] = commands[player_id]) -> None:
3496 _recorded.append(volume)
3497
3498 member.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
3499 members[player_id] = member
3500 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3501 group._attr_group_members = list(members)
3502 players = {"group": group, **members}
3503 controller._players = dict(players)
3504 provider.players = list(players.values())
3505 mock_mass.players = controller
3506 mock_mass.player_queues.get = MagicMock(return_value=None)
3507 for player in players.values():
3508 player.set_initialized()
3509 player._cache.clear()
3510 player.update_state(signal_event=False)
3511 # a second, forced pass so the group derives its group volume from the members
3512 for player in players.values():
3513 player.update_state(force_update=True, signal_event=False)
3514 return controller, players, commands
3515
3516 def _report(self, player: MockPlayer, volume: int) -> None:
3517 """Let the player report the given volume level, the way its provider would."""
3518 player._attr_volume_level = volume
3519 player._cache.clear()
3520 player.update_state(force_update=True)
3521
3522 async def test_a_member_turned_down_on_the_device_keeps_its_level(
3523 self, mock_mass: MagicMock
3524 ) -> None:
3525 """A group nudge up may not undo a member that was turned down on the device."""
3526 controller, players, commands = self._make_group(mock_mass)
3527 await controller.cmd_group_volume_up("group")
3528 self._report(players["member_1"], 55)
3529 self._report(players["member_2"], 55)
3530
3531 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
3532 # the member is turned down on the device itself, well after that command
3533 self._report(players["member_2"], 20)
3534 await controller.cmd_group_volume_up("group")
3535
3536 # the group steps from 55 to 60, and the member keeps its (much lower) share
3537 assert commands["member_1"] == [55, 60]
3538 assert commands["member_2"] == [55, 29]
3539
3540 async def test_a_member_reporting_the_level_it_was_given_keeps_the_balance(
3541 self, mock_mass: MagicMock
3542 ) -> None:
3543 """Members confirming a group nudge may not become the reference themselves."""
3544 controller, players, commands = self._make_group(mock_mass)
3545 await controller.cmd_volume_set("member_2", 20)
3546 self._report(players["member_2"], 20)
3547
3548 await controller.cmd_group_volume_up("group")
3549 self._report(players["member_1"], 55)
3550 self._report(players["member_2"], 28)
3551 await controller.cmd_group_volume_down("group")
3552
3553 # stepping back down to where the group started restores the balance it had
3554 assert commands["member_1"] == [55, 50]
3555 assert commands["member_2"] == [20, 28, 20]
3556
3557 async def test_a_member_that_dropped_off_no_longer_sets_the_reference(
3558 self, mock_mass: MagicMock
3559 ) -> None:
3560 """A group nudge up may not turn the group down over an unreachable member."""
3561 controller, players, commands = self._make_group(
3562 mock_mass, first_volume=30, second_volume=80
3563 )
3564 await controller.cmd_group_volume_up("group")
3565 self._report(players["member_1"], 48)
3566 self._report(players["member_2"], 85)
3567
3568 # the loudest member drops off the network; a permanent group keeps it as a member
3569 players["member_2"]._attr_available = False
3570 players["member_2"]._cache.clear()
3571 players["member_2"].update_state(force_update=True)
3572 await controller.cmd_group_volume_up("group")
3573
3574 assert commands["member_1"] == [48, 53]
3575 assert commands["member_2"] == [85]
3576
3577 async def test_a_group_reporting_its_own_volume_keeps_the_balance(
3578 self, mock_mass: MagicMock
3579 ) -> None:
3580 """A group that reports a volume of its own may not reset its own reference."""
3581 controller, players, commands = self._make_group(
3582 mock_mass, first_volume=50, second_volume=20
3583 )
3584 await controller.cmd_group_volume_up("group")
3585 self._report(players["member_1"], 55)
3586 self._report(players["member_2"], 28)
3587 # a cast group reports the volume of its members as its own
3588 self._report(players["group"], 55)
3589 await controller.cmd_group_volume_down("group")
3590
3591 assert commands["member_1"] == [55, 50]
3592 assert commands["member_2"] == [28, 20]
3593
3594 async def test_a_member_clamped_to_its_volume_limit_keeps_that_level(
3595 self, mock_mass: MagicMock
3596 ) -> None:
3597 """Correcting a volume that ran past its limit is not a level the group commanded."""
3598 mock_mass.config.get_raw_player_config_value = MagicMock(
3599 side_effect=_player_config_stub(min_volume=20)
3600 )
3601 use_real_create_task(mock_mass)
3602 # with a min volume of 20 the members report device volumes of 20-100 for 0-100
3603 controller, players, commands = self._make_group(
3604 mock_mass, first_volume=60, second_volume=60
3605 )
3606 await controller.cmd_group_volume_up("group")
3607 self._report(players["member_1"], 64)
3608 self._report(players["member_2"], 64)
3609
3610 # the member is turned below its own minimum, so it is corrected back up to 0
3611 self._report(players["member_2"], 10)
3612 await controller.cmd_group_volume_up("group")
3613
3614 assert commands["member_1"] == [64, 68]
3615 assert commands["member_2"] == [64, 20, 28]
3616
3617
3618class TestFakeMuteInGroup:
3619 """A fake muted player in a group follows the mute lock, just like a native mute."""
3620
3621 def _make_synced_pair(
3622 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
3623 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3624 """
3625 Build a leader synced to one member.
3626
3627 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
3628 """
3629
3630 def _conf(player_id: str, key: str, default: object = None) -> object:
3631 if key == CONF_MUTE_CONTROL and player_id == "member":
3632 return member_mute_control
3633 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
3634 player_id, key, default
3635 )
3636
3637 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
3638 controller = PlayerController(mock_mass)
3639 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3640 players: dict[str, MockPlayer] = {}
3641 for player_id in ("leader", "member"):
3642 player = MockPlayer(provider, player_id, player_id.title())
3643 player._attr_supported_features = {
3644 PlayerFeature.VOLUME_SET,
3645 PlayerFeature.VOLUME_MUTE,
3646 }
3647 player._attr_volume_level = 50
3648 # let the mocked native volume control behave like a real device
3649 player.volume_set = AsyncMock( # type: ignore[method-assign]
3650 side_effect=lambda volume, _player=player: setattr(
3651 _player, "_attr_volume_level", volume
3652 )
3653 )
3654 players[player_id] = player
3655 players["leader"]._attr_group_members = ["member"]
3656 controller._players = dict(players)
3657 mock_mass.players = controller
3658 mock_mass.player_queues.get = MagicMock(return_value=None)
3659 for player in players.values():
3660 player.set_initialized()
3661 player._cache.clear()
3662 player.update_state(signal_event=False)
3663 # a second pass, so the group volume of the leader accounts for its member
3664 for player in players.values():
3665 player.update_state(signal_event=False)
3666 return controller, players
3667
3668 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
3669 """A group volume change may not bring a muted fake mute pair back to life."""
3670 controller, players = self._make_synced_pair(mock_mass)
3671 await controller.cmd_group_volume_mute("leader", True)
3672
3673 await controller.cmd_group_volume("leader", 30)
3674
3675 for player in players.values():
3676 player.update_state()
3677 assert player.state.volume_muted is True
3678 assert player.state.volume_level == 0
3679
3680 async def test_a_muted_member_does_not_inflate_a_group_nudge(
3681 self, mock_mass: MagicMock
3682 ) -> None:
3683 """A volume level a muted member never receives may not step the group."""
3684 controller, players = self._make_synced_pair(mock_mass)
3685 controller.config = _volume_step_config(5)
3686 await controller.cmd_volume_mute("member", True)
3687 # let the group volume of the leader account for the muted member
3688 players["leader"].update_state(signal_event=False)
3689 # the member is held silent, so this level never reaches it
3690 await controller.cmd_volume_set("member", 60)
3691
3692 await controller.cmd_group_volume_up("leader")
3693
3694 players["leader"].update_state()
3695 assert players["leader"].state.volume_level == 55
3696
3697 async def test_a_nudge_after_unmuting_steps_from_the_restored_volume(
3698 self, mock_mass: MagicMock
3699 ) -> None:
3700 """Unmuting hands the next nudge the volume it restored, not the muted 0."""
3701 controller, players = self._make_synced_pair(mock_mass)
3702 controller.config = _volume_step_config(5)
3703 await controller.cmd_volume_mute("member", True)
3704 players["leader"].update_state(signal_event=False)
3705 # a level set while muted never reaches the member
3706 await controller.cmd_volume_set("member", 60)
3707
3708 await controller.cmd_volume_mute("member", False)
3709 await controller.cmd_volume_up("member")
3710
3711 players["member"].update_state()
3712 assert players["member"].state.volume_level == 55
3713
3714 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
3715 """Turning a group down leaves a single muted member silent, at its own volume."""
3716 controller, players = self._make_synced_pair(mock_mass)
3717 await controller.cmd_volume_mute("member", True)
3718 # let the group volume of the leader account for the muted member
3719 players["leader"].update_state(signal_event=False)
3720
3721 await controller.cmd_group_volume("leader", 25)
3722
3723 for player in players.values():
3724 player.update_state()
3725 member_state = players["member"].state
3726 assert member_state.volume_muted is True
3727 assert member_state.volume_level == 0
3728 # the player that is not muted follows the group volume as usual
3729 assert players["leader"].state.volume_level == 25
3730 # unmuting brings the member back at the volume it had before it was muted
3731 await controller.cmd_volume_mute("member", False)
3732 players["member"].update_state()
3733 assert players["member"].state.volume_level == 50
3734
3735 async def test_unmute_restores_the_volume_from_before_the_mute(
3736 self, mock_mass: MagicMock
3737 ) -> None:
3738 """A group volume change while muted may not alter the volume to restore."""
3739 controller, players = self._make_synced_pair(mock_mass)
3740 await controller.cmd_group_volume_mute("leader", True)
3741 await controller.cmd_group_volume("leader", 30)
3742
3743 await controller.cmd_group_volume_mute("leader", False)
3744
3745 for player in players.values():
3746 player.update_state()
3747 assert player.state.volume_muted is False
3748 assert player.state.volume_level == 50
3749
3750 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
3751 """Members with a different mute control stay muted alike on a group volume change."""
3752 controller, players = self._make_synced_pair(
3753 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
3754 )
3755 mute = AsyncMock(
3756 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
3757 )
3758 players["member"].volume_mute = mute # type: ignore[method-assign]
3759 await controller.cmd_group_volume_mute("leader", True)
3760
3761 await controller.cmd_group_volume("leader", 30)
3762
3763 mute.assert_awaited_once_with(True)
3764 for player in players.values():
3765 player.update_state()
3766 assert player.state.volume_muted is True
3767
3768
3769class TestMuteControlGuard:
3770 """Muting is gated on the mute control, independently of the volume control."""
3771
3772 def _make_player(
3773 self,
3774 mock_mass: MagicMock,
3775 mute_control: str,
3776 volume_control: str,
3777 controls: dict[str, PlayerControl] | None = None,
3778 features: set[PlayerFeature] | None = None,
3779 ) -> tuple[PlayerController, MockPlayer]:
3780 """Build a controller with a single player using the given control config."""
3781 mock_mass.config.get_raw_player_config_value = MagicMock(
3782 side_effect=_player_config_stub(
3783 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
3784 )
3785 )
3786 controller = PlayerController(mock_mass)
3787 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3788 player = MockPlayer(provider, "player_1", "Player 1")
3789 if features is not None:
3790 player._attr_supported_features = features
3791 controller._players = {"player_1": player}
3792 controller._controls = controls or {}
3793 mock_mass.players = controller
3794 mock_mass.player_queues.get = MagicMock(return_value=None)
3795 player.set_initialized()
3796 player.update_state(signal_event=False)
3797 return controller, player
3798
3799 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3800 """A player without volume control still mutes through an external PlayerControl."""
3801 mute_set = AsyncMock()
3802 control = PlayerControl(
3803 id="ext_mute",
3804 provider="test",
3805 name="External Mute",
3806 supports_mute=True,
3807 mute_set=mute_set,
3808 )
3809 controller, player = self._make_player(
3810 mock_mass,
3811 mute_control="ext_mute",
3812 volume_control=PLAYER_CONTROL_NONE,
3813 controls={"ext_mute": control},
3814 )
3815 assert player.mute_control == "ext_mute"
3816
3817 await controller.cmd_volume_mute("player_1", True)
3818 mute_set.assert_awaited_once_with(True)
3819
3820 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3821 """A player without volume control still mutes natively."""
3822 controller, player = self._make_player(
3823 mock_mass,
3824 mute_control=PLAYER_CONTROL_NATIVE,
3825 volume_control=PLAYER_CONTROL_NONE,
3826 # native mute is only honored while the player advertises the feature
3827 features={PlayerFeature.VOLUME_MUTE},
3828 )
3829 volume_mute = AsyncMock()
3830 player.volume_mute = volume_mute # type: ignore[method-assign]
3831
3832 await controller.cmd_volume_mute("player_1", True)
3833 volume_mute.assert_awaited_once_with(True)
3834
3835 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
3836 """A player with volume control but no mute control rejects the command."""
3837 controller, player = self._make_player(
3838 mock_mass,
3839 mute_control=PLAYER_CONTROL_NONE,
3840 volume_control=PLAYER_CONTROL_NATIVE,
3841 )
3842 volume_mute = AsyncMock()
3843 player.volume_mute = volume_mute # type: ignore[method-assign]
3844
3845 with pytest.raises(UnsupportedFeaturedException):
3846 await controller.cmd_volume_mute("player_1", True)
3847 volume_mute.assert_not_awaited()
3848
3849 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
3850 """Fake mute needs a volume control to drive, so it rejects the command outright."""
3851 controller, player = self._make_player(
3852 mock_mass,
3853 mute_control=PLAYER_CONTROL_FAKE,
3854 volume_control=PLAYER_CONTROL_NONE,
3855 )
3856 player._attr_volume_level = 40
3857
3858 with pytest.raises(UnsupportedFeaturedException):
3859 await controller.cmd_volume_mute("player_1", True)
3860 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
3861 assert ATTR_FAKE_MUTE not in player.extra_data
3862
3863 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
3864 """A mute control that disappeared after being resolved is reported, not ignored."""
3865 control = PlayerControl(
3866 id="ext_mute",
3867 provider="test",
3868 name="External Mute",
3869 supports_mute=True,
3870 mute_set=AsyncMock(),
3871 )
3872 controller, player = self._make_player(
3873 mock_mass,
3874 mute_control="ext_mute",
3875 volume_control=PLAYER_CONTROL_NONE,
3876 controls={"ext_mute": control},
3877 )
3878 # the resolved control is cached on the player, so removing it here leaves
3879 # the player pointing at a control that no longer exists
3880 assert player.mute_control == "ext_mute"
3881 controller._controls = {}
3882
3883 with pytest.raises(UnsupportedFeaturedException):
3884 await controller.cmd_volume_mute("player_1", True)
3885
3886 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
3887 """Unmuting clears a mute lock left behind by a since-removed mute control."""
3888 controller, player = self._make_player(
3889 mock_mass,
3890 mute_control=PLAYER_CONTROL_NONE,
3891 volume_control=PLAYER_CONTROL_NATIVE,
3892 )
3893 player.extra_data[ATTR_MUTE_LOCK] = True
3894
3895 with pytest.raises(UnsupportedFeaturedException):
3896 await controller.cmd_volume_mute("player_1", False)
3897 assert ATTR_MUTE_LOCK not in player.extra_data
3898
3899 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
3900 """A grouped player whose mute command failed is not left holding a mute lock."""
3901 control = PlayerControl(
3902 id="ext_mute",
3903 provider="test",
3904 name="External Mute",
3905 supports_mute=False,
3906 )
3907 controller, player = self._make_player(
3908 mock_mass,
3909 mute_control="ext_mute",
3910 volume_control=PLAYER_CONTROL_NONE,
3911 controls={"ext_mute": control},
3912 )
3913 player.state.synced_to = "leader"
3914
3915 with pytest.raises(UnsupportedFeaturedException):
3916 await controller.cmd_volume_mute("player_1", True)
3917 assert ATTR_MUTE_LOCK not in player.extra_data
3918
3919 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
3920 """A failed mute leaves the lock of an earlier successful mute in place."""
3921 control = PlayerControl(
3922 id="ext_mute",
3923 provider="test",
3924 name="External Mute",
3925 supports_mute=False,
3926 )
3927 controller, player = self._make_player(
3928 mock_mass,
3929 mute_control="ext_mute",
3930 volume_control=PLAYER_CONTROL_NONE,
3931 controls={"ext_mute": control},
3932 )
3933 player.state.synced_to = "leader"
3934 player.extra_data[ATTR_MUTE_LOCK] = True
3935
3936 with pytest.raises(UnsupportedFeaturedException):
3937 await controller.cmd_volume_mute("player_1", True)
3938 assert player.extra_data[ATTR_MUTE_LOCK] is True
3939
3940
3941class TestGroupMuteMemberFilter:
3942 """Group mute skips members that have no mute control of their own."""
3943
3944 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
3945 """A member without a mute control must not fail the whole group command."""
3946 controller = PlayerController(mock_mass)
3947 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3948 leader = MockPlayer(provider, "leader", "Leader")
3949 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3950 leader._attr_group_members = ["leader", "member"]
3951 member = MockPlayer(provider, "member", "Member")
3952 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3953 controller._players = {"leader": leader, "member": member}
3954 mock_mass.players = controller
3955 mock_mass.player_queues.get = MagicMock(return_value=None)
3956 for player in (leader, member):
3957 player.set_initialized()
3958 player.update_state(signal_event=False)
3959 leader_mute = AsyncMock()
3960 leader.volume_mute = leader_mute # type: ignore[method-assign]
3961
3962 await controller.cmd_group_volume_mute("leader", True)
3963 leader_mute.assert_awaited_once_with(True)
3964
3965
3966class TestGroupPlayerMuteRedirect:
3967 """A mute command on a group player is handled at group level."""
3968
3969 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
3970 """Build a controller with a group player holding a single mute capable member."""
3971 controller = PlayerController(mock_mass)
3972 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3973 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3974 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3975 group._attr_group_members = ["member"]
3976 member = MockPlayer(provider, "member", "Member")
3977 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3978 controller._players = {"group": group, "member": member}
3979 mock_mass.players = controller
3980 mock_mass.player_queues.get = MagicMock(return_value=None)
3981 for player in (group, member):
3982 player.set_initialized()
3983 player.update_state(signal_event=False)
3984 return controller, group, member
3985
3986 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
3987 """A group player has no mute of its own, so the members must be muted instead."""
3988 controller, _group, member = self._setup(mock_mass)
3989 member_mute = AsyncMock()
3990 member.volume_mute = member_mute # type: ignore[method-assign]
3991
3992 await controller.cmd_volume_mute("group", True)
3993
3994 member_mute.assert_awaited_once_with(True)
3995
3996 async def test_mute_on_group_player_without_own_mute_control(
3997 self, mock_mass: MagicMock
3998 ) -> None:
3999 """A group that has no mute control of its own must still mute its members."""
4000 controller, group, member = self._setup(mock_mass)
4001 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
4002 group._cache.clear()
4003 group.update_state(signal_event=False)
4004 assert group.mute_control == PLAYER_CONTROL_NONE
4005 member_mute = AsyncMock()
4006 member.volume_mute = member_mute # type: ignore[method-assign]
4007
4008 await controller.cmd_volume_mute("group", True)
4009
4010 member_mute.assert_awaited_once_with(True)
4011
4012 async def test_mute_on_group_player_without_mute_capable_members(
4013 self, mock_mass: MagicMock
4014 ) -> None:
4015 """A group whose members cannot mute must not raise, just like group mute itself."""
4016 controller, _group, member = self._setup(mock_mass)
4017 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
4018 member._cache.clear()
4019 member.update_state(signal_event=False)
4020
4021 await controller.cmd_volume_mute("group", True)
4022
4023
4024class TestGroupMuteOnNonGroupPlayer:
4025 """A group mute command works on any player, just like the group volume command."""
4026
4027 def _setup(
4028 self, mock_mass: MagicMock, *members: str
4029 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
4030 """Build a controller with a mute capable leader synced to the given members."""
4031 controller = PlayerController(mock_mass)
4032 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4033 players: dict[str, MockPlayer] = {}
4034 for player_id in ("leader", *members):
4035 player = MockPlayer(provider, player_id, player_id.title())
4036 player._attr_supported_features = {
4037 PlayerFeature.VOLUME_SET,
4038 PlayerFeature.VOLUME_MUTE,
4039 }
4040 player._attr_volume_level = 50
4041 players[player_id] = player
4042 if members:
4043 # the leader is not listed as its own member here, so the tests also cover
4044 # that a sync leader is injected into its own final group_members
4045 players["leader"]._attr_group_members = list(members)
4046 controller._players = dict(players)
4047 mock_mass.players = controller
4048 mock_mass.player_queues.get = MagicMock(return_value=None)
4049 for player in players.values():
4050 player.set_initialized()
4051 player._cache.clear()
4052 player.update_state(signal_event=False)
4053 return controller, players
4054
4055 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
4056 """Replace the native mute command of every given player with a mock."""
4057 mutes: dict[str, AsyncMock] = {}
4058 for player_id, player in players.items():
4059 mutes[player_id] = AsyncMock()
4060 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
4061 return mutes
4062
4063 async def test_group_mute_on_synced_member_redirects_to_leader(
4064 self, mock_mass: MagicMock
4065 ) -> None:
4066 """A member of a sync group mutes the whole group through its sync leader."""
4067 controller, players = self._setup(mock_mass, "member")
4068 assert players["member"].state.synced_to == "leader"
4069 mutes = self._stub_mutes(players)
4070
4071 await controller.cmd_group_volume_mute("member", True)
4072
4073 mutes["leader"].assert_awaited_once_with(True)
4074 mutes["member"].assert_awaited_once_with(True)
4075 assert ATTR_MUTE_LOCK in players["member"].extra_data
4076
4077 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
4078 self, mock_mass: MagicMock
4079 ) -> None:
4080 """A sync leader is part of its own member list, so it must be muted only once."""
4081 controller, players = self._setup(mock_mass, "member")
4082 mutes = self._stub_mutes(players)
4083
4084 await controller.cmd_group_volume_mute("leader", True)
4085
4086 mutes["leader"].assert_awaited_once_with(True)
4087 mutes["member"].assert_awaited_once_with(True)
4088
4089 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
4090 """A player that is not grouped at all is muted as a normal player."""
4091 controller, players = self._setup(mock_mass)
4092 mutes = self._stub_mutes(players)
4093
4094 await controller.cmd_group_volume_mute("leader", True)
4095
4096 mutes["leader"].assert_awaited_once_with(True)
4097
4098 async def test_group_mute_on_plain_player_without_mute_control(
4099 self, mock_mass: MagicMock
4100 ) -> None:
4101 """A plain player that cannot mute reports that, just like a normal mute command."""
4102 controller, players = self._setup(mock_mass)
4103 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
4104 players["leader"]._cache.clear()
4105 players["leader"].update_state(signal_event=False)
4106
4107 with pytest.raises(UnsupportedFeaturedException):
4108 await controller.cmd_group_volume_mute("leader", True)
4109
4110 async def test_group_unmute_on_synced_member_redirects_to_leader(
4111 self, mock_mass: MagicMock
4112 ) -> None:
4113 """Unmuting through a member clears the mute (and mute lock) of every group member."""
4114 controller, players = self._setup(mock_mass, "member")
4115 mutes = self._stub_mutes(players)
4116 players["member"].extra_data[ATTR_MUTE_LOCK] = True
4117
4118 await controller.cmd_group_volume_mute("member", False)
4119
4120 mutes["leader"].assert_awaited_once_with(False)
4121 mutes["member"].assert_awaited_once_with(False)
4122 assert ATTR_MUTE_LOCK not in players["member"].extra_data
4123
4124 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
4125 """A sync leader is as much part of the group as its members, so it is locked too."""
4126 controller, players = self._setup(mock_mass, "member")
4127 self._stub_mutes(players)
4128
4129 await controller.cmd_group_volume_mute("leader", True)
4130
4131 assert ATTR_MUTE_LOCK in players["leader"].extra_data
4132 assert ATTR_MUTE_LOCK in players["member"].extra_data
4133
4134 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
4135 """A group volume change may not half-unmute a muted pair of directly synced players."""
4136 controller, players = self._setup(mock_mass, "member")
4137 self._stub_mutes(players)
4138 await controller.cmd_group_volume_mute("leader", True)
4139 # the mock players do not act on the mute command, so reflect it in their state
4140 for player in players.values():
4141 player._attr_volume_muted = True
4142 player.update_state(signal_event=False)
4143 player.volume_set = AsyncMock() # type: ignore[method-assign]
4144 # re-stub so only the mute commands of the group volume change are counted
4145 mutes = self._stub_mutes(players)
4146
4147 await controller.cmd_group_volume("leader", 30)
4148
4149 for mute in mutes.values():
4150 mute.assert_not_awaited()
4151
4152
4153class TestMuteLockAfterUngroup:
4154 """
4155 Mute persistence across a volume-set command.
4156
4157 A native mute is never lifted by a volume command, grouped or not. A fake mute
4158 lock is honored only while the player it belongs to is still grouped.
4159 """
4160
4161 def _make_synced_pair(
4162 self, mock_mass: MagicMock, member_mute_control: str
4163 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
4164 """
4165 Build a leader with one synced member.
4166
4167 :param member_mute_control: Mute control to configure on both players.
4168 """
4169 mock_mass.config.get_raw_player_config_value = MagicMock(
4170 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
4171 )
4172 controller = PlayerController(mock_mass)
4173 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4174 players: dict[str, MockPlayer] = {}
4175 for player_id in ("leader", "member"):
4176 player = MockPlayer(provider, player_id, player_id.title())
4177 player._attr_supported_features = {
4178 PlayerFeature.VOLUME_SET,
4179 PlayerFeature.VOLUME_MUTE,
4180 }
4181 player._attr_volume_level = 50
4182 player.volume_set = AsyncMock( # type: ignore[method-assign]
4183 side_effect=lambda volume, _player=player: setattr(
4184 _player, "_attr_volume_level", volume
4185 )
4186 )
4187 players[player_id] = player
4188 players["leader"]._attr_group_members = ["member"]
4189 controller._players = dict(players)
4190 mock_mass.players = controller
4191 mock_mass.player_queues.get = MagicMock(return_value=None)
4192 for player in players.values():
4193 player.set_initialized()
4194 player._cache.clear()
4195 player.update_state(signal_event=False)
4196 return controller, players
4197
4198 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
4199 """Drop the sync group, the way a provider side topology change does."""
4200 players["leader"]._attr_group_members = []
4201 for player in players.values():
4202 player.refresh_state(signal_event=False)
4203
4204 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
4205 """A fake muted player is no longer forced silent once its group is gone."""
4206 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
4207 await controller.cmd_volume_mute("member", True)
4208 self._dissolve_group(players)
4209
4210 await controller.cmd_volume_set("member", 70)
4211
4212 players["member"].update_state()
4213 assert players["member"].state.volume_level == 70
4214 assert players["member"].state.volume_muted is False
4215
4216 async def test_natively_muted_player_keeps_its_mute_after_ungroup(
4217 self, mock_mass: MagicMock
4218 ) -> None:
4219 """A natively muted player keeps its mute on a volume change, group gone or not."""
4220 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
4221 mute = AsyncMock(
4222 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
4223 )
4224 players["member"].volume_mute = mute # type: ignore[method-assign]
4225 await controller.cmd_volume_mute("member", True)
4226 self._dissolve_group(players)
4227
4228 await controller.cmd_volume_set("member", 70)
4229
4230 mute.assert_awaited_once_with(True)
4231 players["member"].update_state()
4232 assert players["member"].state.volume_level == 70
4233 assert players["member"].state.volume_muted is True
4234
4235 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
4236 """A muted player that is still grouped stays silent on a volume change."""
4237 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
4238 await controller.cmd_volume_mute("member", True)
4239
4240 await controller.cmd_volume_set("member", 70)
4241
4242 players["member"].update_state()
4243 assert players["member"].state.volume_level == 0
4244 assert players["member"].state.volume_muted is True
4245
4246 async def test_protocol_player_follows_the_lock_of_its_parent(
4247 self, mock_mass: MagicMock
4248 ) -> None:
4249 """A protocol player inherits the lock of the parent it renders for, group and all."""
4250 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
4251 member = players["member"]
4252 protocol_player = MockPlayer(
4253 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
4254 "proto_member",
4255 "Member Protocol",
4256 player_type=PlayerType.PROTOCOL,
4257 )
4258 protocol_player._attr_supported_features = {
4259 PlayerFeature.VOLUME_SET,
4260 PlayerFeature.VOLUME_MUTE,
4261 }
4262 protocol_player._attr_volume_level = 50
4263 protocol_player.volume_set = AsyncMock( # type: ignore[method-assign]
4264 side_effect=lambda volume: setattr(protocol_player, "_attr_volume_level", volume)
4265 )
4266 protocol_player.set_protocol_parent_id("member")
4267 controller._players["proto_member"] = protocol_player
4268 member.set_linked_output_protocols(
4269 [
4270 LinkedOutputProtocol(
4271 output_protocol_id="proto_member",
4272 protocol_domain="sendspin",
4273 priority=40,
4274 )
4275 ]
4276 )
4277 protocol_player.set_initialized()
4278 protocol_player.update_state(signal_event=False)
4279 member.refresh_state(signal_event=False)
4280
4281 # the lock is earned by the parent while it is still grouped. The internal
4282 # handler is used to fake-mute the protocol player itself, bypassing the
4283 # public command's auto-resolve to its parent, so the fake-mute flag ends
4284 # up on the protocol player and the fake-mute volume path applies to it
4285 await controller.cmd_volume_mute("member", True)
4286 await controller._handle_cmd_volume_mute(protocol_player, PLAYER_CONTROL_FAKE, True)
4287
4288 # while the parent holds the lock, a volume command for the protocol player
4289 # is forced to 0 (stays silent) instead of releasing its fake mute
4290 await controller._handle_cmd_volume_set("proto_member", 70)
4291 protocol_player.update_state()
4292 assert protocol_player.state.volume_level == 0
4293
4294 self._dissolve_group(players)
4295 await controller._handle_cmd_volume_set("proto_member", 70)
4296
4297 protocol_player.update_state()
4298 assert protocol_player.state.volume_level == 70
4299
4300
4301class TestCurrentMediaTimeUpdates:
4302 """Playback-position anchor semantics of timing-only state updates."""
4303
4304 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
4305 """Build a controller with a single playing player with a known position anchor."""
4306 controller = PlayerController(mock_mass)
4307 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4308 player = MockPlayer(provider, "player_1", "Player 1")
4309 controller._players = {"player_1": player}
4310 mock_mass.players = controller
4311 # no queue registered: current_media resolves from the player's native media
4312 mock_mass.player_queues.get = MagicMock(return_value=None)
4313 player.set_initialized()
4314 now = time.time()
4315 player._attr_playback_state = PlaybackState.PLAYING
4316 player._attr_elapsed_time = 17
4317 player._attr_elapsed_time_last_updated = now
4318 player.set_current_media(uri="http://test/stream", title="Test")
4319 assert player._attr_current_media is not None
4320 player._attr_current_media.elapsed_time = 17
4321 player._attr_current_media.elapsed_time_last_updated = now
4322 player.update_state(signal_event=False)
4323 # isolate from the unrelated state-forwarding machinery
4324 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
4325 mock_mass.signal_event.reset_mock()
4326 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
4327 return controller, player
4328
4329 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
4330 """Return whether a PLAYER_UPDATED event was signalled."""
4331 return any(
4332 call.args and call.args[0] == EventType.PLAYER_UPDATED
4333 for call in mock_mass.signal_event.call_args_list
4334 )
4335
4336 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
4337 """A regular playback tick (position and anchor advance together) emits nothing."""
4338 _controller, player = self._make_player(mock_mass)
4339 assert player._attr_current_media is not None
4340 assert player._attr_elapsed_time_last_updated is not None
4341
4342 player._attr_elapsed_time = 18
4343 player._attr_elapsed_time_last_updated += 1
4344 player._attr_current_media.elapsed_time = 18
4345 assert player._attr_current_media.elapsed_time_last_updated is not None
4346 player._attr_current_media.elapsed_time_last_updated += 1
4347 player.update_state()
4348
4349 assert not self._player_updated_signalled(mock_mass)
4350 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
4351 # the previous anchor was preserved: steady playback changes nothing
4352 assert player.state.elapsed_time == 17
4353
4354 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
4355 """An anchor-only change (no significant corrected position change) emits nothing."""
4356 _controller, player = self._make_player(mock_mass)
4357 assert player._attr_current_media is not None
4358 assert player._attr_elapsed_time_last_updated is not None
4359
4360 player._attr_elapsed_time_last_updated += 0.5
4361 assert player._attr_current_media.elapsed_time_last_updated is not None
4362 player._attr_current_media.elapsed_time_last_updated += 0.5
4363 player.update_state()
4364
4365 assert not self._player_updated_signalled(mock_mass)
4366 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
4367
4368 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
4369 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
4370 _controller, player = self._make_player(mock_mass)
4371 assert player._attr_current_media is not None
4372
4373 player._attr_current_media.elapsed_time = 61
4374 player._attr_current_media.elapsed_time_last_updated = time.time()
4375 player.update_state()
4376
4377 assert self._player_updated_signalled(mock_mass)
4378 # the adopted anchor is visible to consumers
4379 assert player.state.current_media is not None
4380 assert player.state.current_media.elapsed_time == 61
4381
4382 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
4383 """A player-level corrected-position jump re-bases the queue timing."""
4384 controller, player = self._make_player(mock_mass)
4385
4386 player._attr_elapsed_time = 61
4387 player._attr_elapsed_time_last_updated = time.time()
4388 player.update_state()
4389
4390 # the queue is corrected and a follow-up player update is scheduled
4391 # (which re-anchors current_media onto the corrected queue time),
4392 # but no full player update is emitted for the jump itself
4393 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
4394 assert not self._player_updated_signalled(mock_mass)
4395 cast("MagicMock", controller._forward_state_update).assert_called_once()
4396 assert player.state.elapsed_time == 61
4397
4398 def test_simultaneous_player_and_media_jump_emits_immediately(
4399 self, mock_mass: MagicMock
4400 ) -> None:
4401 """A jump reaching player and current_media in one pass corrects the queue and emits."""
4402 _controller, player = self._make_player(mock_mass)
4403 assert player._attr_current_media is not None
4404 now = time.time()
4405
4406 player._attr_elapsed_time = 61
4407 player._attr_elapsed_time_last_updated = now
4408 player._attr_current_media.elapsed_time = 61
4409 player._attr_current_media.elapsed_time_last_updated = now
4410 player.update_state()
4411
4412 # the queue is re-based AND the full update is emitted right away
4413 # (current_media already holds the fresh position in the same pass)
4414 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
4415 assert self._player_updated_signalled(mock_mass)
4416
4417
4418class TestPlayAnnouncementCleanup:
4419 """Test announcement data cleanup after play_announcement."""
4420
4421 def _make_player(
4422 self, mock_mass: MagicMock, announcements: dict[str, object]
4423 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
4424 """Create a controller and a player with native announcement support."""
4425 controller = PlayerController(mock_mass)
4426 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4427 player = MockPlayer(provider, "player_1", "Player 1")
4428 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4429 player._cache.clear()
4430 controller._players = {"player_1": player}
4431 mock_mass.players = controller
4432 render = MagicMock()
4433 render.wait_ready = AsyncMock(return_value=True)
4434 render.wait_finished = AsyncMock(return_value=3.0)
4435
4436 # mimic the real renderer: it owns which announcement each player is playing
4437 def _register(player_id: str, announce_data: object) -> MagicMock:
4438 announcements[player_id] = announce_data
4439 return render
4440
4441 async def _unregister(player_id: str, _render: object) -> None:
4442 announcements.pop(player_id, None)
4443
4444 renderer = mock_mass.streams.announcement_renderer
4445 renderer.register = MagicMock(side_effect=_register)
4446 renderer.unregister = AsyncMock(side_effect=_unregister)
4447 mock_mass.streams.get_announcement_url = MagicMock(
4448 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
4449 )
4450 player.update_state(signal_event=False)
4451 return controller, player, render
4452
4453 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
4454 """The registered announcement data is released once playback finished."""
4455 announcements: dict[str, object] = {}
4456 controller, player, _render = self._make_player(mock_mass, announcements)
4457
4458 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
4459 # entry must exist while the announcement is being played/served
4460 assert "player_1" in announcements
4461
4462 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
4463
4464 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4465
4466 player.play_announcement.assert_awaited_once()
4467 assert announcements == {}
4468 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
4469
4470 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
4471 """The registered announcement data is released even when playback fails."""
4472 announcements: dict[str, object] = {}
4473 controller, player, _render = self._make_player(mock_mass, announcements)
4474 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
4475
4476 with pytest.raises(PlayerCommandFailed):
4477 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4478
4479 assert announcements == {}
4480 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
4481
4482 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
4483 """A native implementation is handed the url as soon as there is audio to serve."""
4484 announcements: dict[str, object] = {}
4485 controller, player, render = self._make_player(mock_mass, announcements)
4486 player.play_announcement = AsyncMock() # type: ignore[method-assign]
4487
4488 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4489
4490 player.play_announcement.assert_awaited_once()
4491 # waiting for the whole clip here would delay the player for a slow source;
4492 # the length is resolved downstream while it plays
4493 render.wait_ready.assert_awaited_once()
4494 render.wait_finished.assert_not_awaited()
4495
4496 async def test_feature_still_offered_after_the_render_keeps_the_native_path(
4497 self, mock_mass: MagicMock
4498 ) -> None:
4499 """A player still offering the feature once its audio is ready announces natively."""
4500 announcements: dict[str, object] = {}
4501 controller, _player, render = self._make_player(mock_mass, announcements)
4502 order: list[str] = []
4503
4504 async def _wait_ready() -> bool:
4505 order.append("render")
4506 return True
4507
4508 async def _native(*_args: object, **_kwargs: object) -> None:
4509 order.append("native")
4510
4511 render.wait_ready = AsyncMock(side_effect=_wait_ready)
4512
4513 with (
4514 patch.object(controller, "_play_native_announcement", side_effect=_native) as native,
4515 patch.object(controller, "_play_announcement") as fallback,
4516 ):
4517 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4518
4519 native.assert_awaited_once()
4520 fallback.assert_not_awaited()
4521 assert order == ["render", "native"]
4522
4523 async def test_feature_lost_during_the_render_falls_back_to_the_default(
4524 self, mock_mass: MagicMock
4525 ) -> None:
4526 """
4527 A player that stopped offering the feature while its audio rendered is not handed it.
4528
4529 Rendering speech takes seconds, and an output that announces by mixing the clip
4530 into what it is already playing stops offering the feature the moment that
4531 playback ends - so the default implementation has to take over.
4532 """
4533 announcements: dict[str, object] = {}
4534 controller, player, render = self._make_player(mock_mass, announcements)
4535
4536 async def _wait_ready() -> bool:
4537 player._attr_supported_features.discard(PlayerFeature.PLAY_ANNOUNCEMENT)
4538 return True
4539
4540 render.wait_ready = AsyncMock(side_effect=_wait_ready)
4541
4542 with (
4543 patch.object(controller, "_play_native_announcement") as native,
4544 patch.object(controller, "_play_announcement") as fallback,
4545 ):
4546 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4547
4548 native.assert_not_awaited()
4549 fallback.assert_awaited_once()
4550 # nothing renders the clip natively, so the announcement is not tagged for it
4551 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4552 assert registered["announce_player_id"] is None
4553
4554
4555class _AnnounceSetup(NamedTuple):
4556 """A player announcing through a linked protocol output, with the calls it makes mocked."""
4557
4558 controller: PlayerController
4559 parent: MockPlayer
4560 output: MockPlayer
4561 volume_set: AsyncMock
4562 play_announcement: AsyncMock
4563
4564
4565@pytest.mark.usefixtures("running_background_tasks")
4566class TestNativeAnnouncementVolumeRouting:
4567 """The announcement volume is applied through the control that owns it."""
4568
4569 ANNOUNCE_VOLUME = 45
4570
4571 def _make_setup(self, mock_mass: MagicMock, volume_control: str) -> _AnnounceSetup:
4572 """
4573 Create a player announcing through a linked protocol output.
4574
4575 The parent holds a sibling interface and a bridge riding on the announcing
4576 output, so any of them can be named as its volume control.
4577
4578 :param volume_control: Value of the parent's volume control config entry.
4579 """
4580 controller = PlayerController(mock_mass)
4581 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4582 parent = MockPlayer(provider, "parent", "Parent")
4583 output = MockPlayer(provider, "output", "Output")
4584 output._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4585 output._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4586 sibling = MockPlayer(provider, "sibling", "Sibling")
4587 sibling._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4588 sibling._attr_volume_level = 20
4589 bridge = MockPlayer(provider, "bridge", "Bridge")
4590 bridge._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4591 bridge._attr_underlying_player_id = "output"
4592 bridge._attr_volume_level = 20
4593 controller._players = {
4594 "parent": parent,
4595 "output": output,
4596 "sibling": sibling,
4597 "bridge": bridge,
4598 }
4599 # an external control (e.g. a Home Assistant volume entity) is not a player
4600 controller._controls = {
4601 "ha_volume": PlayerControl(
4602 id="ha_volume",
4603 provider="hass",
4604 name="Amplifier volume",
4605 supports_volume=True,
4606 volume_level=20,
4607 )
4608 }
4609 mock_mass.players = controller
4610 mock_mass.config.get_raw_player_config_value = MagicMock(
4611 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: volume_control})
4612 )
4613 for player in controller._players.values():
4614 player._cache.clear()
4615 player.update_state(signal_event=False)
4616 play_announcement = AsyncMock()
4617 volume_set = AsyncMock()
4618 output.play_announcement = play_announcement # type: ignore[method-assign]
4619 controller._handle_cmd_volume_set = volume_set # type: ignore[method-assign]
4620 return _AnnounceSetup(controller, parent, output, volume_set, play_announcement)
4621
4622 async def _announce(self, setup: _AnnounceSetup) -> None:
4623 """Play an announcement on the parent, rendered by the linked output."""
4624 await setup.controller._play_native_announcement(
4625 setup.parent, setup.output, _announcement(), self.ANNOUNCE_VOLUME
4626 )
4627
4628 async def test_external_control_applies_the_announcement_volume(
4629 self, mock_mass: MagicMock
4630 ) -> None:
4631 """A sibling interface owning the volume gets the announcement volume, not the output."""
4632 setup = self._make_setup(mock_mass, "sibling")
4633
4634 await self._announce(setup)
4635
4636 # the output cannot attenuate what another control is already attenuating,
4637 # so the level goes through that control and the output announces at unity
4638 assert setup.volume_set.await_args_list == [
4639 call("parent", self.ANNOUNCE_VOLUME),
4640 call("parent", 20),
4641 ]
4642 setup.play_announcement.assert_awaited_once_with(ANY, None)
4643
4644 async def test_output_that_applies_the_volume_itself_gets_it_handed_down(
4645 self, mock_mass: MagicMock
4646 ) -> None:
4647 """
4648 An output that applies the announcement volume itself is left to do so.
4649
4650 A player that mixes the clip into audio it is already playing knows when the
4651 clip becomes audible; setting the level up front would raise the music that
4652 is still playing instead. Any other output has it applied before it starts.
4653 """
4654 setup = self._make_setup(mock_mass, "sibling")
4655
4656 with patch.object(
4657 type(setup.output),
4658 "applies_announcement_volume",
4659 new_callable=PropertyMock,
4660 return_value=True,
4661 ):
4662 await self._announce(setup)
4663
4664 setup.volume_set.assert_not_awaited()
4665 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4666 setup.play_announcement.reset_mock()
4667
4668 await self._announce(setup)
4669
4670 assert setup.volume_set.await_args_list == [
4671 call("parent", self.ANNOUNCE_VOLUME),
4672 call("parent", 20),
4673 ]
4674 setup.play_announcement.assert_awaited_once_with(ANY, None)
4675
4676 async def test_player_control_applies_the_announcement_volume(
4677 self, mock_mass: MagicMock
4678 ) -> None:
4679 """A player control owning the volume (e.g. an HA entity) gets the announcement volume."""
4680 setup = self._make_setup(mock_mass, "ha_volume")
4681
4682 await self._announce(setup)
4683
4684 assert setup.volume_set.await_args_list == [
4685 call("parent", self.ANNOUNCE_VOLUME),
4686 call("parent", 20),
4687 ]
4688 setup.play_announcement.assert_awaited_once_with(ANY, None)
4689
4690 async def test_external_control_volume_restored_when_the_announcement_fails(
4691 self, mock_mass: MagicMock
4692 ) -> None:
4693 """The temporary volume is restored even when the announcement itself fails."""
4694 setup = self._make_setup(mock_mass, "sibling")
4695 setup.play_announcement.side_effect = RuntimeError("boom")
4696
4697 with pytest.raises(RuntimeError):
4698 await self._announce(setup)
4699
4700 assert setup.volume_set.await_args_list[-1] == call("parent", 20)
4701
4702 async def test_announcing_output_keeps_the_announcement_volume(
4703 self, mock_mass: MagicMock
4704 ) -> None:
4705 """An output that owns the volume applies the announcement volume itself."""
4706 setup = self._make_setup(mock_mass, "output")
4707
4708 await self._announce(setup)
4709
4710 setup.volume_set.assert_not_awaited()
4711 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4712
4713 async def test_bridge_on_the_announcing_output_keeps_the_announcement_volume(
4714 self, mock_mass: MagicMock
4715 ) -> None:
4716 """A bridge riding on the announcing output forwards the volume to it."""
4717 setup = self._make_setup(mock_mass, "bridge")
4718
4719 await self._announce(setup)
4720
4721 setup.volume_set.assert_not_awaited()
4722 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4723
4724 async def test_native_volume_is_applied_through_the_parent(self, mock_mass: MagicMock) -> None:
4725 """
4726 A native volume lives on the parent, so the parent applies and restores it.
4727
4728 The rendering output has no way to reach a native parent volume, and its own
4729 idea of the level can be stale, so routing through the parent keeps both the
4730 announcement level and the restore on the control that actually knows it.
4731 """
4732 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
4733 setup.parent._attr_volume_level = 20
4734 setup.parent._cache.clear()
4735 setup.parent.update_state(signal_event=False)
4736
4737 await self._announce(setup)
4738
4739 assert setup.volume_set.await_args_list == [
4740 call("parent", self.ANNOUNCE_VOLUME),
4741 call("parent", 20),
4742 ]
4743 setup.play_announcement.assert_awaited_once_with(ANY, None)
4744
4745 async def test_native_volume_on_its_own_output_is_kept_by_the_player(
4746 self, mock_mass: MagicMock
4747 ) -> None:
4748 """A player announcing on its own output can apply its native volume itself."""
4749 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
4750 play_announcement = AsyncMock()
4751 setup.parent.play_announcement = play_announcement # type: ignore[method-assign]
4752
4753 await setup.controller._play_native_announcement(
4754 setup.parent, setup.parent, _announcement(), self.ANNOUNCE_VOLUME
4755 )
4756
4757 setup.volume_set.assert_not_awaited()
4758 play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4759
4760 async def test_without_volume_control_no_volume_is_applied(self, mock_mass: MagicMock) -> None:
4761 """Nothing in the signal path can set a volume, so the announcement plays as-is."""
4762 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NONE)
4763
4764 await self._announce(setup)
4765
4766 setup.volume_set.assert_not_awaited()
4767 setup.play_announcement.assert_awaited_once_with(ANY, None)
4768
4769
4770class TestPlayAnnouncementMessage:
4771 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
4772
4773 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
4774
4775 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
4776 """Create a TTS engine that renders every message to the given path."""
4777 engine = MagicMock()
4778 engine.uid = "tts_plugin/voice"
4779 engine.id = "voice"
4780 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
4781 return engine
4782
4783 def _make_player(
4784 self, mock_mass: MagicMock, announcements: dict[str, object]
4785 ) -> tuple[PlayerController, AsyncMock]:
4786 """Create a controller and a player with native announcement support."""
4787 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
4788 mock_mass, announcements
4789 )
4790 announce = AsyncMock()
4791 player.play_announcement = announce # type: ignore[method-assign]
4792 return controller, announce
4793
4794 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
4795 """A message is rendered by the default engine and announced as the rendered audio."""
4796 announcements: dict[str, object] = {}
4797 controller, announce = self._make_player(mock_mass, announcements)
4798 engine = self._make_engine()
4799
4800 with patch(
4801 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4802 ):
4803 await controller.play_announcement("player_1", message="dinner is ready")
4804
4805 # no language is sent, so the engine speaks in the language it is configured for
4806 engine.provider.get_tts_message.assert_awaited_once_with(
4807 "dinner is ready", language=None, engine_id="voice", options=None
4808 )
4809 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4810 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4811 announce.assert_awaited_once()
4812
4813 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
4814 """A message can name the language to speak it in."""
4815 announcements: dict[str, object] = {}
4816 controller, _announce = self._make_player(mock_mass, announcements)
4817 engine = self._make_engine()
4818
4819 with patch(
4820 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4821 ):
4822 await controller.play_announcement(
4823 "player_1", message="het eten is klaar", language="nl-NL"
4824 )
4825
4826 engine.provider.get_tts_message.assert_awaited_once_with(
4827 "het eten is klaar", language="nl-NL", engine_id="voice", options=None
4828 )
4829
4830 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
4831 """An engine that rejects the language speaks the message in its default voice."""
4832 announcements: dict[str, object] = {}
4833 controller, announce = self._make_player(mock_mass, announcements)
4834 engine = self._make_engine()
4835 engine.provider.get_tts_message = AsyncMock(
4836 side_effect=[
4837 TTSLanguageNotSupportedError(
4838 "TTS engine 'voice' does not support language 'en-US'"
4839 ),
4840 SimpleNamespace(path="http://speech/spoken.mp3"),
4841 ]
4842 )
4843
4844 with patch(
4845 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4846 ):
4847 await controller.play_announcement(
4848 "player_1", message="dinner is ready", language="en-US"
4849 )
4850
4851 first_call, second_call = engine.provider.get_tts_message.await_args_list
4852 assert first_call.kwargs["language"] == "en-US"
4853 assert second_call.kwargs["language"] is None
4854 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4855 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4856 announce.assert_awaited_once()
4857
4858 @pytest.mark.parametrize(
4859 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
4860 )
4861 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
4862 self, mock_mass: MagicMock, error: Exception
4863 ) -> None:
4864 """A timeout or a structured failure is no language rejection, so it is not retried."""
4865 announcements: dict[str, object] = {}
4866 controller, announce = self._make_player(mock_mass, announcements)
4867 engine = self._make_engine()
4868 engine.provider.get_tts_message = AsyncMock(side_effect=error)
4869
4870 with (
4871 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4872 pytest.raises(MusicAssistantError),
4873 ):
4874 await controller.play_announcement("player_1", message="dinner is ready")
4875
4876 engine.provider.get_tts_message.assert_awaited_once()
4877 announce.assert_not_awaited()
4878
4879 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
4880 """A message names the engine to speak it, overriding the configured default."""
4881 announcements: dict[str, object] = {}
4882 controller, _announce = self._make_player(mock_mass, announcements)
4883 engine = self._make_engine()
4884
4885 with (
4886 patch(
4887 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
4888 ) as resolve,
4889 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
4890 ):
4891 await controller.play_announcement(
4892 "player_1", message="hello", tts_engine="tts_plugin/voice"
4893 )
4894
4895 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
4896 select.assert_not_awaited()
4897
4898 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
4899 """A spoken message uses the player's pre-announce setting without sniffing the url."""
4900 announcements: dict[str, object] = {}
4901 controller, _announce = self._make_player(mock_mass, announcements)
4902 engine = self._make_engine()
4903 mock_mass.config.get_raw_player_config_value = MagicMock(
4904 side_effect=lambda _player_id, key, default=None: (
4905 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
4906 )
4907 )
4908
4909 with patch(
4910 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4911 ):
4912 await controller.play_announcement("player_1", message="dinner is ready")
4913
4914 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4915 assert registered["pre_announce"] is True
4916
4917 async def test_the_engine_gets_the_shorter_announcement_timeout(
4918 self, mock_mass: MagicMock
4919 ) -> None:
4920 """The engine is capped well below the background default, it holds the player lock."""
4921 announcements: dict[str, object] = {}
4922 controller, _announce = self._make_player(mock_mass, announcements)
4923 engine = self._make_engine()
4924 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
4925
4926 with (
4927 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4928 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
4929 ):
4930 await controller.play_announcement("player_1", message="hello")
4931
4932 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
4933 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
4934
4935 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4936 """Naming an engine for a url announcement is rejected instead of silently ignored."""
4937 announcements: dict[str, object] = {}
4938 controller, _announce = self._make_player(mock_mass, announcements)
4939
4940 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
4941 await controller.play_announcement(
4942 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
4943 )
4944
4945 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4946 """Naming a language for a url announcement is rejected instead of silently ignored."""
4947 announcements: dict[str, object] = {}
4948 controller, _announce = self._make_player(mock_mass, announcements)
4949
4950 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
4951 await controller.play_announcement(
4952 "player_1", url="http://test/clip.mp3", language="nl-NL"
4953 )
4954
4955 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
4956 """An engine that fails to speak the message fails the announcement."""
4957 announcements: dict[str, object] = {}
4958 controller, announce = self._make_player(mock_mass, announcements)
4959 engine = self._make_engine()
4960 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
4961
4962 with (
4963 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4964 pytest.raises(PlayerCommandFailed),
4965 ):
4966 await controller.play_announcement("player_1", message="hello")
4967
4968 announce.assert_not_awaited()
4969 mock_mass.streams.announcement_renderer.register.assert_not_called()
4970
4971 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
4972 """An announcement with neither a url nor a message is rejected."""
4973 announcements: dict[str, object] = {}
4974 controller, _announce = self._make_player(mock_mass, announcements)
4975
4976 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
4977 await controller.play_announcement("player_1")
4978
4979 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
4980 """An announcement carrying both a url and a message is rejected."""
4981 announcements: dict[str, object] = {}
4982 controller, _announce = self._make_player(mock_mass, announcements)
4983
4984 with pytest.raises(PlayerCommandFailed, match="not both"):
4985 await controller.play_announcement(
4986 "player_1", url="http://test/clip.mp3", message="hello"
4987 )
4988
4989 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
4990 """A message naming an engine that does not exist fails instead of using another."""
4991 announcements: dict[str, object] = {}
4992 controller, _announce = self._make_player(mock_mass, announcements)
4993
4994 with (
4995 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
4996 pytest.raises(PlayerCommandFailed, match="is not available"),
4997 ):
4998 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
4999
5000 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
5001 """A message fails clearly when no TTS engine is set up at all."""
5002 announcements: dict[str, object] = {}
5003 controller, _announce = self._make_player(mock_mass, announcements)
5004
5005 with (
5006 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
5007 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
5008 ):
5009 await controller.play_announcement("player_1", message="hello")
5010
5011 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
5012 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
5013 announcements: dict[str, object] = {}
5014 controller, _announce = self._make_player(mock_mass, announcements)
5015 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
5016
5017 with (
5018 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
5019 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
5020 ):
5021 await controller.play_announcement("player_1", message="hello")
5022
5023 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
5024 """The message is spoken once and every group member announces the resulting audio."""
5025 announcements: dict[str, object] = {}
5026 use_real_create_task(mock_mass)
5027 controller, _announce = self._make_player(mock_mass, announcements)
5028 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
5029 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
5030 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
5031 group._attr_group_members = ["player_1"]
5032 group._cache.clear()
5033 controller._players["group_1"] = group
5034 group.update_state(signal_event=False)
5035 engine = self._make_engine()
5036
5037 with patch(
5038 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
5039 ):
5040 await controller.play_announcement("group_1", message="dinner is ready")
5041
5042 # rendered once for the group, then handed to the member as plain audio
5043 engine.provider.get_tts_message.assert_awaited_once()
5044 member_call = next(
5045 call_args
5046 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
5047 if call_args.args[0] == "player_1"
5048 )
5049 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
5050
5051
5052class TestNativeAnnouncementRouting:
5053 """Announcement routing respects the player's own support and its active output."""
5054
5055 def _make_player_with_linked_child(
5056 self,
5057 mock_mass: MagicMock,
5058 playback_state: PlaybackState,
5059 *,
5060 parent_supports_announce: bool = False,
5061 active_protocol: str | None = None,
5062 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
5063 """Create a controller, a player, its linked protocol child and the two path mocks."""
5064 controller = PlayerController(mock_mass)
5065 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
5066 player = MockPlayer(provider, "player_1", "Player 1")
5067 player._attr_playback_state = playback_state
5068 if parent_supports_announce:
5069 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
5070 proto_provider = MockProvider("airplay", mass=mock_mass)
5071 proto = MockPlayer(
5072 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
5073 )
5074 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
5075 controller._players = {"player_1": player, "proto_1": proto}
5076 mock_mass.players = controller
5077 player.set_linked_output_protocols(
5078 [
5079 LinkedOutputProtocol(
5080 output_protocol_id="proto_1",
5081 protocol_domain="airplay",
5082 priority=40,
5083 )
5084 ]
5085 )
5086 if active_protocol is not None:
5087 player.set_active_output_protocol(active_protocol)
5088 render = MagicMock()
5089 render.wait_ready = AsyncMock(return_value=True)
5090 renderer = mock_mass.streams.announcement_renderer
5091 renderer.register = MagicMock(return_value=render)
5092 renderer.unregister = AsyncMock()
5093 mock_mass.streams.get_announcement_url = MagicMock(
5094 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
5095 )
5096 proto.update_state(signal_event=False)
5097 player.update_state(signal_event=False)
5098 native_path = AsyncMock()
5099 generic_path = AsyncMock()
5100 controller._play_native_announcement = native_path # type: ignore[method-assign]
5101 controller._play_announcement = generic_path # type: ignore[method-assign]
5102 return controller, player, proto, native_path, generic_path
5103
5104 async def test_playing_player_does_not_route_to_an_idle_linked_child(
5105 self, mock_mass: MagicMock
5106 ) -> None:
5107 """
5108 A player rendering through one output must not announce through another.
5109
5110 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
5111 the announcement to the child would seize the device from the native
5112 output, with nothing restoring that playback afterwards.
5113 """
5114 controller, _player, _proto, native_path, generic_path = (
5115 self._make_player_with_linked_child(
5116 mock_mass, PlaybackState.PLAYING, active_protocol="native"
5117 )
5118 )
5119
5120 await controller.play_announcement("player_1", "http://test/announcement.mp3")
5121
5122 native_path.assert_not_awaited()
5123 generic_path.assert_awaited_once()
5124
5125 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
5126 """An idle player announces natively through any capable linked protocol."""
5127 controller, _player, proto, native_path, _generic_path = (
5128 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
5129 )
5130
5131 await controller.play_announcement("player_1", "http://test/announcement.mp3")
5132
5133 native_path.assert_awaited_once()
5134 assert native_path.call_args.args[1] is proto
5135
5136 async def test_own_native_support_beats_the_active_protocol_child(
5137 self, mock_mass: MagicMock
5138 ) -> None:
5139 """
5140 The player's own announcement handler wins over the output rendering the audio.
5141
5142 E.g. a Sonos playing through its AirPlay child announces with audioClip,
5143 which overlays the clip on that stream.
5144 """
5145 controller, player, _proto, native_path, _generic_path = (
5146 self._make_player_with_linked_child(
5147 mock_mass,
5148 PlaybackState.PLAYING,
5149 parent_supports_announce=True,
5150 active_protocol="proto_1",
5151 )
5152 )
5153
5154 await controller.play_announcement("player_1", "http://test/announcement.mp3")
5155
5156 native_path.assert_awaited_once()
5157 assert native_path.call_args.args[1] is player
5158
5159 async def test_active_protocol_child_announces_without_own_support(
5160 self, mock_mass: MagicMock
5161 ) -> None:
5162 """A player that cannot announce itself announces through its rendering output."""
5163 controller, _player, proto, native_path, _generic_path = (
5164 self._make_player_with_linked_child(
5165 mock_mass,
5166 PlaybackState.PLAYING,
5167 active_protocol="proto_1",
5168 )
5169 )
5170
5171 await controller.play_announcement("player_1", "http://test/announcement.mp3")
5172
5173 native_path.assert_awaited_once()
5174 assert native_path.call_args.args[1] is proto
5175
5176 async def test_own_native_support_wins_when_playing_natively(
5177 self, mock_mass: MagicMock
5178 ) -> None:
5179 """A player rendering through its own native output announces natively."""
5180 controller, player, _proto, native_path, _generic_path = (
5181 self._make_player_with_linked_child(
5182 mock_mass,
5183 PlaybackState.PLAYING,
5184 parent_supports_announce=True,
5185 active_protocol="native",
5186 )
5187 )
5188
5189 await controller.play_announcement("player_1", "http://test/announcement.mp3")
5190
5191 native_path.assert_awaited_once()
5192 assert native_path.call_args.args[1] is player
5193
5194 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
5195 self, mock_mass: MagicMock
5196 ) -> None:
5197 """Without active playback the player's own announcement support wins."""
5198 controller, player, _proto, native_path, _generic_path = (
5199 self._make_player_with_linked_child(
5200 mock_mass,
5201 PlaybackState.IDLE,
5202 parent_supports_announce=True,
5203 )
5204 )
5205
5206 await controller.play_announcement("player_1", "http://test/announcement.mp3")
5207
5208 native_path.assert_awaited_once()
5209 assert native_path.call_args.args[1] is player
5210
5211
5212@pytest.mark.usefixtures("running_background_tasks")
5213class TestPlayAnnouncementRestore:
5214 """Test the state restore of the default (fallback) announcement implementation."""
5215
5216 def _make_player(
5217 self, mock_mass: MagicMock, prev_media: PlayerMedia
5218 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
5219 """Create a controller and a playing player, returning the patched resume handler."""
5220 controller = PlayerController(mock_mass)
5221 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
5222 player = MockPlayer(provider, "player_1", "Player 1")
5223 player._attr_playback_state = PlaybackState.PLAYING
5224 player._attr_current_media = prev_media
5225 player._cache.clear()
5226 controller._players = {"player_1": player}
5227 mock_mass.players = controller
5228 mock_mass.player_queues.get = MagicMock(return_value=None)
5229 resume_mock = AsyncMock()
5230 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
5231 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
5232 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
5233 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
5234 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
5235 player.set_initialized()
5236 player.update_state(signal_event=False)
5237 return controller, player, resume_mock
5238
5239 @staticmethod
5240 def _add_group(
5241 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
5242 ) -> MockPlayer:
5243 """Register a powered group player that holds the given player as its member."""
5244 group = MockPlayer(
5245 cast("MockProvider", player.provider),
5246 "group_1",
5247 "Group 1",
5248 player_type=PlayerType.GROUP,
5249 )
5250 group._attr_powered = True
5251 group._attr_group_members = [player.player_id]
5252 if supports_set_members:
5253 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
5254 controller._players[group.player_id] = group
5255 group.set_initialized()
5256 group._cache.clear()
5257 group.update_state(signal_event=False)
5258 # the member has no own input change to trigger a recalculation of its
5259 # (group derived) state, so force it here - just like register() does
5260 player._cache.clear()
5261 player.update_state(force_update=True, signal_event=False)
5262 assert player.state.active_group == group.player_id
5263 return group
5264
5265 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
5266 """Content that was playing before the announcement is resumed afterwards."""
5267 controller, player, resume_mock = self._make_player(
5268 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5269 )
5270
5271 await controller._play_announcement(player, _announcement())
5272
5273 resume_mock.assert_awaited_once()
5274
5275 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
5276 """A player still busy with an earlier announcement has no playback to restore."""
5277 controller, player, resume_mock = self._make_player(
5278 mock_mass,
5279 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
5280 )
5281
5282 await controller._play_announcement(player, _announcement())
5283
5284 resume_mock.assert_not_awaited()
5285
5286 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
5287 """A failing announcement never leaves the player at the raised volume."""
5288 controller, player, _ = self._make_player(
5289 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5290 )
5291 player._attr_volume_level = 20
5292 player._cache.clear()
5293 player.update_state(force_update=True, signal_event=False)
5294 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
5295 volume_mock = AsyncMock()
5296 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
5297 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5298 side_effect=PlayerCommandFailed("player went away")
5299 )
5300
5301 with pytest.raises(PlayerCommandFailed):
5302 await controller._play_announcement(player, _announcement())
5303
5304 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
5305
5306 async def test_zero_announcement_volume_is_applied_and_restored(
5307 self, mock_mass: MagicMock
5308 ) -> None:
5309 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
5310 controller, player, _ = self._make_player(
5311 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5312 )
5313 player._attr_volume_level = 20
5314 player._cache.clear()
5315 player.update_state(force_update=True, signal_event=False)
5316 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
5317 volume_mock = AsyncMock()
5318 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
5319
5320 await controller._play_announcement(player, _announcement())
5321
5322 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
5323
5324 async def test_playback_is_restored_when_duration_is_unknown(
5325 self, mock_mass: MagicMock
5326 ) -> None:
5327 """An announcement of unknown length still hands the player back to its content."""
5328 controller, player, resume_mock = self._make_player(
5329 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5330 )
5331 announcement = _announcement()
5332 announcement.duration = None
5333
5334 # an unknown length waits for the player to report it finished instead of failing
5335 await controller._play_announcement(player, announcement)
5336
5337 resume_mock.assert_awaited_once()
5338
5339 async def test_group_membership_is_restored_when_playback_fails(
5340 self, mock_mass: MagicMock
5341 ) -> None:
5342 """A failing announcement never leaves the player out of its group player."""
5343 controller, player, _ = self._make_player(
5344 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5345 )
5346 group = self._add_group(controller, player, supports_set_members=True)
5347 group.set_members = AsyncMock() # type: ignore[method-assign]
5348 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5349 side_effect=PlayerCommandFailed("player went away")
5350 )
5351
5352 with pytest.raises(PlayerCommandFailed):
5353 await controller._play_announcement(player, _announcement())
5354
5355 assert group.set_members.await_args_list == [
5356 call(player_ids_to_remove=["player_1"]),
5357 call(player_ids_to_add=["player_1"]),
5358 ]
5359
5360 async def test_restore_failure_does_not_mask_the_announcement_error(
5361 self, mock_mass: MagicMock
5362 ) -> None:
5363 """A provider blowing up during the restore must not hide why the announcement failed."""
5364 controller, player, _ = self._make_player(
5365 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5366 )
5367 group = self._add_group(controller, player, supports_set_members=True)
5368 # set_members is a raw provider call: whatever its client library raises comes
5369 # through unwrapped, so the ungroup succeeds and the regroup times out
5370 group.set_members = AsyncMock( # type: ignore[method-assign]
5371 side_effect=[None, TimeoutError("provider timeout")]
5372 )
5373 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5374 side_effect=PlayerCommandFailed("player went away")
5375 )
5376
5377 with pytest.raises(PlayerCommandFailed, match="player went away"):
5378 await controller._play_announcement(player, _announcement())
5379
5380 async def test_group_without_set_members_is_the_one_powered_off(
5381 self, mock_mass: MagicMock
5382 ) -> None:
5383 """A group that can not release members is powered off, not the announcement target."""
5384 controller, player, _ = self._make_player(
5385 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5386 )
5387 self._add_group(controller, player, supports_set_members=False)
5388 power_mock = AsyncMock()
5389 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
5390 play_mock = AsyncMock()
5391 controller.cmd_play = play_mock # type: ignore[method-assign]
5392
5393 await controller._play_announcement(player, _announcement())
5394
5395 # the group is switched off for the announcement and restarted afterwards
5396 power_mock.assert_awaited_once_with("group_1", False)
5397 play_mock.assert_awaited_once_with("group_1")
5398
5399 async def test_idle_player_without_power_control_is_regrouped(
5400 self, mock_mass: MagicMock
5401 ) -> None:
5402 """An idle player that has no power state to restore is still put back in its group."""
5403 controller, player, _ = self._make_player(
5404 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5405 )
5406 player._attr_playback_state = PlaybackState.IDLE
5407 player._attr_powered = None
5408 group = self._add_group(controller, player, supports_set_members=True)
5409 assert player.state.power_control == PLAYER_CONTROL_NONE
5410 group.set_members = AsyncMock() # type: ignore[method-assign]
5411
5412 await controller._play_announcement(player, _announcement())
5413
5414 assert group.set_members.await_args_list == [
5415 call(player_ids_to_remove=["player_1"]),
5416 call(player_ids_to_add=["player_1"]),
5417 ]
5418
5419 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
5420 """A muted player hears the announcement and is muted again afterwards."""
5421 controller, player, _ = self._make_player(
5422 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5423 )
5424 mute_mock = _mute_natively(player)
5425
5426 await controller._play_announcement(player, _announcement())
5427
5428 assert mute_mock.await_args_list == [call(False), call(True)]
5429
5430 async def test_player_without_volume_control_is_still_unmuted(
5431 self, mock_mass: MagicMock
5432 ) -> None:
5433 """A player that can only be muted is unmuted for the announcement all the same."""
5434 mock_mass.config.get_raw_player_config_value = MagicMock(
5435 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
5436 )
5437 controller, player, _ = self._make_player(
5438 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5439 )
5440 mute_mock = _mute_natively(player)
5441 assert player.state.volume_control == PLAYER_CONTROL_NONE
5442
5443 await controller._play_announcement(player, _announcement())
5444
5445 assert mute_mock.await_args_list == [call(False), call(True)]
5446
5447 async def test_mute_is_restored_before_the_player_is_regrouped(
5448 self, mock_mass: MagicMock
5449 ) -> None:
5450 """A player is handed back to its group already muted, holding on to its mute lock."""
5451 controller, player, _ = self._make_player(
5452 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5453 )
5454 group = self._add_group(controller, player, supports_set_members=True)
5455 real_set_members = group.set_members
5456
5457 async def _set_members(**kwargs: list[str]) -> None:
5458 # let the membership really change, so the player is ungrouped while the
5459 # announcement plays - just like it is in production. neither player picks
5460 # the new membership up on its own here, so publish it on both.
5461 await real_set_members(**kwargs)
5462 group.update_state(force_update=True, signal_event=False)
5463 player._cache.clear()
5464 player.update_state(force_update=True, signal_event=False)
5465
5466 set_members = AsyncMock(side_effect=_set_members)
5467 group.set_members = set_members # type: ignore[method-assign]
5468 player.extra_data[ATTR_MUTE_LOCK] = True
5469 recorder = MagicMock()
5470 recorder.attach_mock(_mute_natively(player), "mute")
5471 recorder.attach_mock(set_members, "set_members")
5472
5473 await controller._play_announcement(player, _announcement())
5474
5475 assert recorder.mock_calls == [
5476 call.set_members(player_ids_to_remove=["player_1"]),
5477 call.mute(False),
5478 call.mute(True),
5479 call.set_members(player_ids_to_add=["player_1"]),
5480 ]
5481 # the lock survives the announcement, so the regroup does not unmute the player
5482 assert player.extra_data[ATTR_MUTE_LOCK] is True
5483
5484 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
5485 """A player that was not muted is never sent a mute command."""
5486 controller, player, _ = self._make_player(
5487 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5488 )
5489 mute_mock = _mute_natively(player)
5490 player._attr_volume_muted = False
5491 player._cache.clear()
5492 player.update_state(force_update=True, signal_event=False)
5493 mute_mock.reset_mock()
5494
5495 await controller._play_announcement(player, _announcement())
5496
5497 mute_mock.assert_not_awaited()
5498
5499 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
5500 """A failing announcement never leaves the player unmuted."""
5501 controller, player, _ = self._make_player(
5502 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5503 )
5504 mute_mock = _mute_natively(player)
5505 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5506 side_effect=PlayerCommandFailed("player went away")
5507 )
5508
5509 with pytest.raises(PlayerCommandFailed):
5510 await controller._play_announcement(player, _announcement())
5511
5512 assert mute_mock.await_args_list == [call(False), call(True)]
5513
5514 async def test_muted_sync_group_members_all_hear_the_announcement(
5515 self, mock_mass: MagicMock
5516 ) -> None:
5517 """Every member of a muted sync group is unmuted, keeping its mute lock."""
5518 controller, leader, _ = self._make_player(
5519 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5520 )
5521 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
5522 controller._players["player_2"] = member
5523 member.set_initialized()
5524 leader._attr_group_members = ["player_1", "player_2"]
5525 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
5526 # both members were muted while grouped, so both hold a mute lock
5527 for player in (leader, member):
5528 player.extra_data[ATTR_MUTE_LOCK] = True
5529
5530 await controller._play_announcement(leader, _announcement())
5531
5532 for player_id, mute_mock in mute_mocks.items():
5533 assert mute_mock.await_args_list == [call(False), call(True)], player_id
5534 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
5535
5536 async def test_fake_muted_player_announces_at_its_real_volume(
5537 self, mock_mass: MagicMock
5538 ) -> None:
5539 """A fake muted player announces at its real volume, not at the zero it is parked on."""
5540 mock_mass.config.get_raw_player_config_value = MagicMock(
5541 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
5542 )
5543 controller, player, _ = self._make_player(
5544 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5545 )
5546
5547 def _apply_volume(volume: int) -> None:
5548 player._attr_volume_level = volume
5549 player.update_state(signal_event=False)
5550
5551 player._attr_volume_level = 40
5552 volume_set = AsyncMock(side_effect=_apply_volume)
5553 player.volume_set = volume_set # type: ignore[method-assign]
5554 player._cache.clear()
5555 player.update_state(force_update=True, signal_event=False)
5556 await controller.cmd_volume_mute("player_1", True)
5557 assert player.state.volume_muted is True
5558 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
5559
5560 await controller._play_announcement(player, _announcement())
5561
5562 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
5563 assert volume_set.await_args_list == [
5564 call(0),
5565 call(40),
5566 call(80),
5567 call(40),
5568 call(0),
5569 ]
5570 assert player.state.volume_muted is True
5571 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
5572
5573
5574@pytest.mark.usefixtures("running_background_tasks")
5575class TestPlayNativeAnnouncement:
5576 """Test the mute handling around an announcement that a player plays natively."""
5577
5578 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
5579 """Create a controller and a player with native announcement support."""
5580 controller = PlayerController(mock_mass)
5581 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
5582 player = MockPlayer(provider, "player_1", "Player 1")
5583 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
5584 player._cache.clear()
5585 controller._players = {"player_1": player}
5586 mock_mass.players = controller
5587 mock_mass.player_queues.get = MagicMock(return_value=None)
5588 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
5589 announce_mock = AsyncMock()
5590 player.play_announcement = announce_mock # type: ignore[method-assign]
5591 player.set_initialized()
5592 player.update_state(signal_event=False)
5593 return controller, player, announce_mock
5594
5595 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
5596 """A muted player hears the announcement and is muted again afterwards."""
5597 controller, player, announce_mock = self._make_player(mock_mass)
5598 recorder = MagicMock()
5599 recorder.attach_mock(_mute_natively(player), "mute")
5600 recorder.attach_mock(announce_mock, "announce")
5601
5602 await controller._play_native_announcement(player, player, _announcement(), None)
5603
5604 assert recorder.mock_calls == [
5605 call.mute(False),
5606 call.announce(ANY, None),
5607 call.mute(True),
5608 ]
5609
5610 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
5611 """A player that was not muted is never sent a mute command."""
5612 controller, player, _ = self._make_player(mock_mass)
5613 mute_mock = _mute_natively(player)
5614 player._attr_volume_muted = False
5615 player._cache.clear()
5616 player.update_state(force_update=True, signal_event=False)
5617 mute_mock.reset_mock()
5618
5619 await controller._play_native_announcement(player, player, _announcement(), None)
5620
5621 mute_mock.assert_not_awaited()
5622
5623 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
5624 """A failing announcement never leaves the player unmuted."""
5625 controller, player, announce_mock = self._make_player(mock_mass)
5626 mute_mock = _mute_natively(player)
5627 announce_mock.side_effect = PlayerCommandFailed("player went away")
5628
5629 with pytest.raises(PlayerCommandFailed):
5630 await controller._play_native_announcement(player, player, _announcement(), None)
5631
5632 assert mute_mock.await_args_list == [call(False), call(True)]
5633
5634 async def test_muted_sync_group_members_all_hear_the_announcement(
5635 self, mock_mass: MagicMock
5636 ) -> None:
5637 """Every member of a muted sync group is unmuted, keeping its mute lock."""
5638 controller, leader, _ = self._make_player(mock_mass)
5639 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
5640 controller._players["player_2"] = member
5641 member.set_initialized()
5642 leader._attr_group_members = ["player_1", "player_2"]
5643 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
5644 # both members were muted while grouped, so both hold a mute lock
5645 for player in (leader, member):
5646 player.extra_data[ATTR_MUTE_LOCK] = True
5647
5648 await controller._play_native_announcement(leader, leader, _announcement(), None)
5649
5650 for player_id, mute_mock in mute_mocks.items():
5651 assert mute_mock.await_args_list == [call(False), call(True)], player_id
5652 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
5653
5654 async def test_fake_muted_player_announces_at_its_real_volume(
5655 self, mock_mass: MagicMock
5656 ) -> None:
5657 """The announcement volume is resolved after the unmute, not from the parked zero."""
5658 mock_mass.config.get_raw_player_config_value = MagicMock(
5659 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
5660 )
5661 controller, player, announce_mock = self._make_player(mock_mass)
5662
5663 def _apply_volume(volume: int) -> None:
5664 player._attr_volume_level = volume
5665 player.update_state(signal_event=False)
5666
5667 player._attr_volume_level = 40
5668 volume_set = AsyncMock(side_effect=_apply_volume)
5669 player.volume_set = volume_set # type: ignore[method-assign]
5670 player._cache.clear()
5671 player.update_state(force_update=True, signal_event=False)
5672 await controller.cmd_volume_mute("player_1", True)
5673 assert player.state.volume_muted is True
5674 # stand in for the configured strategy, which reads the volume of the player
5675 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
5676 side_effect=lambda _player_id, _volume_level: player.state.volume_level
5677 )
5678
5679 await controller._play_native_announcement(player, player, _announcement(), None)
5680
5681 assert announce_mock.await_args == call(ANY, 40)
5682 # unmute to 40 for the announcement, park back on 0 for the fake mute
5683 assert volume_set.await_args_list == [call(0), call(40), call(0)]
5684 assert player.state.volume_muted is True
5685 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
5686
5687
5688class TestScheduleActiveOutputProtocolClear:
5689 """Test the deferred clear of a player's active output protocol."""
5690
5691 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
5692 """Scheduling defers the clear to a single, per-player, cancellable task."""
5693 controller = PlayerController(mock_mass)
5694 player = MagicMock()
5695 player.player_id = "player_1"
5696
5697 controller.schedule_active_output_protocol_clear(player)
5698
5699 mock_mass.create_task.assert_called_once()
5700 # close the coroutine passed to the mocked create_task to avoid a
5701 # "coroutine was never awaited" warning
5702 mock_mass.create_task.call_args.args[0].close()
5703 # no abort_existing: a duplicate schedule must reuse the pending clear
5704 # (deduped by task_id) instead of replacing it with an untracked task
5705 assert mock_mass.create_task.call_args.kwargs == {
5706 "task_id": "clear_active_protocol_player_1",
5707 }
5708
5709 @pytest.mark.asyncio
5710 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
5711 """The protocol is cleared after waiting for the player to reach IDLE."""
5712 controller = PlayerController(mock_mass)
5713 player = MagicMock()
5714 player.player_id = "player_1"
5715
5716 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
5717 await controller._clear_active_output_protocol_when_idle(player)
5718
5719 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
5720 player.set_active_output_protocol.assert_called_once_with(None)
5721
5722
5723@contextlib.asynccontextmanager
5724async def _skip_player_update_wait(
5725 *_args: object,
5726 **_kwargs: object,
5727) -> AsyncIterator[None]:
5728 """Skip provider-driven state propagation in command-routing tests."""
5729 yield
5730
5731
5732class TestRemovePlayerControl:
5733 """Test removing a registered player control."""
5734
5735 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
5736 """Test that only the players configured to use the removed control are refreshed."""
5737 mock_mass.loop = MagicMock()
5738 controller = PlayerController(mock_mass)
5739 using_control = MagicMock()
5740 using_control.state.power_control = "switch.amp"
5741 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
5742 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
5743 unrelated = MagicMock()
5744 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
5745 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
5746 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
5747 controller._players = {"using_control": using_control, "unrelated": unrelated}
5748 controller._controls = {
5749 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
5750 }
5751
5752 controller.remove_player_control("switch.amp")
5753
5754 assert controller.player_controls() == []
5755 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
5756
5757 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
5758 """Test that a control removed and registered again re-attaches to its player."""
5759 # run the scheduled refresh straight away so each step is observable
5760 mock_mass.loop = MagicMock()
5761 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
5762 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
5763 {CONF_POWER_CONTROL: "switch.amp"}
5764 )
5765 controller = PlayerController(mock_mass)
5766 mock_mass.players = controller
5767 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5768 mock_mass.get_provider.return_value = provider
5769 player = MockPlayer(provider, "player", "Player")
5770 controller._players = {"player": player}
5771 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
5772
5773 await controller.register_or_update_player_control(control)
5774 assert player.state.power_control == "switch.amp"
5775
5776 # the Home Assistant plugin drops and re-registers its controls around a reload
5777 controller.remove_player_control(control.id)
5778 assert player.state.power_control == PLAYER_CONTROL_NONE
5779
5780 await controller.register_or_update_player_control(control)
5781 assert player.state.power_control == "switch.amp"
5782
5783 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
5784 """Test that removing a control that was never registered is a no-op."""
5785 mock_mass.loop = MagicMock()
5786 controller = PlayerController(mock_mass)
5787 player = MagicMock()
5788 player.state.power_control = PLAYER_CONTROL_NATIVE
5789 player.state.volume_control = PLAYER_CONTROL_NATIVE
5790 player.state.mute_control = PLAYER_CONTROL_NATIVE
5791 controller._players = {"player": player}
5792
5793 controller.remove_player_control("switch.gone")
5794
5795 mock_mass.loop.call_soon.assert_not_called()
5796
5797
5798class _FailingTeardownPlayer(MockPlayer):
5799 """Player whose provider fails to release it."""
5800
5801 unloaded = False
5802
5803 async def on_unload(self) -> None:
5804 """Handle logic when the player is unloaded from the Player controller."""
5805 self.unloaded = True
5806 msg = "device is gone"
5807 raise RuntimeError(msg)
5808
5809
5810class TestUnregisterTeardown:
5811 """Test that a failing player teardown stays contained."""
5812
5813 async def test_failing_on_unload_still_unregisters_the_player(
5814 self, mock_mass: MagicMock
5815 ) -> None:
5816 """Test that a provider raising while releasing its player does not break unregister."""
5817 controller = PlayerController(mock_mass)
5818 mock_mass.players = controller
5819 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5820 player = _FailingTeardownPlayer(provider, "boom", "Boom")
5821 controller._players = {"boom": player}
5822
5823 await controller.unregister("boom")
5824
5825 assert "boom" not in controller._players
5826 assert player.unloaded
5827
5828
5829class TestDeletePlayerConfigUserFilters:
5830 """Test how a deleted player config is reflected in the user access filters."""
5831
5832 def test_removal_drops_the_player_from_the_filters(self, mock_mass: MagicMock) -> None:
5833 """A removed player is dropped from the access filter of every user."""
5834 controller = PlayerController(mock_mass)
5835 mock_mass.players = controller
5836
5837 controller.delete_player_config("sonos_1")
5838
5839 mock_mass.webserver.auth.remove_from_user_filters.assert_called_once_with(
5840 player_ids=["sonos_1"]
5841 )
5842 mock_mass.webserver.auth.replace_player_in_user_filters.assert_not_called()
5843
5844 def test_replacement_hands_the_filters_to_the_new_player(self, mock_mass: MagicMock) -> None:
5845 """A replaced player hands its access filter entries over to its replacement."""
5846 controller = PlayerController(mock_mass)
5847 mock_mass.players = controller
5848
5849 controller.delete_player_config("up_old", replacement_player_id="sonos_1")
5850
5851 mock_mass.webserver.auth.replace_player_in_user_filters.assert_called_once_with(
5852 "up_old", "sonos_1", removed_player_ids=["up_old"]
5853 )
5854 mock_mass.webserver.auth.remove_from_user_filters.assert_not_called()
5855
5856
5857class TestDeletePlayerConfigGroupMemberships:
5858 """Test how a deleted player config is reflected in the stored group member lists."""
5859
5860 @staticmethod
5861 def _config_store(mock_mass: MagicMock) -> dict[str, Any]:
5862 """Back the mocked config with a store holding a group that lists sonos_1."""
5863 config_store: dict[str, Any] = {
5864 "players": {
5865 "group_1": {
5866 "values": {
5867 "group_members": ["sonos_1", "sonos_2"],
5868 "allowed_members": ["sonos_1"],
5869 }
5870 },
5871 },
5872 }
5873 mock_mass.config.get = MagicMock(
5874 side_effect=lambda key, default=None: config_store.get(key, default)
5875 )
5876 mock_mass.config.set = MagicMock(
5877 side_effect=lambda key, value: config_store.__setitem__(key, value)
5878 )
5879 return config_store
5880
5881 def test_removal_drops_the_player_from_the_member_lists(self, mock_mass: MagicMock) -> None:
5882 """A removed player is dropped from the member lists of every group."""
5883 controller = PlayerController(mock_mass)
5884 mock_mass.players = controller
5885 config_store = self._config_store(mock_mass)
5886
5887 controller.delete_player_config("sonos_1")
5888
5889 assert config_store["players/group_1/values/group_members"] == ["sonos_2"]
5890 # the allow-list is left alone: emptying it would stop it restricting anything
5891 assert "players/group_1/values/allowed_members" not in config_store
5892
5893 def test_replacement_hands_the_membership_to_the_new_player(self, mock_mass: MagicMock) -> None:
5894 """A replaced player hands its group memberships over to its replacement."""
5895 controller = PlayerController(mock_mass)
5896 mock_mass.players = controller
5897 config_store = self._config_store(mock_mass)
5898
5899 controller.delete_player_config("sonos_1", replacement_player_id="sonos_3")
5900
5901 assert config_store["players/group_1/values/group_members"] == ["sonos_3", "sonos_2"]
5902 assert config_store["players/group_1/values/allowed_members"] == ["sonos_3"]
5903
5904 async def test_permanent_unregister_drops_the_membership(self, mock_mass: MagicMock) -> None:
5905 """A permanently removed player does not linger in a group's stored member list."""
5906 controller = PlayerController(mock_mass)
5907 mock_mass.players = controller
5908 config_store = self._config_store(mock_mass)
5909 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5910 controller._players = {"sonos_1": MockPlayer(provider, "sonos_1", "Sonos 1")}
5911
5912 await controller.unregister("sonos_1", permanent=True)
5913
5914 assert config_store["players/group_1/values/group_members"] == ["sonos_2"]
5915
5916 async def test_temporary_unregister_keeps_the_membership(self, mock_mass: MagicMock) -> None:
5917 """A player that is only temporarily gone keeps its spot in the group."""
5918 controller = PlayerController(mock_mass)
5919 mock_mass.players = controller
5920 config_store = self._config_store(mock_mass)
5921 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5922 controller._players = {"sonos_1": MockPlayer(provider, "sonos_1", "Sonos 1")}
5923
5924 await controller.unregister("sonos_1")
5925
5926 assert "players/group_1/values/group_members" not in config_store
5927
5928 async def test_a_registered_group_re_reads_its_members(self, mock_mass: MagicMock) -> None:
5929 """A registered group is told to re-read its members so its live list follows."""
5930 controller = PlayerController(mock_mass)
5931 mock_mass.players = controller
5932 self._config_store(mock_mass)
5933 scheduled: list[Any] = []
5934 mock_mass.create_task = MagicMock(side_effect=lambda task: scheduled.append(task))
5935 group = MockPlayer(MockProvider("sync_group", mass=mock_mass), "group_1", "Group")
5936 entry = ConfigEntry(key="group_members", type=ConfigEntryType.STRING, multi_value=True)
5937 entry.value = ["sonos_1", "sonos_2"]
5938 group.config.values = {"group_members": entry}
5939 controller._players = {"group_1": group}
5940
5941 with (
5942 patch.object(group, "on_config_updated", AsyncMock()) as mock_reload,
5943 patch.object(group, "refresh_state") as mock_refresh,
5944 ):
5945 controller.delete_player_config("sonos_1")
5946 for task in [t for t in scheduled if "_reload_group_members" in repr(t)]:
5947 await task
5948
5949 # the in-place config copy is pruned before the group re-reads it
5950 assert group.config.values["group_members"].value == ["sonos_2"]
5951 mock_reload.assert_awaited_once()
5952 mock_refresh.assert_called_once()
5953
5954
5955class TestConfigChangeRestartsPlayback:
5956 """Test that a changed player setting which needs a reload restarts playback."""
5957
5958 @staticmethod
5959 def _config(*, requires_reload: bool) -> PlayerConfig:
5960 """Build a PlayerConfig holding a single output codec entry."""
5961 return PlayerConfig(
5962 provider="test_prov",
5963 player_id="player_1",
5964 values={
5965 CONF_OUTPUT_CODEC: ConfigEntry(
5966 key=CONF_OUTPUT_CODEC,
5967 type=ConfigEntryType.STRING,
5968 label="Output codec",
5969 value="flac",
5970 requires_reload=requires_reload,
5971 )
5972 },
5973 )
5974
5975 @staticmethod
5976 def _prepare(mock_mass: MagicMock, queue_state: PlaybackState) -> PlayerController:
5977 """Register a player whose active queue is in the given state."""
5978 controller = PlayerController(mock_mass)
5979 player = MagicMock()
5980 player.state.active_source = "player_1"
5981 player.on_config_updated = AsyncMock()
5982 controller._players = {"player_1": player}
5983 queue = MagicMock()
5984 queue.queue_id = "player_1"
5985 queue.state = queue_state
5986 mock_mass.player_queues.get = MagicMock(return_value=queue)
5987 mock_mass.player_queues.stop = AsyncMock()
5988 return controller
5989
5990 async def test_reload_setting_restarts_playback(self, mock_mass: MagicMock) -> None:
5991 """Test that changing a reload-requiring setting stops and resumes the queue."""
5992 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5993
5994 await controller.on_player_config_change(
5995 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5996 )
5997
5998 mock_mass.player_queues.stop.assert_awaited_once_with("player_1")
5999 mock_mass.call_later.assert_called_once_with(
6000 1, mock_mass.player_queues.resume, "player_1", False
6001 )
6002
6003 async def test_plain_setting_does_not_restart_playback(self, mock_mass: MagicMock) -> None:
6004 """Test that a setting which applies on the fly leaves playback alone."""
6005 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
6006
6007 await controller.on_player_config_change(
6008 self._config(requires_reload=False), {f"values/{CONF_OUTPUT_CODEC}"}
6009 )
6010
6011 mock_mass.player_queues.stop.assert_not_awaited()
6012 mock_mass.call_later.assert_not_called()
6013
6014 async def test_untouched_reload_setting_does_not_restart_playback(
6015 self, mock_mass: MagicMock
6016 ) -> None:
6017 """Test that only a changed reload-requiring setting restarts playback."""
6018 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
6019
6020 await controller.on_player_config_change(
6021 self._config(requires_reload=True), {f"values/{CONF_ICON}"}
6022 )
6023
6024 mock_mass.player_queues.stop.assert_not_awaited()
6025 mock_mass.call_later.assert_not_called()
6026
6027 async def test_idle_queue_is_left_alone(self, mock_mass: MagicMock) -> None:
6028 """Test that a reload-requiring change does not start playback on an idle queue."""
6029 controller = self._prepare(mock_mass, PlaybackState.IDLE)
6030
6031 await controller.on_player_config_change(
6032 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
6033 )
6034
6035 mock_mass.player_queues.stop.assert_not_awaited()
6036 mock_mass.call_later.assert_not_called()
6037
6038
6039if __name__ == "__main__":
6040 pytest.main([__file__, "-v"])
6041