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