/
/
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 ) -> tuple[PlayerController, MockPlayer]:
3051 """Build a controller with a single player using the given control config."""
3052 mock_mass.config.get_raw_player_config_value = MagicMock(
3053 side_effect=_player_config_stub(
3054 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
3055 )
3056 )
3057 controller = PlayerController(mock_mass)
3058 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3059 player = MockPlayer(provider, "player_1", "Player 1")
3060 controller._players = {"player_1": player}
3061 controller._controls = controls or {}
3062 mock_mass.players = controller
3063 mock_mass.player_queues.get = MagicMock(return_value=None)
3064 player.set_initialized()
3065 player.update_state(signal_event=False)
3066 return controller, player
3067
3068 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3069 """A player without volume control still mutes through an external PlayerControl."""
3070 mute_set = AsyncMock()
3071 control = PlayerControl(
3072 id="ext_mute",
3073 provider="test",
3074 name="External Mute",
3075 supports_mute=True,
3076 mute_set=mute_set,
3077 )
3078 controller, player = self._make_player(
3079 mock_mass,
3080 mute_control="ext_mute",
3081 volume_control=PLAYER_CONTROL_NONE,
3082 controls={"ext_mute": control},
3083 )
3084 assert player.mute_control == "ext_mute"
3085
3086 await controller.cmd_volume_mute("player_1", True)
3087 mute_set.assert_awaited_once_with(True)
3088
3089 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3090 """A player without volume control still mutes natively."""
3091 controller, player = self._make_player(
3092 mock_mass,
3093 mute_control=PLAYER_CONTROL_NATIVE,
3094 volume_control=PLAYER_CONTROL_NONE,
3095 )
3096 volume_mute = AsyncMock()
3097 player.volume_mute = volume_mute # type: ignore[method-assign]
3098
3099 await controller.cmd_volume_mute("player_1", True)
3100 volume_mute.assert_awaited_once_with(True)
3101
3102 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
3103 """A player with volume control but no mute control rejects the command."""
3104 controller, player = self._make_player(
3105 mock_mass,
3106 mute_control=PLAYER_CONTROL_NONE,
3107 volume_control=PLAYER_CONTROL_NATIVE,
3108 )
3109 volume_mute = AsyncMock()
3110 player.volume_mute = volume_mute # type: ignore[method-assign]
3111
3112 with pytest.raises(UnsupportedFeaturedException):
3113 await controller.cmd_volume_mute("player_1", True)
3114 volume_mute.assert_not_awaited()
3115
3116 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
3117 """Fake mute needs a volume control to drive, so it rejects the command outright."""
3118 controller, player = self._make_player(
3119 mock_mass,
3120 mute_control=PLAYER_CONTROL_FAKE,
3121 volume_control=PLAYER_CONTROL_NONE,
3122 )
3123 player._attr_volume_level = 40
3124
3125 with pytest.raises(UnsupportedFeaturedException):
3126 await controller.cmd_volume_mute("player_1", True)
3127 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
3128 assert ATTR_FAKE_MUTE not in player.extra_data
3129
3130 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
3131 """A mute control that disappeared after being resolved is reported, not ignored."""
3132 control = PlayerControl(
3133 id="ext_mute",
3134 provider="test",
3135 name="External Mute",
3136 supports_mute=True,
3137 mute_set=AsyncMock(),
3138 )
3139 controller, player = self._make_player(
3140 mock_mass,
3141 mute_control="ext_mute",
3142 volume_control=PLAYER_CONTROL_NONE,
3143 controls={"ext_mute": control},
3144 )
3145 # the resolved control is cached on the player, so removing it here leaves
3146 # the player pointing at a control that no longer exists
3147 assert player.mute_control == "ext_mute"
3148 controller._controls = {}
3149
3150 with pytest.raises(UnsupportedFeaturedException):
3151 await controller.cmd_volume_mute("player_1", True)
3152
3153 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
3154 """Unmuting clears a mute lock left behind by a since-removed mute control."""
3155 controller, player = self._make_player(
3156 mock_mass,
3157 mute_control=PLAYER_CONTROL_NONE,
3158 volume_control=PLAYER_CONTROL_NATIVE,
3159 )
3160 player.extra_data[ATTR_MUTE_LOCK] = True
3161
3162 with pytest.raises(UnsupportedFeaturedException):
3163 await controller.cmd_volume_mute("player_1", False)
3164 assert ATTR_MUTE_LOCK not in player.extra_data
3165
3166 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
3167 """A grouped player whose mute command failed is not left holding a mute lock."""
3168 control = PlayerControl(
3169 id="ext_mute",
3170 provider="test",
3171 name="External Mute",
3172 supports_mute=False,
3173 )
3174 controller, player = self._make_player(
3175 mock_mass,
3176 mute_control="ext_mute",
3177 volume_control=PLAYER_CONTROL_NONE,
3178 controls={"ext_mute": control},
3179 )
3180 player.state.synced_to = "leader"
3181
3182 with pytest.raises(UnsupportedFeaturedException):
3183 await controller.cmd_volume_mute("player_1", True)
3184 assert ATTR_MUTE_LOCK not in player.extra_data
3185
3186 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
3187 """A failed mute leaves the lock of an earlier successful mute in place."""
3188 control = PlayerControl(
3189 id="ext_mute",
3190 provider="test",
3191 name="External Mute",
3192 supports_mute=False,
3193 )
3194 controller, player = self._make_player(
3195 mock_mass,
3196 mute_control="ext_mute",
3197 volume_control=PLAYER_CONTROL_NONE,
3198 controls={"ext_mute": control},
3199 )
3200 player.state.synced_to = "leader"
3201 player.extra_data[ATTR_MUTE_LOCK] = True
3202
3203 with pytest.raises(UnsupportedFeaturedException):
3204 await controller.cmd_volume_mute("player_1", True)
3205 assert player.extra_data[ATTR_MUTE_LOCK] is True
3206
3207
3208class TestGroupMuteMemberFilter:
3209 """Group mute skips members that have no mute control of their own."""
3210
3211 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
3212 """A member without a mute control must not fail the whole group command."""
3213 controller = PlayerController(mock_mass)
3214 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3215 leader = MockPlayer(provider, "leader", "Leader")
3216 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3217 leader._attr_group_members = ["leader", "member"]
3218 member = MockPlayer(provider, "member", "Member")
3219 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3220 controller._players = {"leader": leader, "member": member}
3221 mock_mass.players = controller
3222 mock_mass.player_queues.get = MagicMock(return_value=None)
3223 for player in (leader, member):
3224 player.set_initialized()
3225 player.update_state(signal_event=False)
3226 leader_mute = AsyncMock()
3227 leader.volume_mute = leader_mute # type: ignore[method-assign]
3228
3229 await controller.cmd_group_volume_mute("leader", True)
3230 leader_mute.assert_awaited_once_with(True)
3231
3232
3233class TestGroupPlayerMuteRedirect:
3234 """A mute command on a group player is handled at group level."""
3235
3236 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
3237 """Build a controller with a group player holding a single mute capable member."""
3238 controller = PlayerController(mock_mass)
3239 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3240 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3241 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3242 group._attr_group_members = ["member"]
3243 member = MockPlayer(provider, "member", "Member")
3244 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3245 controller._players = {"group": group, "member": member}
3246 mock_mass.players = controller
3247 mock_mass.player_queues.get = MagicMock(return_value=None)
3248 for player in (group, member):
3249 player.set_initialized()
3250 player.update_state(signal_event=False)
3251 return controller, group, member
3252
3253 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
3254 """A group player has no mute of its own, so the members must be muted instead."""
3255 controller, _group, member = self._setup(mock_mass)
3256 member_mute = AsyncMock()
3257 member.volume_mute = member_mute # type: ignore[method-assign]
3258
3259 await controller.cmd_volume_mute("group", True)
3260
3261 member_mute.assert_awaited_once_with(True)
3262
3263 async def test_mute_on_group_player_without_own_mute_control(
3264 self, mock_mass: MagicMock
3265 ) -> None:
3266 """A group that has no mute control of its own must still mute its members."""
3267 controller, group, member = self._setup(mock_mass)
3268 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
3269 group._cache.clear()
3270 group.update_state(signal_event=False)
3271 assert group.mute_control == PLAYER_CONTROL_NONE
3272 member_mute = AsyncMock()
3273 member.volume_mute = member_mute # type: ignore[method-assign]
3274
3275 await controller.cmd_volume_mute("group", True)
3276
3277 member_mute.assert_awaited_once_with(True)
3278
3279 async def test_mute_on_group_player_without_mute_capable_members(
3280 self, mock_mass: MagicMock
3281 ) -> None:
3282 """A group whose members cannot mute must not raise, just like group mute itself."""
3283 controller, _group, member = self._setup(mock_mass)
3284 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3285 member._cache.clear()
3286 member.update_state(signal_event=False)
3287
3288 await controller.cmd_volume_mute("group", True)
3289
3290
3291class TestGroupMuteOnNonGroupPlayer:
3292 """A group mute command works on any player, just like the group volume command."""
3293
3294 def _setup(
3295 self, mock_mass: MagicMock, *members: str
3296 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3297 """Build a controller with a mute capable leader synced to the given members."""
3298 controller = PlayerController(mock_mass)
3299 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3300 players: dict[str, MockPlayer] = {}
3301 for player_id in ("leader", *members):
3302 player = MockPlayer(provider, player_id, player_id.title())
3303 player._attr_supported_features = {
3304 PlayerFeature.VOLUME_SET,
3305 PlayerFeature.VOLUME_MUTE,
3306 }
3307 player._attr_volume_level = 50
3308 players[player_id] = player
3309 if members:
3310 # the leader is not listed as its own member here, so the tests also cover
3311 # that a sync leader is injected into its own final group_members
3312 players["leader"]._attr_group_members = list(members)
3313 controller._players = dict(players)
3314 mock_mass.players = controller
3315 mock_mass.player_queues.get = MagicMock(return_value=None)
3316 for player in players.values():
3317 player.set_initialized()
3318 player._cache.clear()
3319 player.update_state(signal_event=False)
3320 return controller, players
3321
3322 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
3323 """Replace the native mute command of every given player with a mock."""
3324 mutes: dict[str, AsyncMock] = {}
3325 for player_id, player in players.items():
3326 mutes[player_id] = AsyncMock()
3327 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
3328 return mutes
3329
3330 async def test_group_mute_on_synced_member_redirects_to_leader(
3331 self, mock_mass: MagicMock
3332 ) -> None:
3333 """A member of a sync group mutes the whole group through its sync leader."""
3334 controller, players = self._setup(mock_mass, "member")
3335 assert players["member"].state.synced_to == "leader"
3336 mutes = self._stub_mutes(players)
3337
3338 await controller.cmd_group_volume_mute("member", True)
3339
3340 mutes["leader"].assert_awaited_once_with(True)
3341 mutes["member"].assert_awaited_once_with(True)
3342 assert ATTR_MUTE_LOCK in players["member"].extra_data
3343
3344 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
3345 self, mock_mass: MagicMock
3346 ) -> None:
3347 """A sync leader is part of its own member list, so it must be muted only once."""
3348 controller, players = self._setup(mock_mass, "member")
3349 mutes = self._stub_mutes(players)
3350
3351 await controller.cmd_group_volume_mute("leader", True)
3352
3353 mutes["leader"].assert_awaited_once_with(True)
3354 mutes["member"].assert_awaited_once_with(True)
3355
3356 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
3357 """A player that is not grouped at all is muted as a normal player."""
3358 controller, players = self._setup(mock_mass)
3359 mutes = self._stub_mutes(players)
3360
3361 await controller.cmd_group_volume_mute("leader", True)
3362
3363 mutes["leader"].assert_awaited_once_with(True)
3364
3365 async def test_group_mute_on_plain_player_without_mute_control(
3366 self, mock_mass: MagicMock
3367 ) -> None:
3368 """A plain player that cannot mute reports that, just like a normal mute command."""
3369 controller, players = self._setup(mock_mass)
3370 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
3371 players["leader"]._cache.clear()
3372 players["leader"].update_state(signal_event=False)
3373
3374 with pytest.raises(UnsupportedFeaturedException):
3375 await controller.cmd_group_volume_mute("leader", True)
3376
3377 async def test_group_unmute_on_synced_member_redirects_to_leader(
3378 self, mock_mass: MagicMock
3379 ) -> None:
3380 """Unmuting through a member clears the mute (and mute lock) of every group member."""
3381 controller, players = self._setup(mock_mass, "member")
3382 mutes = self._stub_mutes(players)
3383 players["member"].extra_data[ATTR_MUTE_LOCK] = True
3384
3385 await controller.cmd_group_volume_mute("member", False)
3386
3387 mutes["leader"].assert_awaited_once_with(False)
3388 mutes["member"].assert_awaited_once_with(False)
3389 assert ATTR_MUTE_LOCK not in players["member"].extra_data
3390
3391 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
3392 """A sync leader is as much part of the group as its members, so it is locked too."""
3393 controller, players = self._setup(mock_mass, "member")
3394 self._stub_mutes(players)
3395
3396 await controller.cmd_group_volume_mute("leader", True)
3397
3398 assert ATTR_MUTE_LOCK in players["leader"].extra_data
3399 assert ATTR_MUTE_LOCK in players["member"].extra_data
3400
3401 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
3402 """A group volume change may not half-unmute a muted pair of directly synced players."""
3403 controller, players = self._setup(mock_mass, "member")
3404 self._stub_mutes(players)
3405 await controller.cmd_group_volume_mute("leader", True)
3406 # the mock players do not act on the mute command, so reflect it in their state
3407 for player in players.values():
3408 player._attr_volume_muted = True
3409 player.update_state(signal_event=False)
3410 player.volume_set = AsyncMock() # type: ignore[method-assign]
3411 # re-stub so only the mute commands of the group volume change are counted
3412 mutes = self._stub_mutes(players)
3413
3414 await controller.cmd_group_volume("leader", 30)
3415
3416 for mute in mutes.values():
3417 mute.assert_not_awaited()
3418
3419
3420class TestMuteLockAfterUngroup:
3421 """
3422 Mute persistence across a volume-set command.
3423
3424 A native mute is never lifted by a volume command, grouped or not. A fake mute
3425 lock is honored only while the player it belongs to is still grouped.
3426 """
3427
3428 def _make_synced_pair(
3429 self, mock_mass: MagicMock, member_mute_control: str
3430 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3431 """
3432 Build a leader with one synced member.
3433
3434 :param member_mute_control: Mute control to configure on both players.
3435 """
3436 mock_mass.config.get_raw_player_config_value = MagicMock(
3437 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
3438 )
3439 controller = PlayerController(mock_mass)
3440 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3441 players: dict[str, MockPlayer] = {}
3442 for player_id in ("leader", "member"):
3443 player = MockPlayer(provider, player_id, player_id.title())
3444 player._attr_supported_features = {
3445 PlayerFeature.VOLUME_SET,
3446 PlayerFeature.VOLUME_MUTE,
3447 }
3448 player._attr_volume_level = 50
3449 player.volume_set = AsyncMock( # type: ignore[method-assign]
3450 side_effect=lambda volume, _player=player: setattr(
3451 _player, "_attr_volume_level", volume
3452 )
3453 )
3454 players[player_id] = player
3455 players["leader"]._attr_group_members = ["member"]
3456 controller._players = dict(players)
3457 mock_mass.players = controller
3458 mock_mass.player_queues.get = MagicMock(return_value=None)
3459 for player in players.values():
3460 player.set_initialized()
3461 player._cache.clear()
3462 player.update_state(signal_event=False)
3463 return controller, players
3464
3465 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
3466 """Drop the sync group, the way a provider side topology change does."""
3467 players["leader"]._attr_group_members = []
3468 for player in players.values():
3469 player.refresh_state(signal_event=False)
3470
3471 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
3472 """A fake muted player is no longer forced silent once its group is gone."""
3473 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3474 await controller.cmd_volume_mute("member", True)
3475 self._dissolve_group(players)
3476
3477 await controller.cmd_volume_set("member", 70)
3478
3479 players["member"].update_state()
3480 assert players["member"].state.volume_level == 70
3481 assert players["member"].state.volume_muted is False
3482
3483 async def test_natively_muted_player_keeps_its_mute_after_ungroup(
3484 self, mock_mass: MagicMock
3485 ) -> None:
3486 """A natively muted player keeps its mute on a volume change, group gone or not."""
3487 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
3488 mute = AsyncMock(
3489 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
3490 )
3491 players["member"].volume_mute = mute # type: ignore[method-assign]
3492 await controller.cmd_volume_mute("member", True)
3493 self._dissolve_group(players)
3494
3495 await controller.cmd_volume_set("member", 70)
3496
3497 mute.assert_awaited_once_with(True)
3498 players["member"].update_state()
3499 assert players["member"].state.volume_level == 70
3500 assert players["member"].state.volume_muted is True
3501
3502 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
3503 """A muted player that is still grouped stays silent on a volume change."""
3504 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3505 await controller.cmd_volume_mute("member", True)
3506
3507 await controller.cmd_volume_set("member", 70)
3508
3509 players["member"].update_state()
3510 assert players["member"].state.volume_level == 0
3511 assert players["member"].state.volume_muted is True
3512
3513 async def test_protocol_player_follows_the_lock_of_its_parent(
3514 self, mock_mass: MagicMock
3515 ) -> None:
3516 """A protocol player inherits the lock of the parent it renders for, group and all."""
3517 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3518 member = players["member"]
3519 protocol_player = MockPlayer(
3520 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
3521 "proto_member",
3522 "Member Protocol",
3523 player_type=PlayerType.PROTOCOL,
3524 )
3525 protocol_player._attr_supported_features = {
3526 PlayerFeature.VOLUME_SET,
3527 PlayerFeature.VOLUME_MUTE,
3528 }
3529 protocol_player._attr_volume_level = 50
3530 protocol_player.volume_set = AsyncMock( # type: ignore[method-assign]
3531 side_effect=lambda volume: setattr(protocol_player, "_attr_volume_level", volume)
3532 )
3533 protocol_player.set_protocol_parent_id("member")
3534 controller._players["proto_member"] = protocol_player
3535 member.set_linked_output_protocols(
3536 [
3537 LinkedOutputProtocol(
3538 output_protocol_id="proto_member",
3539 protocol_domain="sendspin",
3540 priority=40,
3541 )
3542 ]
3543 )
3544 protocol_player.set_initialized()
3545 protocol_player.update_state(signal_event=False)
3546 member.refresh_state(signal_event=False)
3547
3548 # the lock is earned by the parent while it is still grouped. The internal
3549 # handler is used to fake-mute the protocol player itself, bypassing the
3550 # public command's auto-resolve to its parent, so the fake-mute flag ends
3551 # up on the protocol player and the fake-mute volume path applies to it
3552 await controller.cmd_volume_mute("member", True)
3553 await controller._handle_cmd_volume_mute(protocol_player, PLAYER_CONTROL_FAKE, True)
3554
3555 # while the parent holds the lock, a volume command for the protocol player
3556 # is forced to 0 (stays silent) instead of releasing its fake mute
3557 await controller._handle_cmd_volume_set("proto_member", 70)
3558 protocol_player.update_state()
3559 assert protocol_player.state.volume_level == 0
3560
3561 self._dissolve_group(players)
3562 await controller._handle_cmd_volume_set("proto_member", 70)
3563
3564 protocol_player.update_state()
3565 assert protocol_player.state.volume_level == 70
3566
3567
3568class TestCurrentMediaTimeUpdates:
3569 """Playback-position anchor semantics of timing-only state updates."""
3570
3571 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
3572 """Build a controller with a single playing player with a known position anchor."""
3573 controller = PlayerController(mock_mass)
3574 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3575 player = MockPlayer(provider, "player_1", "Player 1")
3576 controller._players = {"player_1": player}
3577 mock_mass.players = controller
3578 # no queue registered: current_media resolves from the player's native media
3579 mock_mass.player_queues.get = MagicMock(return_value=None)
3580 player.set_initialized()
3581 now = time.time()
3582 player._attr_playback_state = PlaybackState.PLAYING
3583 player._attr_elapsed_time = 17
3584 player._attr_elapsed_time_last_updated = now
3585 player.set_current_media(uri="http://test/stream", title="Test")
3586 assert player._attr_current_media is not None
3587 player._attr_current_media.elapsed_time = 17
3588 player._attr_current_media.elapsed_time_last_updated = now
3589 player.update_state(signal_event=False)
3590 # isolate from the unrelated state-forwarding machinery
3591 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
3592 mock_mass.signal_event.reset_mock()
3593 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
3594 return controller, player
3595
3596 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
3597 """Return whether a PLAYER_UPDATED event was signalled."""
3598 return any(
3599 call.args and call.args[0] == EventType.PLAYER_UPDATED
3600 for call in mock_mass.signal_event.call_args_list
3601 )
3602
3603 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
3604 """A regular playback tick (position and anchor advance together) emits nothing."""
3605 _controller, player = self._make_player(mock_mass)
3606 assert player._attr_current_media is not None
3607 assert player._attr_elapsed_time_last_updated is not None
3608
3609 player._attr_elapsed_time = 18
3610 player._attr_elapsed_time_last_updated += 1
3611 player._attr_current_media.elapsed_time = 18
3612 assert player._attr_current_media.elapsed_time_last_updated is not None
3613 player._attr_current_media.elapsed_time_last_updated += 1
3614 player.update_state()
3615
3616 assert not self._player_updated_signalled(mock_mass)
3617 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
3618 # the previous anchor was preserved: steady playback changes nothing
3619 assert player.state.elapsed_time == 17
3620
3621 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
3622 """An anchor-only change (no significant corrected position change) emits nothing."""
3623 _controller, player = self._make_player(mock_mass)
3624 assert player._attr_current_media is not None
3625 assert player._attr_elapsed_time_last_updated is not None
3626
3627 player._attr_elapsed_time_last_updated += 0.5
3628 assert player._attr_current_media.elapsed_time_last_updated is not None
3629 player._attr_current_media.elapsed_time_last_updated += 0.5
3630 player.update_state()
3631
3632 assert not self._player_updated_signalled(mock_mass)
3633 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
3634
3635 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
3636 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
3637 _controller, player = self._make_player(mock_mass)
3638 assert player._attr_current_media is not None
3639
3640 player._attr_current_media.elapsed_time = 61
3641 player._attr_current_media.elapsed_time_last_updated = time.time()
3642 player.update_state()
3643
3644 assert self._player_updated_signalled(mock_mass)
3645 # the adopted anchor is visible to consumers
3646 assert player.state.current_media is not None
3647 assert player.state.current_media.elapsed_time == 61
3648
3649 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
3650 """A player-level corrected-position jump re-bases the queue timing."""
3651 controller, player = self._make_player(mock_mass)
3652
3653 player._attr_elapsed_time = 61
3654 player._attr_elapsed_time_last_updated = time.time()
3655 player.update_state()
3656
3657 # the queue is corrected and a follow-up player update is scheduled
3658 # (which re-anchors current_media onto the corrected queue time),
3659 # but no full player update is emitted for the jump itself
3660 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3661 assert not self._player_updated_signalled(mock_mass)
3662 cast("MagicMock", controller._forward_state_update).assert_called_once()
3663 assert player.state.elapsed_time == 61
3664
3665 def test_simultaneous_player_and_media_jump_emits_immediately(
3666 self, mock_mass: MagicMock
3667 ) -> None:
3668 """A jump reaching player and current_media in one pass corrects the queue and emits."""
3669 _controller, player = self._make_player(mock_mass)
3670 assert player._attr_current_media is not None
3671 now = time.time()
3672
3673 player._attr_elapsed_time = 61
3674 player._attr_elapsed_time_last_updated = now
3675 player._attr_current_media.elapsed_time = 61
3676 player._attr_current_media.elapsed_time_last_updated = now
3677 player.update_state()
3678
3679 # the queue is re-based AND the full update is emitted right away
3680 # (current_media already holds the fresh position in the same pass)
3681 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3682 assert self._player_updated_signalled(mock_mass)
3683
3684
3685class TestPlayAnnouncementCleanup:
3686 """Test announcement data cleanup after play_announcement."""
3687
3688 def _make_player(
3689 self, mock_mass: MagicMock, announcements: dict[str, object]
3690 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
3691 """Create a controller and a player with native announcement support."""
3692 controller = PlayerController(mock_mass)
3693 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3694 player = MockPlayer(provider, "player_1", "Player 1")
3695 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3696 player._cache.clear()
3697 controller._players = {"player_1": player}
3698 mock_mass.players = controller
3699 render = MagicMock()
3700 render.wait_ready = AsyncMock(return_value=True)
3701 render.wait_finished = AsyncMock(return_value=3.0)
3702
3703 # mimic the real renderer: it owns which announcement each player is playing
3704 def _register(player_id: str, announce_data: object) -> MagicMock:
3705 announcements[player_id] = announce_data
3706 return render
3707
3708 async def _unregister(player_id: str, _render: object) -> None:
3709 announcements.pop(player_id, None)
3710
3711 renderer = mock_mass.streams.announcement_renderer
3712 renderer.register = MagicMock(side_effect=_register)
3713 renderer.unregister = AsyncMock(side_effect=_unregister)
3714 mock_mass.streams.get_announcement_url = MagicMock(
3715 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
3716 )
3717 player.update_state(signal_event=False)
3718 return controller, player, render
3719
3720 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
3721 """The registered announcement data is released once playback finished."""
3722 announcements: dict[str, object] = {}
3723 controller, player, _render = self._make_player(mock_mass, announcements)
3724
3725 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
3726 # entry must exist while the announcement is being played/served
3727 assert "player_1" in announcements
3728
3729 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
3730
3731 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3732
3733 player.play_announcement.assert_awaited_once()
3734 assert announcements == {}
3735 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3736
3737 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
3738 """The registered announcement data is released even when playback fails."""
3739 announcements: dict[str, object] = {}
3740 controller, player, _render = self._make_player(mock_mass, announcements)
3741 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
3742
3743 with pytest.raises(PlayerCommandFailed):
3744 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3745
3746 assert announcements == {}
3747 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3748
3749 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
3750 """A native implementation is handed the url as soon as there is audio to serve."""
3751 announcements: dict[str, object] = {}
3752 controller, player, render = self._make_player(mock_mass, announcements)
3753 player.play_announcement = AsyncMock() # type: ignore[method-assign]
3754
3755 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3756
3757 player.play_announcement.assert_awaited_once()
3758 # waiting for the whole clip here would delay the player for a slow source;
3759 # the length is resolved downstream while it plays
3760 render.wait_ready.assert_awaited_once()
3761 render.wait_finished.assert_not_awaited()
3762
3763
3764class _AnnounceSetup(NamedTuple):
3765 """A player announcing through a linked protocol output, with the calls it makes mocked."""
3766
3767 controller: PlayerController
3768 parent: MockPlayer
3769 output: MockPlayer
3770 volume_set: AsyncMock
3771 play_announcement: AsyncMock
3772
3773
3774@pytest.mark.usefixtures("running_background_tasks")
3775class TestNativeAnnouncementVolumeRouting:
3776 """The announcement volume is applied through the control that owns it."""
3777
3778 ANNOUNCE_VOLUME = 45
3779
3780 def _make_setup(self, mock_mass: MagicMock, volume_control: str) -> _AnnounceSetup:
3781 """
3782 Create a player announcing through a linked protocol output.
3783
3784 The parent holds a sibling interface and a bridge riding on the announcing
3785 output, so any of them can be named as its volume control.
3786
3787 :param volume_control: Value of the parent's volume control config entry.
3788 """
3789 controller = PlayerController(mock_mass)
3790 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3791 parent = MockPlayer(provider, "parent", "Parent")
3792 output = MockPlayer(provider, "output", "Output")
3793 output._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3794 output._attr_supported_features.add(PlayerFeature.VOLUME_SET)
3795 sibling = MockPlayer(provider, "sibling", "Sibling")
3796 sibling._attr_supported_features.add(PlayerFeature.VOLUME_SET)
3797 sibling._attr_volume_level = 20
3798 bridge = MockPlayer(provider, "bridge", "Bridge")
3799 bridge._attr_supported_features.add(PlayerFeature.VOLUME_SET)
3800 bridge._attr_underlying_player_id = "output"
3801 bridge._attr_volume_level = 20
3802 controller._players = {
3803 "parent": parent,
3804 "output": output,
3805 "sibling": sibling,
3806 "bridge": bridge,
3807 }
3808 # an external control (e.g. a Home Assistant volume entity) is not a player
3809 controller._controls = {
3810 "ha_volume": PlayerControl(
3811 id="ha_volume",
3812 provider="hass",
3813 name="Amplifier volume",
3814 supports_volume=True,
3815 volume_level=20,
3816 )
3817 }
3818 mock_mass.players = controller
3819 mock_mass.config.get_raw_player_config_value = MagicMock(
3820 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: volume_control})
3821 )
3822 for player in controller._players.values():
3823 player._cache.clear()
3824 player.update_state(signal_event=False)
3825 play_announcement = AsyncMock()
3826 volume_set = AsyncMock()
3827 output.play_announcement = play_announcement # type: ignore[method-assign]
3828 controller._handle_cmd_volume_set = volume_set # type: ignore[method-assign]
3829 return _AnnounceSetup(controller, parent, output, volume_set, play_announcement)
3830
3831 async def _announce(self, setup: _AnnounceSetup) -> None:
3832 """Play an announcement on the parent, rendered by the linked output."""
3833 await setup.controller._play_native_announcement(
3834 setup.parent, setup.output, _announcement(), self.ANNOUNCE_VOLUME
3835 )
3836
3837 async def test_external_control_applies_the_announcement_volume(
3838 self, mock_mass: MagicMock
3839 ) -> None:
3840 """A sibling interface owning the volume gets the announcement volume, not the output."""
3841 setup = self._make_setup(mock_mass, "sibling")
3842
3843 await self._announce(setup)
3844
3845 # the output cannot attenuate what another control is already attenuating,
3846 # so the level goes through that control and the output announces at unity
3847 assert setup.volume_set.await_args_list == [
3848 call("parent", self.ANNOUNCE_VOLUME),
3849 call("parent", 20),
3850 ]
3851 setup.play_announcement.assert_awaited_once_with(ANY, None)
3852
3853 async def test_player_control_applies_the_announcement_volume(
3854 self, mock_mass: MagicMock
3855 ) -> None:
3856 """A player control owning the volume (e.g. an HA entity) gets the announcement volume."""
3857 setup = self._make_setup(mock_mass, "ha_volume")
3858
3859 await self._announce(setup)
3860
3861 assert setup.volume_set.await_args_list == [
3862 call("parent", self.ANNOUNCE_VOLUME),
3863 call("parent", 20),
3864 ]
3865 setup.play_announcement.assert_awaited_once_with(ANY, None)
3866
3867 async def test_external_control_volume_restored_when_the_announcement_fails(
3868 self, mock_mass: MagicMock
3869 ) -> None:
3870 """The temporary volume is restored even when the announcement itself fails."""
3871 setup = self._make_setup(mock_mass, "sibling")
3872 setup.play_announcement.side_effect = RuntimeError("boom")
3873
3874 with pytest.raises(RuntimeError):
3875 await self._announce(setup)
3876
3877 assert setup.volume_set.await_args_list[-1] == call("parent", 20)
3878
3879 async def test_announcing_output_keeps_the_announcement_volume(
3880 self, mock_mass: MagicMock
3881 ) -> None:
3882 """An output that owns the volume applies the announcement volume itself."""
3883 setup = self._make_setup(mock_mass, "output")
3884
3885 await self._announce(setup)
3886
3887 setup.volume_set.assert_not_awaited()
3888 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3889
3890 async def test_bridge_on_the_announcing_output_keeps_the_announcement_volume(
3891 self, mock_mass: MagicMock
3892 ) -> None:
3893 """A bridge riding on the announcing output forwards the volume to it."""
3894 setup = self._make_setup(mock_mass, "bridge")
3895
3896 await self._announce(setup)
3897
3898 setup.volume_set.assert_not_awaited()
3899 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3900
3901 async def test_native_volume_is_applied_through_the_parent(self, mock_mass: MagicMock) -> None:
3902 """
3903 A native volume lives on the parent, so the parent applies and restores it.
3904
3905 The rendering output has no way to reach a native parent volume, and its own
3906 idea of the level can be stale, so routing through the parent keeps both the
3907 announcement level and the restore on the control that actually knows it.
3908 """
3909 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
3910 setup.parent._attr_volume_level = 20
3911 setup.parent._cache.clear()
3912 setup.parent.update_state(signal_event=False)
3913
3914 await self._announce(setup)
3915
3916 assert setup.volume_set.await_args_list == [
3917 call("parent", self.ANNOUNCE_VOLUME),
3918 call("parent", 20),
3919 ]
3920 setup.play_announcement.assert_awaited_once_with(ANY, None)
3921
3922 async def test_native_volume_on_its_own_output_is_kept_by_the_player(
3923 self, mock_mass: MagicMock
3924 ) -> None:
3925 """A player announcing on its own output can apply its native volume itself."""
3926 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
3927 play_announcement = AsyncMock()
3928 setup.parent.play_announcement = play_announcement # type: ignore[method-assign]
3929
3930 await setup.controller._play_native_announcement(
3931 setup.parent, setup.parent, _announcement(), self.ANNOUNCE_VOLUME
3932 )
3933
3934 setup.volume_set.assert_not_awaited()
3935 play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3936
3937 async def test_without_volume_control_no_volume_is_applied(self, mock_mass: MagicMock) -> None:
3938 """Nothing in the signal path can set a volume, so the announcement plays as-is."""
3939 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NONE)
3940
3941 await self._announce(setup)
3942
3943 setup.volume_set.assert_not_awaited()
3944 setup.play_announcement.assert_awaited_once_with(ANY, None)
3945
3946
3947class TestPlayAnnouncementMessage:
3948 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
3949
3950 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
3951
3952 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
3953 """Create a TTS engine that renders every message to the given path."""
3954 engine = MagicMock()
3955 engine.uid = "tts_plugin/voice"
3956 engine.id = "voice"
3957 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
3958 return engine
3959
3960 def _make_player(
3961 self, mock_mass: MagicMock, announcements: dict[str, object]
3962 ) -> tuple[PlayerController, AsyncMock]:
3963 """Create a controller and a player with native announcement support."""
3964 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
3965 mock_mass, announcements
3966 )
3967 announce = AsyncMock()
3968 player.play_announcement = announce # type: ignore[method-assign]
3969 return controller, announce
3970
3971 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
3972 """A message is rendered by the default engine and announced as the rendered audio."""
3973 announcements: dict[str, object] = {}
3974 controller, announce = self._make_player(mock_mass, announcements)
3975 engine = self._make_engine()
3976
3977 with patch(
3978 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3979 ):
3980 await controller.play_announcement("player_1", message="dinner is ready")
3981
3982 # no language is sent, so the engine speaks in the language it is configured for
3983 engine.provider.get_tts_message.assert_awaited_once_with(
3984 "dinner is ready", language=None, engine_id="voice", options=None
3985 )
3986 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3987 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3988 announce.assert_awaited_once()
3989
3990 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
3991 """A message can name the language to speak it in."""
3992 announcements: dict[str, object] = {}
3993 controller, _announce = self._make_player(mock_mass, announcements)
3994 engine = self._make_engine()
3995
3996 with patch(
3997 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3998 ):
3999 await controller.play_announcement(
4000 "player_1", message="het eten is klaar", language="nl-NL"
4001 )
4002
4003 engine.provider.get_tts_message.assert_awaited_once_with(
4004 "het eten is klaar", language="nl-NL", engine_id="voice", options=None
4005 )
4006
4007 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
4008 """An engine that rejects the language speaks the message in its default voice."""
4009 announcements: dict[str, object] = {}
4010 controller, announce = self._make_player(mock_mass, announcements)
4011 engine = self._make_engine()
4012 engine.provider.get_tts_message = AsyncMock(
4013 side_effect=[
4014 RuntimeError("unsupported language"),
4015 SimpleNamespace(path="http://speech/spoken.mp3"),
4016 ]
4017 )
4018
4019 with patch(
4020 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4021 ):
4022 await controller.play_announcement(
4023 "player_1", message="dinner is ready", language="en-US"
4024 )
4025
4026 first_call, second_call = engine.provider.get_tts_message.await_args_list
4027 assert first_call.kwargs["language"] == "en-US"
4028 assert second_call.kwargs["language"] is None
4029 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4030 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4031 announce.assert_awaited_once()
4032
4033 @pytest.mark.parametrize(
4034 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
4035 )
4036 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
4037 self, mock_mass: MagicMock, error: Exception
4038 ) -> None:
4039 """A timeout or a structured failure is no language rejection, so it is not retried."""
4040 announcements: dict[str, object] = {}
4041 controller, announce = self._make_player(mock_mass, announcements)
4042 engine = self._make_engine()
4043 engine.provider.get_tts_message = AsyncMock(side_effect=error)
4044
4045 with (
4046 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4047 pytest.raises(MusicAssistantError),
4048 ):
4049 await controller.play_announcement("player_1", message="dinner is ready")
4050
4051 engine.provider.get_tts_message.assert_awaited_once()
4052 announce.assert_not_awaited()
4053
4054 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
4055 """A message names the engine to speak it, overriding the configured default."""
4056 announcements: dict[str, object] = {}
4057 controller, _announce = self._make_player(mock_mass, announcements)
4058 engine = self._make_engine()
4059
4060 with (
4061 patch(
4062 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
4063 ) as resolve,
4064 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
4065 ):
4066 await controller.play_announcement(
4067 "player_1", message="hello", tts_engine="tts_plugin/voice"
4068 )
4069
4070 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
4071 select.assert_not_awaited()
4072
4073 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
4074 """A spoken message uses the player's pre-announce setting without sniffing the url."""
4075 announcements: dict[str, object] = {}
4076 controller, _announce = self._make_player(mock_mass, announcements)
4077 engine = self._make_engine()
4078 mock_mass.config.get_raw_player_config_value = MagicMock(
4079 side_effect=lambda _player_id, key, default=None: (
4080 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
4081 )
4082 )
4083
4084 with patch(
4085 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4086 ):
4087 await controller.play_announcement("player_1", message="dinner is ready")
4088
4089 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4090 assert registered["pre_announce"] is True
4091
4092 async def test_the_engine_gets_the_shorter_announcement_timeout(
4093 self, mock_mass: MagicMock
4094 ) -> None:
4095 """The engine is capped well below the background default, it holds the player lock."""
4096 announcements: dict[str, object] = {}
4097 controller, _announce = self._make_player(mock_mass, announcements)
4098 engine = self._make_engine()
4099 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
4100
4101 with (
4102 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4103 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
4104 ):
4105 await controller.play_announcement("player_1", message="hello")
4106
4107 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
4108 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
4109
4110 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4111 """Naming an engine for a url announcement is rejected instead of silently ignored."""
4112 announcements: dict[str, object] = {}
4113 controller, _announce = self._make_player(mock_mass, announcements)
4114
4115 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
4116 await controller.play_announcement(
4117 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
4118 )
4119
4120 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4121 """Naming a language for a url announcement is rejected instead of silently ignored."""
4122 announcements: dict[str, object] = {}
4123 controller, _announce = self._make_player(mock_mass, announcements)
4124
4125 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
4126 await controller.play_announcement(
4127 "player_1", url="http://test/clip.mp3", language="nl-NL"
4128 )
4129
4130 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
4131 """An engine that fails to speak the message fails the announcement."""
4132 announcements: dict[str, object] = {}
4133 controller, announce = self._make_player(mock_mass, announcements)
4134 engine = self._make_engine()
4135 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
4136
4137 with (
4138 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4139 pytest.raises(PlayerCommandFailed),
4140 ):
4141 await controller.play_announcement("player_1", message="hello")
4142
4143 announce.assert_not_awaited()
4144 mock_mass.streams.announcement_renderer.register.assert_not_called()
4145
4146 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
4147 """An announcement with neither a url nor a message is rejected."""
4148 announcements: dict[str, object] = {}
4149 controller, _announce = self._make_player(mock_mass, announcements)
4150
4151 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
4152 await controller.play_announcement("player_1")
4153
4154 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
4155 """An announcement carrying both a url and a message is rejected."""
4156 announcements: dict[str, object] = {}
4157 controller, _announce = self._make_player(mock_mass, announcements)
4158
4159 with pytest.raises(PlayerCommandFailed, match="not both"):
4160 await controller.play_announcement(
4161 "player_1", url="http://test/clip.mp3", message="hello"
4162 )
4163
4164 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
4165 """A message naming an engine that does not exist fails instead of using another."""
4166 announcements: dict[str, object] = {}
4167 controller, _announce = self._make_player(mock_mass, announcements)
4168
4169 with (
4170 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
4171 pytest.raises(PlayerCommandFailed, match="is not available"),
4172 ):
4173 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
4174
4175 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
4176 """A message fails clearly when no TTS engine is set up at all."""
4177 announcements: dict[str, object] = {}
4178 controller, _announce = self._make_player(mock_mass, announcements)
4179
4180 with (
4181 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
4182 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
4183 ):
4184 await controller.play_announcement("player_1", message="hello")
4185
4186 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
4187 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
4188 announcements: dict[str, object] = {}
4189 controller, _announce = self._make_player(mock_mass, announcements)
4190 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
4191
4192 with (
4193 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4194 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
4195 ):
4196 await controller.play_announcement("player_1", message="hello")
4197
4198 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
4199 """The message is spoken once and every group member announces the resulting audio."""
4200 announcements: dict[str, object] = {}
4201 use_real_create_task(mock_mass)
4202 controller, _announce = self._make_player(mock_mass, announcements)
4203 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4204 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
4205 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4206 group._attr_group_members = ["player_1"]
4207 group._cache.clear()
4208 controller._players["group_1"] = group
4209 group.update_state(signal_event=False)
4210 engine = self._make_engine()
4211
4212 with patch(
4213 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4214 ):
4215 await controller.play_announcement("group_1", message="dinner is ready")
4216
4217 # rendered once for the group, then handed to the member as plain audio
4218 engine.provider.get_tts_message.assert_awaited_once()
4219 member_call = next(
4220 call_args
4221 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
4222 if call_args.args[0] == "player_1"
4223 )
4224 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
4225
4226
4227class TestNativeAnnouncementRouting:
4228 """Announcement routing respects the player's own support and its active output."""
4229
4230 def _make_player_with_linked_child(
4231 self,
4232 mock_mass: MagicMock,
4233 playback_state: PlaybackState,
4234 *,
4235 parent_supports_announce: bool = False,
4236 active_protocol: str | None = None,
4237 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
4238 """Create a controller, a player, its linked protocol child and the two path mocks."""
4239 controller = PlayerController(mock_mass)
4240 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4241 player = MockPlayer(provider, "player_1", "Player 1")
4242 player._attr_playback_state = playback_state
4243 if parent_supports_announce:
4244 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4245 proto_provider = MockProvider("airplay", mass=mock_mass)
4246 proto = MockPlayer(
4247 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
4248 )
4249 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4250 controller._players = {"player_1": player, "proto_1": proto}
4251 mock_mass.players = controller
4252 player.set_linked_output_protocols(
4253 [
4254 LinkedOutputProtocol(
4255 output_protocol_id="proto_1",
4256 protocol_domain="airplay",
4257 priority=40,
4258 )
4259 ]
4260 )
4261 if active_protocol is not None:
4262 player.set_active_output_protocol(active_protocol)
4263 render = MagicMock()
4264 render.wait_ready = AsyncMock(return_value=True)
4265 renderer = mock_mass.streams.announcement_renderer
4266 renderer.register = MagicMock(return_value=render)
4267 renderer.unregister = AsyncMock()
4268 mock_mass.streams.get_announcement_url = MagicMock(
4269 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
4270 )
4271 proto.update_state(signal_event=False)
4272 player.update_state(signal_event=False)
4273 native_path = AsyncMock()
4274 generic_path = AsyncMock()
4275 controller._play_native_announcement = native_path # type: ignore[method-assign]
4276 controller._play_announcement = generic_path # type: ignore[method-assign]
4277 return controller, player, proto, native_path, generic_path
4278
4279 async def test_playing_player_does_not_route_to_an_idle_linked_child(
4280 self, mock_mass: MagicMock
4281 ) -> None:
4282 """
4283 A player rendering through one output must not announce through another.
4284
4285 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
4286 the announcement to the child would seize the device from the native
4287 output, with nothing restoring that playback afterwards.
4288 """
4289 controller, _player, _proto, native_path, generic_path = (
4290 self._make_player_with_linked_child(
4291 mock_mass, PlaybackState.PLAYING, active_protocol="native"
4292 )
4293 )
4294
4295 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4296
4297 native_path.assert_not_awaited()
4298 generic_path.assert_awaited_once()
4299
4300 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
4301 """An idle player announces natively through any capable linked protocol."""
4302 controller, _player, proto, native_path, _generic_path = (
4303 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
4304 )
4305
4306 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4307
4308 native_path.assert_awaited_once()
4309 assert native_path.call_args.args[1] is proto
4310
4311 async def test_active_protocol_child_beats_own_native_support(
4312 self, mock_mass: MagicMock
4313 ) -> None:
4314 """
4315 The output that is actively rendering wins over the player's own support.
4316
4317 E.g. a Sonos playing through its AirPlay child: the announcement rides
4318 the same audio path as the music (mixed into the live stream, in sync
4319 with the rest of a group) instead of a second mechanism firing beside
4320 the playback.
4321 """
4322 controller, _player, proto, native_path, _generic_path = (
4323 self._make_player_with_linked_child(
4324 mock_mass,
4325 PlaybackState.PLAYING,
4326 parent_supports_announce=True,
4327 active_protocol="proto_1",
4328 )
4329 )
4330
4331 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4332
4333 native_path.assert_awaited_once()
4334 assert native_path.call_args.args[1] is proto
4335
4336 async def test_own_native_support_wins_when_playing_natively(
4337 self, mock_mass: MagicMock
4338 ) -> None:
4339 """A player rendering through its own native output announces natively."""
4340 controller, player, _proto, native_path, _generic_path = (
4341 self._make_player_with_linked_child(
4342 mock_mass,
4343 PlaybackState.PLAYING,
4344 parent_supports_announce=True,
4345 active_protocol="native",
4346 )
4347 )
4348
4349 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4350
4351 native_path.assert_awaited_once()
4352 assert native_path.call_args.args[1] is player
4353
4354 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
4355 self, mock_mass: MagicMock
4356 ) -> None:
4357 """Without active playback the player's own announcement support wins."""
4358 controller, player, _proto, native_path, _generic_path = (
4359 self._make_player_with_linked_child(
4360 mock_mass,
4361 PlaybackState.IDLE,
4362 parent_supports_announce=True,
4363 )
4364 )
4365
4366 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4367
4368 native_path.assert_awaited_once()
4369 assert native_path.call_args.args[1] is player
4370
4371
4372@pytest.mark.usefixtures("running_background_tasks")
4373class TestPlayAnnouncementRestore:
4374 """Test the state restore of the default (fallback) announcement implementation."""
4375
4376 def _make_player(
4377 self, mock_mass: MagicMock, prev_media: PlayerMedia
4378 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4379 """Create a controller and a playing player, returning the patched resume handler."""
4380 controller = PlayerController(mock_mass)
4381 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4382 player = MockPlayer(provider, "player_1", "Player 1")
4383 player._attr_playback_state = PlaybackState.PLAYING
4384 player._attr_current_media = prev_media
4385 player._cache.clear()
4386 controller._players = {"player_1": player}
4387 mock_mass.players = controller
4388 mock_mass.player_queues.get = MagicMock(return_value=None)
4389 resume_mock = AsyncMock()
4390 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
4391 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
4392 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
4393 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
4394 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4395 player.set_initialized()
4396 player.update_state(signal_event=False)
4397 return controller, player, resume_mock
4398
4399 @staticmethod
4400 def _add_group(
4401 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
4402 ) -> MockPlayer:
4403 """Register a powered group player that holds the given player as its member."""
4404 group = MockPlayer(
4405 cast("MockProvider", player.provider),
4406 "group_1",
4407 "Group 1",
4408 player_type=PlayerType.GROUP,
4409 )
4410 group._attr_powered = True
4411 group._attr_group_members = [player.player_id]
4412 if supports_set_members:
4413 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4414 controller._players[group.player_id] = group
4415 group.set_initialized()
4416 group._cache.clear()
4417 group.update_state(signal_event=False)
4418 # the member has no own input change to trigger a recalculation of its
4419 # (group derived) state, so force it here - just like register() does
4420 player._cache.clear()
4421 player.update_state(force_update=True, signal_event=False)
4422 assert player.state.active_group == group.player_id
4423 return group
4424
4425 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
4426 """Content that was playing before the announcement is resumed afterwards."""
4427 controller, player, resume_mock = self._make_player(
4428 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4429 )
4430
4431 await controller._play_announcement(player, _announcement())
4432
4433 resume_mock.assert_awaited_once()
4434
4435 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
4436 """A player still busy with an earlier announcement has no playback to restore."""
4437 controller, player, resume_mock = self._make_player(
4438 mock_mass,
4439 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
4440 )
4441
4442 await controller._play_announcement(player, _announcement())
4443
4444 resume_mock.assert_not_awaited()
4445
4446 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4447 """A failing announcement never leaves the player at the raised volume."""
4448 controller, player, _ = self._make_player(
4449 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4450 )
4451 player._attr_volume_level = 20
4452 player._cache.clear()
4453 player.update_state(force_update=True, signal_event=False)
4454 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4455 volume_mock = AsyncMock()
4456 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4457 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4458 side_effect=PlayerCommandFailed("player went away")
4459 )
4460
4461 with pytest.raises(PlayerCommandFailed):
4462 await controller._play_announcement(player, _announcement())
4463
4464 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
4465
4466 async def test_zero_announcement_volume_is_applied_and_restored(
4467 self, mock_mass: MagicMock
4468 ) -> None:
4469 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
4470 controller, player, _ = self._make_player(
4471 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4472 )
4473 player._attr_volume_level = 20
4474 player._cache.clear()
4475 player.update_state(force_update=True, signal_event=False)
4476 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
4477 volume_mock = AsyncMock()
4478 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4479
4480 await controller._play_announcement(player, _announcement())
4481
4482 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
4483
4484 async def test_playback_is_restored_when_duration_is_unknown(
4485 self, mock_mass: MagicMock
4486 ) -> None:
4487 """An announcement of unknown length still hands the player back to its content."""
4488 controller, player, resume_mock = self._make_player(
4489 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4490 )
4491 announcement = _announcement()
4492 announcement.duration = None
4493
4494 # an unknown length waits for the player to report it finished instead of failing
4495 await controller._play_announcement(player, announcement)
4496
4497 resume_mock.assert_awaited_once()
4498
4499 async def test_group_membership_is_restored_when_playback_fails(
4500 self, mock_mass: MagicMock
4501 ) -> None:
4502 """A failing announcement never leaves the player out of its group player."""
4503 controller, player, _ = self._make_player(
4504 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4505 )
4506 group = self._add_group(controller, player, supports_set_members=True)
4507 group.set_members = AsyncMock() # type: ignore[method-assign]
4508 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4509 side_effect=PlayerCommandFailed("player went away")
4510 )
4511
4512 with pytest.raises(PlayerCommandFailed):
4513 await controller._play_announcement(player, _announcement())
4514
4515 assert group.set_members.await_args_list == [
4516 call(player_ids_to_remove=["player_1"]),
4517 call(player_ids_to_add=["player_1"]),
4518 ]
4519
4520 async def test_restore_failure_does_not_mask_the_announcement_error(
4521 self, mock_mass: MagicMock
4522 ) -> None:
4523 """A provider blowing up during the restore must not hide why the announcement failed."""
4524 controller, player, _ = self._make_player(
4525 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4526 )
4527 group = self._add_group(controller, player, supports_set_members=True)
4528 # set_members is a raw provider call: whatever its client library raises comes
4529 # through unwrapped, so the ungroup succeeds and the regroup times out
4530 group.set_members = AsyncMock( # type: ignore[method-assign]
4531 side_effect=[None, TimeoutError("provider timeout")]
4532 )
4533 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4534 side_effect=PlayerCommandFailed("player went away")
4535 )
4536
4537 with pytest.raises(PlayerCommandFailed, match="player went away"):
4538 await controller._play_announcement(player, _announcement())
4539
4540 async def test_group_without_set_members_is_the_one_powered_off(
4541 self, mock_mass: MagicMock
4542 ) -> None:
4543 """A group that can not release members is powered off, not the announcement target."""
4544 controller, player, _ = self._make_player(
4545 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4546 )
4547 self._add_group(controller, player, supports_set_members=False)
4548 power_mock = AsyncMock()
4549 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
4550 play_mock = AsyncMock()
4551 controller.cmd_play = play_mock # type: ignore[method-assign]
4552
4553 await controller._play_announcement(player, _announcement())
4554
4555 # the group is switched off for the announcement and restarted afterwards
4556 power_mock.assert_awaited_once_with("group_1", False)
4557 play_mock.assert_awaited_once_with("group_1")
4558
4559 async def test_idle_player_without_power_control_is_regrouped(
4560 self, mock_mass: MagicMock
4561 ) -> None:
4562 """An idle player that has no power state to restore is still put back in its group."""
4563 controller, player, _ = self._make_player(
4564 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4565 )
4566 player._attr_playback_state = PlaybackState.IDLE
4567 player._attr_powered = None
4568 group = self._add_group(controller, player, supports_set_members=True)
4569 assert player.state.power_control == PLAYER_CONTROL_NONE
4570 group.set_members = AsyncMock() # type: ignore[method-assign]
4571
4572 await controller._play_announcement(player, _announcement())
4573
4574 assert group.set_members.await_args_list == [
4575 call(player_ids_to_remove=["player_1"]),
4576 call(player_ids_to_add=["player_1"]),
4577 ]
4578
4579 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4580 """A muted player hears the announcement and is muted again afterwards."""
4581 controller, player, _ = self._make_player(
4582 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4583 )
4584 mute_mock = _mute_natively(player)
4585
4586 await controller._play_announcement(player, _announcement())
4587
4588 assert mute_mock.await_args_list == [call(False), call(True)]
4589
4590 async def test_player_without_volume_control_is_still_unmuted(
4591 self, mock_mass: MagicMock
4592 ) -> None:
4593 """A player that can only be muted is unmuted for the announcement all the same."""
4594 mock_mass.config.get_raw_player_config_value = MagicMock(
4595 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
4596 )
4597 controller, player, _ = self._make_player(
4598 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4599 )
4600 mute_mock = _mute_natively(player)
4601 assert player.state.volume_control == PLAYER_CONTROL_NONE
4602
4603 await controller._play_announcement(player, _announcement())
4604
4605 assert mute_mock.await_args_list == [call(False), call(True)]
4606
4607 async def test_mute_is_restored_before_the_player_is_regrouped(
4608 self, mock_mass: MagicMock
4609 ) -> None:
4610 """A player is handed back to its group already muted, holding on to its mute lock."""
4611 controller, player, _ = self._make_player(
4612 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4613 )
4614 group = self._add_group(controller, player, supports_set_members=True)
4615 real_set_members = group.set_members
4616
4617 async def _set_members(**kwargs: list[str]) -> None:
4618 # let the membership really change, so the player is ungrouped while the
4619 # announcement plays - just like it is in production. neither player picks
4620 # the new membership up on its own here, so publish it on both.
4621 await real_set_members(**kwargs)
4622 group.update_state(force_update=True, signal_event=False)
4623 player._cache.clear()
4624 player.update_state(force_update=True, signal_event=False)
4625
4626 set_members = AsyncMock(side_effect=_set_members)
4627 group.set_members = set_members # type: ignore[method-assign]
4628 player.extra_data[ATTR_MUTE_LOCK] = True
4629 recorder = MagicMock()
4630 recorder.attach_mock(_mute_natively(player), "mute")
4631 recorder.attach_mock(set_members, "set_members")
4632
4633 await controller._play_announcement(player, _announcement())
4634
4635 assert recorder.mock_calls == [
4636 call.set_members(player_ids_to_remove=["player_1"]),
4637 call.mute(False),
4638 call.mute(True),
4639 call.set_members(player_ids_to_add=["player_1"]),
4640 ]
4641 # the lock survives the announcement, so the regroup does not unmute the player
4642 assert player.extra_data[ATTR_MUTE_LOCK] is True
4643
4644 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4645 """A player that was not muted is never sent a mute command."""
4646 controller, player, _ = self._make_player(
4647 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4648 )
4649 mute_mock = _mute_natively(player)
4650 player._attr_volume_muted = False
4651 player._cache.clear()
4652 player.update_state(force_update=True, signal_event=False)
4653 mute_mock.reset_mock()
4654
4655 await controller._play_announcement(player, _announcement())
4656
4657 mute_mock.assert_not_awaited()
4658
4659 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4660 """A failing announcement never leaves the player unmuted."""
4661 controller, player, _ = self._make_player(
4662 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4663 )
4664 mute_mock = _mute_natively(player)
4665 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4666 side_effect=PlayerCommandFailed("player went away")
4667 )
4668
4669 with pytest.raises(PlayerCommandFailed):
4670 await controller._play_announcement(player, _announcement())
4671
4672 assert mute_mock.await_args_list == [call(False), call(True)]
4673
4674 async def test_muted_sync_group_members_all_hear_the_announcement(
4675 self, mock_mass: MagicMock
4676 ) -> None:
4677 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4678 controller, leader, _ = self._make_player(
4679 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4680 )
4681 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4682 controller._players["player_2"] = member
4683 member.set_initialized()
4684 leader._attr_group_members = ["player_1", "player_2"]
4685 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4686 # both members were muted while grouped, so both hold a mute lock
4687 for player in (leader, member):
4688 player.extra_data[ATTR_MUTE_LOCK] = True
4689
4690 await controller._play_announcement(leader, _announcement())
4691
4692 for player_id, mute_mock in mute_mocks.items():
4693 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4694 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4695
4696 async def test_fake_muted_player_announces_at_its_real_volume(
4697 self, mock_mass: MagicMock
4698 ) -> None:
4699 """A fake muted player announces at its real volume, not at the zero it is parked on."""
4700 mock_mass.config.get_raw_player_config_value = MagicMock(
4701 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4702 )
4703 controller, player, _ = self._make_player(
4704 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4705 )
4706
4707 def _apply_volume(volume: int) -> None:
4708 player._attr_volume_level = volume
4709 player.update_state(signal_event=False)
4710
4711 player._attr_volume_level = 40
4712 volume_set = AsyncMock(side_effect=_apply_volume)
4713 player.volume_set = volume_set # type: ignore[method-assign]
4714 player._cache.clear()
4715 player.update_state(force_update=True, signal_event=False)
4716 await controller.cmd_volume_mute("player_1", True)
4717 assert player.state.volume_muted is True
4718 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4719
4720 await controller._play_announcement(player, _announcement())
4721
4722 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
4723 assert volume_set.await_args_list == [
4724 call(0),
4725 call(40),
4726 call(80),
4727 call(40),
4728 call(0),
4729 ]
4730 assert player.state.volume_muted is True
4731 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4732
4733
4734@pytest.mark.usefixtures("running_background_tasks")
4735class TestPlayNativeAnnouncement:
4736 """Test the mute handling around an announcement that a player plays natively."""
4737
4738 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4739 """Create a controller and a player with native announcement support."""
4740 controller = PlayerController(mock_mass)
4741 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4742 player = MockPlayer(provider, "player_1", "Player 1")
4743 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4744 player._cache.clear()
4745 controller._players = {"player_1": player}
4746 mock_mass.players = controller
4747 mock_mass.player_queues.get = MagicMock(return_value=None)
4748 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4749 announce_mock = AsyncMock()
4750 player.play_announcement = announce_mock # type: ignore[method-assign]
4751 player.set_initialized()
4752 player.update_state(signal_event=False)
4753 return controller, player, announce_mock
4754
4755 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4756 """A muted player hears the announcement and is muted again afterwards."""
4757 controller, player, announce_mock = self._make_player(mock_mass)
4758 recorder = MagicMock()
4759 recorder.attach_mock(_mute_natively(player), "mute")
4760 recorder.attach_mock(announce_mock, "announce")
4761
4762 await controller._play_native_announcement(player, player, _announcement(), None)
4763
4764 assert recorder.mock_calls == [
4765 call.mute(False),
4766 call.announce(ANY, None),
4767 call.mute(True),
4768 ]
4769
4770 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4771 """A player that was not muted is never sent a mute command."""
4772 controller, player, _ = self._make_player(mock_mass)
4773 mute_mock = _mute_natively(player)
4774 player._attr_volume_muted = False
4775 player._cache.clear()
4776 player.update_state(force_update=True, signal_event=False)
4777 mute_mock.reset_mock()
4778
4779 await controller._play_native_announcement(player, player, _announcement(), None)
4780
4781 mute_mock.assert_not_awaited()
4782
4783 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
4784 """A failing announcement never leaves the player unmuted."""
4785 controller, player, announce_mock = self._make_player(mock_mass)
4786 mute_mock = _mute_natively(player)
4787 announce_mock.side_effect = PlayerCommandFailed("player went away")
4788
4789 with pytest.raises(PlayerCommandFailed):
4790 await controller._play_native_announcement(player, player, _announcement(), None)
4791
4792 assert mute_mock.await_args_list == [call(False), call(True)]
4793
4794 async def test_muted_sync_group_members_all_hear_the_announcement(
4795 self, mock_mass: MagicMock
4796 ) -> None:
4797 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4798 controller, leader, _ = self._make_player(mock_mass)
4799 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4800 controller._players["player_2"] = member
4801 member.set_initialized()
4802 leader._attr_group_members = ["player_1", "player_2"]
4803 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4804 # both members were muted while grouped, so both hold a mute lock
4805 for player in (leader, member):
4806 player.extra_data[ATTR_MUTE_LOCK] = True
4807
4808 await controller._play_native_announcement(leader, leader, _announcement(), None)
4809
4810 for player_id, mute_mock in mute_mocks.items():
4811 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4812 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4813
4814 async def test_fake_muted_player_announces_at_its_real_volume(
4815 self, mock_mass: MagicMock
4816 ) -> None:
4817 """The announcement volume is resolved after the unmute, not from the parked zero."""
4818 mock_mass.config.get_raw_player_config_value = MagicMock(
4819 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4820 )
4821 controller, player, announce_mock = self._make_player(mock_mass)
4822
4823 def _apply_volume(volume: int) -> None:
4824 player._attr_volume_level = volume
4825 player.update_state(signal_event=False)
4826
4827 player._attr_volume_level = 40
4828 volume_set = AsyncMock(side_effect=_apply_volume)
4829 player.volume_set = volume_set # type: ignore[method-assign]
4830 player._cache.clear()
4831 player.update_state(force_update=True, signal_event=False)
4832 await controller.cmd_volume_mute("player_1", True)
4833 assert player.state.volume_muted is True
4834 # stand in for the configured strategy, which reads the volume of the player
4835 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
4836 side_effect=lambda _player_id, _volume_level: player.state.volume_level
4837 )
4838
4839 await controller._play_native_announcement(player, player, _announcement(), None)
4840
4841 assert announce_mock.await_args == call(ANY, 40)
4842 # unmute to 40 for the announcement, park back on 0 for the fake mute
4843 assert volume_set.await_args_list == [call(0), call(40), call(0)]
4844 assert player.state.volume_muted is True
4845 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4846
4847
4848class TestScheduleActiveOutputProtocolClear:
4849 """Test the deferred clear of a player's active output protocol."""
4850
4851 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
4852 """Scheduling defers the clear to a single, per-player, cancellable task."""
4853 controller = PlayerController(mock_mass)
4854 player = MagicMock()
4855 player.player_id = "player_1"
4856
4857 controller.schedule_active_output_protocol_clear(player)
4858
4859 mock_mass.create_task.assert_called_once()
4860 # close the coroutine passed to the mocked create_task to avoid a
4861 # "coroutine was never awaited" warning
4862 mock_mass.create_task.call_args.args[0].close()
4863 # no abort_existing: a duplicate schedule must reuse the pending clear
4864 # (deduped by task_id) instead of replacing it with an untracked task
4865 assert mock_mass.create_task.call_args.kwargs == {
4866 "task_id": "clear_active_protocol_player_1",
4867 }
4868
4869 @pytest.mark.asyncio
4870 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
4871 """The protocol is cleared after waiting for the player to reach IDLE."""
4872 controller = PlayerController(mock_mass)
4873 player = MagicMock()
4874 player.player_id = "player_1"
4875
4876 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
4877 await controller._clear_active_output_protocol_when_idle(player)
4878
4879 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
4880 player.set_active_output_protocol.assert_called_once_with(None)
4881
4882
4883@contextlib.asynccontextmanager
4884async def _skip_player_update_wait(
4885 *_args: object,
4886 **_kwargs: object,
4887) -> AsyncIterator[None]:
4888 """Skip provider-driven state propagation in command-routing tests."""
4889 yield
4890
4891
4892class TestRemovePlayerControl:
4893 """Test removing a registered player control."""
4894
4895 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
4896 """Test that only the players configured to use the removed control are refreshed."""
4897 mock_mass.loop = MagicMock()
4898 controller = PlayerController(mock_mass)
4899 using_control = MagicMock()
4900 using_control.state.power_control = "switch.amp"
4901 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
4902 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
4903 unrelated = MagicMock()
4904 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
4905 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
4906 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
4907 controller._players = {"using_control": using_control, "unrelated": unrelated}
4908 controller._controls = {
4909 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4910 }
4911
4912 controller.remove_player_control("switch.amp")
4913
4914 assert controller.player_controls() == []
4915 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
4916
4917 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
4918 """Test that a control removed and registered again re-attaches to its player."""
4919 # run the scheduled refresh straight away so each step is observable
4920 mock_mass.loop = MagicMock()
4921 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
4922 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
4923 {CONF_POWER_CONTROL: "switch.amp"}
4924 )
4925 controller = PlayerController(mock_mass)
4926 mock_mass.players = controller
4927 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4928 mock_mass.get_provider.return_value = provider
4929 player = MockPlayer(provider, "player", "Player")
4930 controller._players = {"player": player}
4931 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4932
4933 await controller.register_or_update_player_control(control)
4934 assert player.state.power_control == "switch.amp"
4935
4936 # the Home Assistant plugin drops and re-registers its controls around a reload
4937 controller.remove_player_control(control.id)
4938 assert player.state.power_control == PLAYER_CONTROL_NONE
4939
4940 await controller.register_or_update_player_control(control)
4941 assert player.state.power_control == "switch.amp"
4942
4943 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
4944 """Test that removing a control that was never registered is a no-op."""
4945 mock_mass.loop = MagicMock()
4946 controller = PlayerController(mock_mass)
4947 player = MagicMock()
4948 player.state.power_control = PLAYER_CONTROL_NATIVE
4949 player.state.volume_control = PLAYER_CONTROL_NATIVE
4950 player.state.mute_control = PLAYER_CONTROL_NATIVE
4951 controller._players = {"player": player}
4952
4953 controller.remove_player_control("switch.gone")
4954
4955 mock_mass.loop.call_soon.assert_not_called()
4956
4957
4958class _FailingTeardownPlayer(MockPlayer):
4959 """Player whose provider fails to release it."""
4960
4961 unloaded = False
4962
4963 async def on_unload(self) -> None:
4964 """Handle logic when the player is unloaded from the Player controller."""
4965 self.unloaded = True
4966 msg = "device is gone"
4967 raise RuntimeError(msg)
4968
4969
4970class TestUnregisterTeardown:
4971 """Test that a failing player teardown stays contained."""
4972
4973 async def test_failing_on_unload_still_unregisters_the_player(
4974 self, mock_mass: MagicMock
4975 ) -> None:
4976 """Test that a provider raising while releasing its player does not break unregister."""
4977 controller = PlayerController(mock_mass)
4978 mock_mass.players = controller
4979 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4980 player = _FailingTeardownPlayer(provider, "boom", "Boom")
4981 controller._players = {"boom": player}
4982
4983 await controller.unregister("boom")
4984
4985 assert "boom" not in controller._players
4986 assert player.unloaded
4987
4988
4989class TestConfigChangeRestartsPlayback:
4990 """Test that a changed player setting which needs a reload restarts playback."""
4991
4992 @staticmethod
4993 def _config(*, requires_reload: bool) -> PlayerConfig:
4994 """Build a PlayerConfig holding a single output codec entry."""
4995 return PlayerConfig(
4996 provider="test_prov",
4997 player_id="player_1",
4998 values={
4999 CONF_OUTPUT_CODEC: ConfigEntry(
5000 key=CONF_OUTPUT_CODEC,
5001 type=ConfigEntryType.STRING,
5002 label="Output codec",
5003 value="flac",
5004 requires_reload=requires_reload,
5005 )
5006 },
5007 )
5008
5009 @staticmethod
5010 def _prepare(mock_mass: MagicMock, queue_state: PlaybackState) -> PlayerController:
5011 """Register a player whose active queue is in the given state."""
5012 controller = PlayerController(mock_mass)
5013 player = MagicMock()
5014 player.state.active_source = "player_1"
5015 player.on_config_updated = AsyncMock()
5016 controller._players = {"player_1": player}
5017 queue = MagicMock()
5018 queue.queue_id = "player_1"
5019 queue.state = queue_state
5020 mock_mass.player_queues.get = MagicMock(return_value=queue)
5021 mock_mass.player_queues.stop = AsyncMock()
5022 return controller
5023
5024 async def test_reload_setting_restarts_playback(self, mock_mass: MagicMock) -> None:
5025 """Test that changing a reload-requiring setting stops and resumes the queue."""
5026 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5027
5028 await controller.on_player_config_change(
5029 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5030 )
5031
5032 mock_mass.player_queues.stop.assert_awaited_once_with("player_1")
5033 mock_mass.call_later.assert_called_once_with(
5034 1, mock_mass.player_queues.resume, "player_1", False
5035 )
5036
5037 async def test_plain_setting_does_not_restart_playback(self, mock_mass: MagicMock) -> None:
5038 """Test that a setting which applies on the fly leaves playback alone."""
5039 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5040
5041 await controller.on_player_config_change(
5042 self._config(requires_reload=False), {f"values/{CONF_OUTPUT_CODEC}"}
5043 )
5044
5045 mock_mass.player_queues.stop.assert_not_awaited()
5046 mock_mass.call_later.assert_not_called()
5047
5048 async def test_untouched_reload_setting_does_not_restart_playback(
5049 self, mock_mass: MagicMock
5050 ) -> None:
5051 """Test that only a changed reload-requiring setting restarts playback."""
5052 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5053
5054 await controller.on_player_config_change(
5055 self._config(requires_reload=True), {f"values/{CONF_ICON}"}
5056 )
5057
5058 mock_mass.player_queues.stop.assert_not_awaited()
5059 mock_mass.call_later.assert_not_called()
5060
5061 async def test_idle_queue_is_left_alone(self, mock_mass: MagicMock) -> None:
5062 """Test that a reload-requiring change does not start playback on an idle queue."""
5063 controller = self._prepare(mock_mass, PlaybackState.IDLE)
5064
5065 await controller.on_player_config_change(
5066 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5067 )
5068
5069 mock_mass.player_queues.stop.assert_not_awaited()
5070 mock_mass.call_later.assert_not_called()
5071
5072
5073if __name__ == "__main__":
5074 pytest.main([__file__, "-v"])
5075