/
/
/
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_does_not_wait_on_its_own_group_command(
2475 self, mock_mass: MagicMock
2476 ) -> None:
2477 """A muted sync leader is unmuted by 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 # (and unmutes) the very player the group command is running for
2484 async with asyncio.timeout(5):
2485 await controller.cmd_group_volume("leader", 30)
2486
2487 players["leader"].update_state()
2488 assert players["leader"].state.volume_muted is False
2489 assert players["leader"].state.volume_level == 30
2490
2491
2492class LateReportingDevice:
2493 """A mocked device that only reports the volume it was given back when told to."""
2494
2495 def __init__(self, command_delay: float = 0) -> None:
2496 """
2497 Initialize the mocked device.
2498
2499 :param command_delay: Seconds a single volume command takes to reach the device.
2500 """
2501 self.commands: list[int] = []
2502 self._command_delay = command_delay
2503 self._pending: dict[MockPlayer, int] = {}
2504
2505 def bind(self, player: MockPlayer) -> None:
2506 """Make the given player answer its volume commands like this device."""
2507
2508 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
2509 self.commands.append(volume)
2510 if self._command_delay:
2511 await asyncio.sleep(self._command_delay)
2512 self._pending[_player] = volume
2513
2514 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
2515
2516 def report(self) -> None:
2517 """Report the volume of every command received so far back to the player state."""
2518 for player, volume in self._pending.items():
2519 player._attr_volume_level = volume
2520 player.update_state(force_update=True, signal_event=False)
2521 self._pending.clear()
2522
2523
2524class TestVolumeNudgeTarget:
2525 """Volume nudges step from the level last commanded, not from a lagging report."""
2526
2527 def _make_player(
2528 self, mock_mass: MagicMock, command_delay: float = 0
2529 ) -> tuple[PlayerController, MockPlayer, LateReportingDevice]:
2530 """
2531 Build a single player at volume 50 whose device reports back on request.
2532
2533 :param command_delay: Seconds a single volume command takes to reach the device.
2534 """
2535 controller = PlayerController(mock_mass)
2536 controller.config = _volume_step_config(5)
2537 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2538 player = MockPlayer(provider, "player_1", "Player 1")
2539 player._attr_volume_level = 50
2540 device = LateReportingDevice(command_delay)
2541 device.bind(player)
2542 controller._players = {"player_1": player}
2543 mock_mass.players = controller
2544 mock_mass.player_queues.get = MagicMock(return_value=None)
2545 player.set_initialized()
2546 player.update_state(signal_event=False)
2547 # a second, forced pass so the player derives its group volume from its own state
2548 player.update_state(force_update=True, signal_event=False)
2549 return controller, player, device
2550
2551 def _make_synced_pair(
2552 self, mock_mass: MagicMock, command_delay: float = 0
2553 ) -> tuple[PlayerController, dict[str, MockPlayer], LateReportingDevice]:
2554 """
2555 Build a leader synced to one member, both at volume 50, both late reporting.
2556
2557 :param command_delay: Seconds a single volume command takes to reach the device.
2558 """
2559 controller = PlayerController(mock_mass)
2560 controller.config = _volume_step_config(5)
2561 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2562 device = LateReportingDevice(command_delay)
2563 players: dict[str, MockPlayer] = {}
2564 for player_id in ("leader", "member"):
2565 player = MockPlayer(provider, player_id, player_id.title())
2566 player._attr_volume_level = 50
2567 device.bind(player)
2568 players[player_id] = player
2569 players["leader"]._attr_group_members = ["member"]
2570 controller._players = dict(players)
2571 mock_mass.players = controller
2572 mock_mass.player_queues.get = MagicMock(return_value=None)
2573 for player in players.values():
2574 player.set_initialized()
2575 player._cache.clear()
2576 player.update_state(signal_event=False)
2577 # a second, forced pass so the leader derives its group volume from the member
2578 for player in players.values():
2579 player.update_state(force_update=True, signal_event=False)
2580 return controller, players, device
2581
2582 async def test_nudges_up_stack_before_the_player_reports_back(
2583 self, mock_mass: MagicMock
2584 ) -> None:
2585 """Three volume ups in a row climb, even with no report in between."""
2586 controller, _player, device = self._make_player(mock_mass)
2587
2588 for _ in range(3):
2589 await controller.cmd_volume_up("player_1")
2590
2591 assert device.commands == [55, 60, 65]
2592
2593 async def test_nudges_down_stack_before_the_player_reports_back(
2594 self, mock_mass: MagicMock
2595 ) -> None:
2596 """Three volume downs in a row descend, even with no report in between."""
2597 controller, _player, device = self._make_player(mock_mass)
2598
2599 for _ in range(3):
2600 await controller.cmd_volume_down("player_1")
2601
2602 assert device.commands == [45, 40, 35]
2603
2604 async def test_nudges_that_overlap_each_get_their_own_step(self, mock_mass: MagicMock) -> None:
2605 """Volume ups that arrive while an earlier one is still on its way all count."""
2606 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
2607
2608 await asyncio.gather(*(controller.cmd_volume_up("player_1") for _ in range(3)))
2609
2610 assert device.commands == [55, 60, 65]
2611
2612 async def test_a_queued_nudge_does_not_undo_the_level_a_later_one_claimed(
2613 self, mock_mass: MagicMock
2614 ) -> None:
2615 """A nudge waiting for the volume lock may not take the level back down."""
2616 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
2617 tasks = [asyncio.create_task(controller.cmd_volume_up("player_1")) for _ in range(3)]
2618 # let all three claim their level; the second and third then queue on the lock
2619 await asyncio.sleep(0)
2620 # wait for the second one to reach the device, so a nudge sent now reads whatever
2621 # that (by then oldest) command left behind
2622 while len(device.commands) < 2:
2623 await asyncio.sleep(0.005)
2624 tasks.append(asyncio.create_task(controller.cmd_volume_up("player_1")))
2625
2626 await asyncio.gather(*tasks)
2627
2628 assert device.commands == [55, 60, 65, 70]
2629
2630 async def test_a_group_nudge_moves_every_member_up(self, mock_mass: MagicMock) -> None:
2631 """A group nudge up may not send a member the other way."""
2632 controller, players, device = self._make_synced_pair(mock_mass)
2633 # the loudest member is the one that was just turned down on its own, so the
2634 # level it still reports sits above the level the group is being stepped to
2635 players["member"]._attr_volume_level = 80
2636 for _ in range(3):
2637 for player in players.values():
2638 player._cache.clear()
2639 player.update_state(force_update=True, signal_event=False)
2640
2641 await controller.cmd_volume_set("member", 40)
2642 await controller.cmd_group_volume_up("leader")
2643
2644 assert device.commands == [40, 55, 46]
2645
2646 async def test_a_group_nudge_on_an_ungrouped_player_steps_its_own_volume(
2647 self, mock_mass: MagicMock
2648 ) -> None:
2649 """A group nudge falls back to the player itself, which has no group to step."""
2650 controller, _player, device = self._make_player(mock_mass)
2651
2652 for _ in range(3):
2653 await controller.cmd_group_volume_up("player_1")
2654
2655 assert device.commands == [55, 60, 65]
2656
2657 async def test_a_nudge_steps_from_the_level_the_slider_was_left_at(
2658 self, mock_mass: MagicMock
2659 ) -> None:
2660 """A nudge right after a volume set steps from that set level."""
2661 controller, _player, device = self._make_player(mock_mass)
2662
2663 await controller.cmd_volume_set("player_1", 20)
2664 await controller.cmd_volume_up("player_1")
2665
2666 assert device.commands == [20, 25]
2667
2668 async def test_a_change_on_the_device_wins_once_the_last_command_ages_out(
2669 self, mock_mass: MagicMock
2670 ) -> None:
2671 """A volume turned down on the device itself is the base of the next nudge."""
2672 controller, player, device = self._make_player(mock_mass)
2673 await controller.cmd_volume_up("player_1")
2674 device.report()
2675 # the volume is turned down on the device itself, well after that command
2676 player._attr_volume_level = 20
2677 player.update_state(force_update=True, signal_event=False)
2678
2679 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
2680 await controller.cmd_volume_up("player_1")
2681
2682 assert device.commands[-1] == 25
2683
2684 async def test_group_nudges_up_stack_before_the_players_report_back(
2685 self, mock_mass: MagicMock
2686 ) -> None:
2687 """Three group volume ups in a row climb, even with no report in between."""
2688 controller, _players, device = self._make_synced_pair(mock_mass)
2689
2690 for _ in range(3):
2691 await controller.cmd_group_volume_up("leader")
2692
2693 assert device.commands == [55, 55, 60, 60, 65, 65]
2694
2695 async def test_group_nudges_down_stack_before_the_players_report_back(
2696 self, mock_mass: MagicMock
2697 ) -> None:
2698 """Three group volume downs in a row descend, even with no report in between."""
2699 controller, _players, device = self._make_synced_pair(mock_mass)
2700
2701 for _ in range(3):
2702 await controller.cmd_group_volume_down("leader")
2703
2704 assert device.commands == [45, 45, 40, 40, 35, 35]
2705
2706 async def test_a_group_nudge_after_a_member_was_set_on_its_own_keeps_the_step(
2707 self, mock_mass: MagicMock
2708 ) -> None:
2709 """Setting one member does not send the group back to the level it reports."""
2710 controller, _players, device = self._make_synced_pair(mock_mass)
2711
2712 await controller.cmd_group_volume_up("leader")
2713 await controller.cmd_volume_set("member", 10)
2714 await controller.cmd_group_volume_up("leader")
2715
2716 # the loudest member was commanded to 55, so the group steps from there, and the
2717 # member that was just turned down keeps its share of the group volume
2718 assert device.commands == [55, 55, 10, 60, 20]
2719
2720 async def test_a_group_volume_beyond_the_range_does_not_pin_the_next_nudge(
2721 self, mock_mass: MagicMock
2722 ) -> None:
2723 """An out of range group volume leaves the members at 100, not above it."""
2724 controller, _players, device = self._make_synced_pair(mock_mass)
2725
2726 await controller.cmd_group_volume("leader", 200)
2727 await controller.cmd_group_volume_down("leader")
2728
2729 assert device.commands == [100, 100, 95, 95]
2730
2731 async def test_a_group_nudge_from_a_member_shares_the_target_of_its_leader(
2732 self, mock_mass: MagicMock
2733 ) -> None:
2734 """Group nudges addressed to a member and to its leader step the same group."""
2735 controller, _players, device = self._make_synced_pair(mock_mass)
2736
2737 await controller.cmd_group_volume_up("leader")
2738 await controller.cmd_group_volume_up("member")
2739
2740 assert device.commands == [55, 55, 60, 60]
2741
2742
2743class TestFakeMuteInGroup:
2744 """A fake muted player in a group follows the mute lock, just like a native mute."""
2745
2746 def _make_synced_pair(
2747 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
2748 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2749 """
2750 Build a leader synced to one member.
2751
2752 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
2753 """
2754
2755 def _conf(player_id: str, key: str, default: object = None) -> object:
2756 if key == CONF_MUTE_CONTROL and player_id == "member":
2757 return member_mute_control
2758 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
2759 player_id, key, default
2760 )
2761
2762 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2763 controller = PlayerController(mock_mass)
2764 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2765 players: dict[str, MockPlayer] = {}
2766 for player_id in ("leader", "member"):
2767 player = MockPlayer(provider, player_id, player_id.title())
2768 player._attr_supported_features = {
2769 PlayerFeature.VOLUME_SET,
2770 PlayerFeature.VOLUME_MUTE,
2771 }
2772 player._attr_volume_level = 50
2773 # let the mocked native volume control behave like a real device
2774 player.volume_set = AsyncMock( # type: ignore[method-assign]
2775 side_effect=lambda volume, _player=player: setattr(
2776 _player, "_attr_volume_level", volume
2777 )
2778 )
2779 players[player_id] = player
2780 players["leader"]._attr_group_members = ["member"]
2781 controller._players = dict(players)
2782 mock_mass.players = controller
2783 mock_mass.player_queues.get = MagicMock(return_value=None)
2784 for player in players.values():
2785 player.set_initialized()
2786 player._cache.clear()
2787 player.update_state(signal_event=False)
2788 # a second pass, so the group volume of the leader accounts for its member
2789 for player in players.values():
2790 player.update_state(signal_event=False)
2791 return controller, players
2792
2793 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
2794 """A group volume change may not bring a muted fake mute pair back to life."""
2795 controller, players = self._make_synced_pair(mock_mass)
2796 await controller.cmd_group_volume_mute("leader", True)
2797
2798 await controller.cmd_group_volume("leader", 30)
2799
2800 for player in players.values():
2801 player.update_state()
2802 assert player.state.volume_muted is True
2803 assert player.state.volume_level == 0
2804
2805 async def test_a_muted_member_does_not_inflate_a_group_nudge(
2806 self, mock_mass: MagicMock
2807 ) -> None:
2808 """A volume level a muted member never receives may not step the group."""
2809 controller, players = self._make_synced_pair(mock_mass)
2810 controller.config = _volume_step_config(5)
2811 await controller.cmd_volume_mute("member", True)
2812 # let the group volume of the leader account for the muted member
2813 players["leader"].update_state(signal_event=False)
2814 # the member is held silent, so this level never reaches it
2815 await controller.cmd_volume_set("member", 60)
2816
2817 await controller.cmd_group_volume_up("leader")
2818
2819 players["leader"].update_state()
2820 assert players["leader"].state.volume_level == 55
2821
2822 async def test_a_nudge_after_unmuting_steps_from_the_restored_volume(
2823 self, mock_mass: MagicMock
2824 ) -> None:
2825 """Unmuting hands the next nudge the volume it restored, not the muted 0."""
2826 controller, players = self._make_synced_pair(mock_mass)
2827 controller.config = _volume_step_config(5)
2828 await controller.cmd_volume_mute("member", True)
2829 players["leader"].update_state(signal_event=False)
2830 # a level set while muted never reaches the member
2831 await controller.cmd_volume_set("member", 60)
2832
2833 await controller.cmd_volume_mute("member", False)
2834 await controller.cmd_volume_up("member")
2835
2836 players["member"].update_state()
2837 assert players["member"].state.volume_level == 55
2838
2839 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
2840 """Turning a group down leaves a single muted member silent, at its own volume."""
2841 controller, players = self._make_synced_pair(mock_mass)
2842 await controller.cmd_volume_mute("member", True)
2843 # let the group volume of the leader account for the muted member
2844 players["leader"].update_state(signal_event=False)
2845
2846 await controller.cmd_group_volume("leader", 25)
2847
2848 for player in players.values():
2849 player.update_state()
2850 member_state = players["member"].state
2851 assert member_state.volume_muted is True
2852 assert member_state.volume_level == 0
2853 # the player that is not muted follows the group volume as usual
2854 assert players["leader"].state.volume_level == 25
2855 # unmuting brings the member back at the volume it had before it was muted
2856 await controller.cmd_volume_mute("member", False)
2857 players["member"].update_state()
2858 assert players["member"].state.volume_level == 50
2859
2860 async def test_unmute_restores_the_volume_from_before_the_mute(
2861 self, mock_mass: MagicMock
2862 ) -> None:
2863 """A group volume change while muted may not alter the volume to restore."""
2864 controller, players = self._make_synced_pair(mock_mass)
2865 await controller.cmd_group_volume_mute("leader", True)
2866 await controller.cmd_group_volume("leader", 30)
2867
2868 await controller.cmd_group_volume_mute("leader", False)
2869
2870 for player in players.values():
2871 player.update_state()
2872 assert player.state.volume_muted is False
2873 assert player.state.volume_level == 50
2874
2875 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
2876 """Members with a different mute control stay muted alike on a group volume change."""
2877 controller, players = self._make_synced_pair(
2878 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
2879 )
2880 mute = AsyncMock(
2881 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
2882 )
2883 players["member"].volume_mute = mute # type: ignore[method-assign]
2884 await controller.cmd_group_volume_mute("leader", True)
2885
2886 await controller.cmd_group_volume("leader", 30)
2887
2888 mute.assert_awaited_once_with(True)
2889 for player in players.values():
2890 player.update_state()
2891 assert player.state.volume_muted is True
2892
2893
2894class TestMuteControlGuard:
2895 """Muting is gated on the mute control, independently of the volume control."""
2896
2897 def _make_player(
2898 self,
2899 mock_mass: MagicMock,
2900 mute_control: str,
2901 volume_control: str,
2902 controls: dict[str, PlayerControl] | None = None,
2903 ) -> tuple[PlayerController, MockPlayer]:
2904 """Build a controller with a single player using the given control config."""
2905 mock_mass.config.get_raw_player_config_value = MagicMock(
2906 side_effect=_player_config_stub(
2907 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
2908 )
2909 )
2910 controller = PlayerController(mock_mass)
2911 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2912 player = MockPlayer(provider, "player_1", "Player 1")
2913 controller._players = {"player_1": player}
2914 controller._controls = controls or {}
2915 mock_mass.players = controller
2916 mock_mass.player_queues.get = MagicMock(return_value=None)
2917 player.set_initialized()
2918 player.update_state(signal_event=False)
2919 return controller, player
2920
2921 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
2922 """A player without volume control still mutes through an external PlayerControl."""
2923 mute_set = AsyncMock()
2924 control = PlayerControl(
2925 id="ext_mute",
2926 provider="test",
2927 name="External Mute",
2928 supports_mute=True,
2929 mute_set=mute_set,
2930 )
2931 controller, player = self._make_player(
2932 mock_mass,
2933 mute_control="ext_mute",
2934 volume_control=PLAYER_CONTROL_NONE,
2935 controls={"ext_mute": control},
2936 )
2937 assert player.mute_control == "ext_mute"
2938
2939 await controller.cmd_volume_mute("player_1", True)
2940 mute_set.assert_awaited_once_with(True)
2941
2942 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
2943 """A player without volume control still mutes natively."""
2944 controller, player = self._make_player(
2945 mock_mass,
2946 mute_control=PLAYER_CONTROL_NATIVE,
2947 volume_control=PLAYER_CONTROL_NONE,
2948 )
2949 volume_mute = AsyncMock()
2950 player.volume_mute = volume_mute # type: ignore[method-assign]
2951
2952 await controller.cmd_volume_mute("player_1", True)
2953 volume_mute.assert_awaited_once_with(True)
2954
2955 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
2956 """A player with volume control but no mute control rejects the command."""
2957 controller, player = self._make_player(
2958 mock_mass,
2959 mute_control=PLAYER_CONTROL_NONE,
2960 volume_control=PLAYER_CONTROL_NATIVE,
2961 )
2962 volume_mute = AsyncMock()
2963 player.volume_mute = volume_mute # type: ignore[method-assign]
2964
2965 with pytest.raises(UnsupportedFeaturedException):
2966 await controller.cmd_volume_mute("player_1", True)
2967 volume_mute.assert_not_awaited()
2968
2969 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
2970 """Fake mute needs a volume control to drive, so it rejects the command outright."""
2971 controller, player = self._make_player(
2972 mock_mass,
2973 mute_control=PLAYER_CONTROL_FAKE,
2974 volume_control=PLAYER_CONTROL_NONE,
2975 )
2976 player._attr_volume_level = 40
2977
2978 with pytest.raises(UnsupportedFeaturedException):
2979 await controller.cmd_volume_mute("player_1", True)
2980 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
2981 assert ATTR_FAKE_MUTE not in player.extra_data
2982
2983 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
2984 """A mute control that disappeared after being resolved is reported, not ignored."""
2985 control = PlayerControl(
2986 id="ext_mute",
2987 provider="test",
2988 name="External Mute",
2989 supports_mute=True,
2990 mute_set=AsyncMock(),
2991 )
2992 controller, player = self._make_player(
2993 mock_mass,
2994 mute_control="ext_mute",
2995 volume_control=PLAYER_CONTROL_NONE,
2996 controls={"ext_mute": control},
2997 )
2998 # the resolved control is cached on the player, so removing it here leaves
2999 # the player pointing at a control that no longer exists
3000 assert player.mute_control == "ext_mute"
3001 controller._controls = {}
3002
3003 with pytest.raises(UnsupportedFeaturedException):
3004 await controller.cmd_volume_mute("player_1", True)
3005
3006 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
3007 """Unmuting clears a mute lock left behind by a since-removed mute control."""
3008 controller, player = self._make_player(
3009 mock_mass,
3010 mute_control=PLAYER_CONTROL_NONE,
3011 volume_control=PLAYER_CONTROL_NATIVE,
3012 )
3013 player.extra_data[ATTR_MUTE_LOCK] = True
3014
3015 with pytest.raises(UnsupportedFeaturedException):
3016 await controller.cmd_volume_mute("player_1", False)
3017 assert ATTR_MUTE_LOCK not in player.extra_data
3018
3019 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
3020 """A grouped player whose mute command failed is not left holding a mute lock."""
3021 control = PlayerControl(
3022 id="ext_mute",
3023 provider="test",
3024 name="External Mute",
3025 supports_mute=False,
3026 )
3027 controller, player = self._make_player(
3028 mock_mass,
3029 mute_control="ext_mute",
3030 volume_control=PLAYER_CONTROL_NONE,
3031 controls={"ext_mute": control},
3032 )
3033 player.state.synced_to = "leader"
3034
3035 with pytest.raises(UnsupportedFeaturedException):
3036 await controller.cmd_volume_mute("player_1", True)
3037 assert ATTR_MUTE_LOCK not in player.extra_data
3038
3039 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
3040 """A failed mute leaves the lock of an earlier successful mute in place."""
3041 control = PlayerControl(
3042 id="ext_mute",
3043 provider="test",
3044 name="External Mute",
3045 supports_mute=False,
3046 )
3047 controller, player = self._make_player(
3048 mock_mass,
3049 mute_control="ext_mute",
3050 volume_control=PLAYER_CONTROL_NONE,
3051 controls={"ext_mute": control},
3052 )
3053 player.state.synced_to = "leader"
3054 player.extra_data[ATTR_MUTE_LOCK] = True
3055
3056 with pytest.raises(UnsupportedFeaturedException):
3057 await controller.cmd_volume_mute("player_1", True)
3058 assert player.extra_data[ATTR_MUTE_LOCK] is True
3059
3060
3061class TestGroupMuteMemberFilter:
3062 """Group mute skips members that have no mute control of their own."""
3063
3064 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
3065 """A member without a mute control must not fail the whole group command."""
3066 controller = PlayerController(mock_mass)
3067 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3068 leader = MockPlayer(provider, "leader", "Leader")
3069 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3070 leader._attr_group_members = ["leader", "member"]
3071 member = MockPlayer(provider, "member", "Member")
3072 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3073 controller._players = {"leader": leader, "member": member}
3074 mock_mass.players = controller
3075 mock_mass.player_queues.get = MagicMock(return_value=None)
3076 for player in (leader, member):
3077 player.set_initialized()
3078 player.update_state(signal_event=False)
3079 leader_mute = AsyncMock()
3080 leader.volume_mute = leader_mute # type: ignore[method-assign]
3081
3082 await controller.cmd_group_volume_mute("leader", True)
3083 leader_mute.assert_awaited_once_with(True)
3084
3085
3086class TestGroupPlayerMuteRedirect:
3087 """A mute command on a group player is handled at group level."""
3088
3089 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
3090 """Build a controller with a group player holding a single mute capable member."""
3091 controller = PlayerController(mock_mass)
3092 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3093 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3094 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3095 group._attr_group_members = ["member"]
3096 member = MockPlayer(provider, "member", "Member")
3097 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3098 controller._players = {"group": group, "member": member}
3099 mock_mass.players = controller
3100 mock_mass.player_queues.get = MagicMock(return_value=None)
3101 for player in (group, member):
3102 player.set_initialized()
3103 player.update_state(signal_event=False)
3104 return controller, group, member
3105
3106 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
3107 """A group player has no mute of its own, so the members must be muted instead."""
3108 controller, _group, member = self._setup(mock_mass)
3109 member_mute = AsyncMock()
3110 member.volume_mute = member_mute # type: ignore[method-assign]
3111
3112 await controller.cmd_volume_mute("group", True)
3113
3114 member_mute.assert_awaited_once_with(True)
3115
3116 async def test_mute_on_group_player_without_own_mute_control(
3117 self, mock_mass: MagicMock
3118 ) -> None:
3119 """A group that has no mute control of its own must still mute its members."""
3120 controller, group, member = self._setup(mock_mass)
3121 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
3122 group._cache.clear()
3123 group.update_state(signal_event=False)
3124 assert group.mute_control == PLAYER_CONTROL_NONE
3125 member_mute = AsyncMock()
3126 member.volume_mute = member_mute # type: ignore[method-assign]
3127
3128 await controller.cmd_volume_mute("group", True)
3129
3130 member_mute.assert_awaited_once_with(True)
3131
3132 async def test_mute_on_group_player_without_mute_capable_members(
3133 self, mock_mass: MagicMock
3134 ) -> None:
3135 """A group whose members cannot mute must not raise, just like group mute itself."""
3136 controller, _group, member = self._setup(mock_mass)
3137 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3138 member._cache.clear()
3139 member.update_state(signal_event=False)
3140
3141 await controller.cmd_volume_mute("group", True)
3142
3143
3144class TestGroupMuteOnNonGroupPlayer:
3145 """A group mute command works on any player, just like the group volume command."""
3146
3147 def _setup(
3148 self, mock_mass: MagicMock, *members: str
3149 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3150 """Build a controller with a mute capable leader synced to the given members."""
3151 controller = PlayerController(mock_mass)
3152 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3153 players: dict[str, MockPlayer] = {}
3154 for player_id in ("leader", *members):
3155 player = MockPlayer(provider, player_id, player_id.title())
3156 player._attr_supported_features = {
3157 PlayerFeature.VOLUME_SET,
3158 PlayerFeature.VOLUME_MUTE,
3159 }
3160 player._attr_volume_level = 50
3161 players[player_id] = player
3162 if members:
3163 # the leader is not listed as its own member here, so the tests also cover
3164 # that a sync leader is injected into its own final group_members
3165 players["leader"]._attr_group_members = list(members)
3166 controller._players = dict(players)
3167 mock_mass.players = controller
3168 mock_mass.player_queues.get = MagicMock(return_value=None)
3169 for player in players.values():
3170 player.set_initialized()
3171 player._cache.clear()
3172 player.update_state(signal_event=False)
3173 return controller, players
3174
3175 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
3176 """Replace the native mute command of every given player with a mock."""
3177 mutes: dict[str, AsyncMock] = {}
3178 for player_id, player in players.items():
3179 mutes[player_id] = AsyncMock()
3180 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
3181 return mutes
3182
3183 async def test_group_mute_on_synced_member_redirects_to_leader(
3184 self, mock_mass: MagicMock
3185 ) -> None:
3186 """A member of a sync group mutes the whole group through its sync leader."""
3187 controller, players = self._setup(mock_mass, "member")
3188 assert players["member"].state.synced_to == "leader"
3189 mutes = self._stub_mutes(players)
3190
3191 await controller.cmd_group_volume_mute("member", True)
3192
3193 mutes["leader"].assert_awaited_once_with(True)
3194 mutes["member"].assert_awaited_once_with(True)
3195 assert ATTR_MUTE_LOCK in players["member"].extra_data
3196
3197 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
3198 self, mock_mass: MagicMock
3199 ) -> None:
3200 """A sync leader is part of its own member list, so it must be muted only once."""
3201 controller, players = self._setup(mock_mass, "member")
3202 mutes = self._stub_mutes(players)
3203
3204 await controller.cmd_group_volume_mute("leader", True)
3205
3206 mutes["leader"].assert_awaited_once_with(True)
3207 mutes["member"].assert_awaited_once_with(True)
3208
3209 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
3210 """A player that is not grouped at all is muted as a normal player."""
3211 controller, players = self._setup(mock_mass)
3212 mutes = self._stub_mutes(players)
3213
3214 await controller.cmd_group_volume_mute("leader", True)
3215
3216 mutes["leader"].assert_awaited_once_with(True)
3217
3218 async def test_group_mute_on_plain_player_without_mute_control(
3219 self, mock_mass: MagicMock
3220 ) -> None:
3221 """A plain player that cannot mute reports that, just like a normal mute command."""
3222 controller, players = self._setup(mock_mass)
3223 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
3224 players["leader"]._cache.clear()
3225 players["leader"].update_state(signal_event=False)
3226
3227 with pytest.raises(UnsupportedFeaturedException):
3228 await controller.cmd_group_volume_mute("leader", True)
3229
3230 async def test_group_unmute_on_synced_member_redirects_to_leader(
3231 self, mock_mass: MagicMock
3232 ) -> None:
3233 """Unmuting through a member clears the mute (and mute lock) of every group member."""
3234 controller, players = self._setup(mock_mass, "member")
3235 mutes = self._stub_mutes(players)
3236 players["member"].extra_data[ATTR_MUTE_LOCK] = True
3237
3238 await controller.cmd_group_volume_mute("member", False)
3239
3240 mutes["leader"].assert_awaited_once_with(False)
3241 mutes["member"].assert_awaited_once_with(False)
3242 assert ATTR_MUTE_LOCK not in players["member"].extra_data
3243
3244 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
3245 """A sync leader is as much part of the group as its members, so it is locked too."""
3246 controller, players = self._setup(mock_mass, "member")
3247 self._stub_mutes(players)
3248
3249 await controller.cmd_group_volume_mute("leader", True)
3250
3251 assert ATTR_MUTE_LOCK in players["leader"].extra_data
3252 assert ATTR_MUTE_LOCK in players["member"].extra_data
3253
3254 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
3255 """A group volume change may not half-unmute a muted pair of directly synced players."""
3256 controller, players = self._setup(mock_mass, "member")
3257 self._stub_mutes(players)
3258 await controller.cmd_group_volume_mute("leader", True)
3259 # the mock players do not act on the mute command, so reflect it in their state
3260 for player in players.values():
3261 player._attr_volume_muted = True
3262 player.update_state(signal_event=False)
3263 player.volume_set = AsyncMock() # type: ignore[method-assign]
3264 # re-stub so only the mute commands of the group volume change are counted
3265 mutes = self._stub_mutes(players)
3266
3267 await controller.cmd_group_volume("leader", 30)
3268
3269 for mute in mutes.values():
3270 mute.assert_not_awaited()
3271
3272
3273class TestMuteLockAfterUngroup:
3274 """A mute lock is only honored while the player it belongs to is still grouped."""
3275
3276 def _make_synced_pair(
3277 self, mock_mass: MagicMock, member_mute_control: str
3278 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3279 """
3280 Build a leader with one synced member.
3281
3282 :param member_mute_control: Mute control to configure on both players.
3283 """
3284 mock_mass.config.get_raw_player_config_value = MagicMock(
3285 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
3286 )
3287 controller = PlayerController(mock_mass)
3288 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3289 players: dict[str, MockPlayer] = {}
3290 for player_id in ("leader", "member"):
3291 player = MockPlayer(provider, player_id, player_id.title())
3292 player._attr_supported_features = {
3293 PlayerFeature.VOLUME_SET,
3294 PlayerFeature.VOLUME_MUTE,
3295 }
3296 player._attr_volume_level = 50
3297 player.volume_set = AsyncMock( # type: ignore[method-assign]
3298 side_effect=lambda volume, _player=player: setattr(
3299 _player, "_attr_volume_level", volume
3300 )
3301 )
3302 players[player_id] = player
3303 players["leader"]._attr_group_members = ["member"]
3304 controller._players = dict(players)
3305 mock_mass.players = controller
3306 mock_mass.player_queues.get = MagicMock(return_value=None)
3307 for player in players.values():
3308 player.set_initialized()
3309 player._cache.clear()
3310 player.update_state(signal_event=False)
3311 return controller, players
3312
3313 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
3314 """Drop the sync group, the way a provider side topology change does."""
3315 players["leader"]._attr_group_members = []
3316 for player in players.values():
3317 player.refresh_state(signal_event=False)
3318
3319 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
3320 """A fake muted player is no longer forced silent once its group is gone."""
3321 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3322 await controller.cmd_volume_mute("member", True)
3323 self._dissolve_group(players)
3324
3325 await controller.cmd_volume_set("member", 70)
3326
3327 players["member"].update_state()
3328 assert players["member"].state.volume_level == 70
3329 assert players["member"].state.volume_muted is False
3330
3331 async def test_natively_muted_player_is_unmuted_again(self, mock_mass: MagicMock) -> None:
3332 """A natively muted player is auto-unmuted by a volume change once its group is gone."""
3333 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
3334 mute = AsyncMock(
3335 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
3336 )
3337 players["member"].volume_mute = mute # type: ignore[method-assign]
3338 await controller.cmd_volume_mute("member", True)
3339 self._dissolve_group(players)
3340
3341 await controller.cmd_volume_set("member", 70)
3342
3343 assert mute.await_args_list == [call(True), call(False)]
3344 players["member"].update_state()
3345 assert players["member"].state.volume_level == 70
3346 assert players["member"].state.volume_muted is False
3347
3348 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
3349 """A muted player that is still grouped stays silent on a volume change."""
3350 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3351 await controller.cmd_volume_mute("member", True)
3352
3353 await controller.cmd_volume_set("member", 70)
3354
3355 players["member"].update_state()
3356 assert players["member"].state.volume_level == 0
3357 assert players["member"].state.volume_muted is True
3358
3359 async def test_protocol_player_follows_the_lock_of_its_parent(
3360 self, mock_mass: MagicMock
3361 ) -> None:
3362 """A protocol player inherits the lock of the parent it renders for, group and all."""
3363 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
3364 member = players["member"]
3365 protocol_player = MockPlayer(
3366 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
3367 "proto_member",
3368 "Member Protocol",
3369 player_type=PlayerType.PROTOCOL,
3370 )
3371 protocol_player._attr_supported_features = {
3372 PlayerFeature.VOLUME_SET,
3373 PlayerFeature.VOLUME_MUTE,
3374 }
3375 protocol_player._attr_volume_muted = True
3376 protocol_player.set_protocol_parent_id("member")
3377 # an unmute on a protocol player is redirected to the parent it renders for
3378 mute = AsyncMock()
3379 member.volume_mute = mute # type: ignore[method-assign]
3380 protocol_player.volume_mute = AsyncMock() # type: ignore[method-assign]
3381 protocol_player.volume_set = AsyncMock() # type: ignore[method-assign]
3382 controller._players["proto_member"] = protocol_player
3383 member.set_linked_output_protocols(
3384 [
3385 LinkedOutputProtocol(
3386 output_protocol_id="proto_member",
3387 protocol_domain="sendspin",
3388 priority=40,
3389 )
3390 ]
3391 )
3392 protocol_player.set_initialized()
3393 protocol_player.update_state(signal_event=False)
3394 member.refresh_state(signal_event=False)
3395 member.extra_data[ATTR_MUTE_LOCK] = True
3396
3397 # a group volume change reaches the protocol player through the internal handler
3398 await controller._handle_cmd_volume_set("proto_member", 70)
3399 mute.assert_not_awaited()
3400
3401 self._dissolve_group(players)
3402 await controller._handle_cmd_volume_set("proto_member", 70)
3403
3404 mute.assert_awaited_once_with(False)
3405
3406
3407class TestCurrentMediaTimeUpdates:
3408 """Playback-position anchor semantics of timing-only state updates."""
3409
3410 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
3411 """Build a controller with a single playing player with a known position anchor."""
3412 controller = PlayerController(mock_mass)
3413 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3414 player = MockPlayer(provider, "player_1", "Player 1")
3415 controller._players = {"player_1": player}
3416 mock_mass.players = controller
3417 # no queue registered: current_media resolves from the player's native media
3418 mock_mass.player_queues.get = MagicMock(return_value=None)
3419 player.set_initialized()
3420 now = time.time()
3421 player._attr_playback_state = PlaybackState.PLAYING
3422 player._attr_elapsed_time = 17
3423 player._attr_elapsed_time_last_updated = now
3424 player.set_current_media(uri="http://test/stream", title="Test")
3425 assert player._attr_current_media is not None
3426 player._attr_current_media.elapsed_time = 17
3427 player._attr_current_media.elapsed_time_last_updated = now
3428 player.update_state(signal_event=False)
3429 # isolate from the unrelated state-forwarding machinery
3430 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
3431 mock_mass.signal_event.reset_mock()
3432 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
3433 return controller, player
3434
3435 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
3436 """Return whether a PLAYER_UPDATED event was signalled."""
3437 return any(
3438 call.args and call.args[0] == EventType.PLAYER_UPDATED
3439 for call in mock_mass.signal_event.call_args_list
3440 )
3441
3442 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
3443 """A regular playback tick (position and anchor advance together) emits nothing."""
3444 _controller, player = self._make_player(mock_mass)
3445 assert player._attr_current_media is not None
3446 assert player._attr_elapsed_time_last_updated is not None
3447
3448 player._attr_elapsed_time = 18
3449 player._attr_elapsed_time_last_updated += 1
3450 player._attr_current_media.elapsed_time = 18
3451 assert player._attr_current_media.elapsed_time_last_updated is not None
3452 player._attr_current_media.elapsed_time_last_updated += 1
3453 player.update_state()
3454
3455 assert not self._player_updated_signalled(mock_mass)
3456 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
3457 # the previous anchor was preserved: steady playback changes nothing
3458 assert player.state.elapsed_time == 17
3459
3460 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
3461 """An anchor-only change (no significant corrected position change) emits nothing."""
3462 _controller, player = self._make_player(mock_mass)
3463 assert player._attr_current_media is not None
3464 assert player._attr_elapsed_time_last_updated is not None
3465
3466 player._attr_elapsed_time_last_updated += 0.5
3467 assert player._attr_current_media.elapsed_time_last_updated is not None
3468 player._attr_current_media.elapsed_time_last_updated += 0.5
3469 player.update_state()
3470
3471 assert not self._player_updated_signalled(mock_mass)
3472 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
3473
3474 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
3475 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
3476 _controller, player = self._make_player(mock_mass)
3477 assert player._attr_current_media is not None
3478
3479 player._attr_current_media.elapsed_time = 61
3480 player._attr_current_media.elapsed_time_last_updated = time.time()
3481 player.update_state()
3482
3483 assert self._player_updated_signalled(mock_mass)
3484 # the adopted anchor is visible to consumers
3485 assert player.state.current_media is not None
3486 assert player.state.current_media.elapsed_time == 61
3487
3488 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
3489 """A player-level corrected-position jump re-bases the queue timing."""
3490 controller, player = self._make_player(mock_mass)
3491
3492 player._attr_elapsed_time = 61
3493 player._attr_elapsed_time_last_updated = time.time()
3494 player.update_state()
3495
3496 # the queue is corrected and a follow-up player update is scheduled
3497 # (which re-anchors current_media onto the corrected queue time),
3498 # but no full player update is emitted for the jump itself
3499 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3500 assert not self._player_updated_signalled(mock_mass)
3501 cast("MagicMock", controller._forward_state_update).assert_called_once()
3502 assert player.state.elapsed_time == 61
3503
3504 def test_simultaneous_player_and_media_jump_emits_immediately(
3505 self, mock_mass: MagicMock
3506 ) -> None:
3507 """A jump reaching player and current_media in one pass corrects the queue and emits."""
3508 _controller, player = self._make_player(mock_mass)
3509 assert player._attr_current_media is not None
3510 now = time.time()
3511
3512 player._attr_elapsed_time = 61
3513 player._attr_elapsed_time_last_updated = now
3514 player._attr_current_media.elapsed_time = 61
3515 player._attr_current_media.elapsed_time_last_updated = now
3516 player.update_state()
3517
3518 # the queue is re-based AND the full update is emitted right away
3519 # (current_media already holds the fresh position in the same pass)
3520 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3521 assert self._player_updated_signalled(mock_mass)
3522
3523
3524class TestPlayAnnouncementCleanup:
3525 """Test announcement data cleanup after play_announcement."""
3526
3527 def _make_player(
3528 self, mock_mass: MagicMock, announcements: dict[str, object]
3529 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
3530 """Create a controller and a player with native announcement support."""
3531 controller = PlayerController(mock_mass)
3532 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3533 player = MockPlayer(provider, "player_1", "Player 1")
3534 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3535 player._cache.clear()
3536 controller._players = {"player_1": player}
3537 mock_mass.players = controller
3538 render = MagicMock()
3539 render.wait_ready = AsyncMock(return_value=True)
3540 render.wait_finished = AsyncMock(return_value=3.0)
3541
3542 # mimic the real renderer: it owns which announcement each player is playing
3543 def _register(player_id: str, announce_data: object) -> MagicMock:
3544 announcements[player_id] = announce_data
3545 return render
3546
3547 async def _unregister(player_id: str, _render: object) -> None:
3548 announcements.pop(player_id, None)
3549
3550 renderer = mock_mass.streams.announcement_renderer
3551 renderer.register = MagicMock(side_effect=_register)
3552 renderer.unregister = AsyncMock(side_effect=_unregister)
3553 mock_mass.streams.get_announcement_url = MagicMock(
3554 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
3555 )
3556 player.update_state(signal_event=False)
3557 return controller, player, render
3558
3559 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
3560 """The registered announcement data is released once playback finished."""
3561 announcements: dict[str, object] = {}
3562 controller, player, _render = self._make_player(mock_mass, announcements)
3563
3564 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
3565 # entry must exist while the announcement is being played/served
3566 assert "player_1" in announcements
3567
3568 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
3569
3570 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3571
3572 player.play_announcement.assert_awaited_once()
3573 assert announcements == {}
3574 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3575
3576 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
3577 """The registered announcement data is released even when playback fails."""
3578 announcements: dict[str, object] = {}
3579 controller, player, _render = self._make_player(mock_mass, announcements)
3580 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
3581
3582 with pytest.raises(PlayerCommandFailed):
3583 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3584
3585 assert announcements == {}
3586 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3587
3588 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
3589 """A native implementation is handed the url as soon as there is audio to serve."""
3590 announcements: dict[str, object] = {}
3591 controller, player, render = self._make_player(mock_mass, announcements)
3592 player.play_announcement = AsyncMock() # type: ignore[method-assign]
3593
3594 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3595
3596 player.play_announcement.assert_awaited_once()
3597 # waiting for the whole clip here would delay the player for a slow source;
3598 # the length is resolved downstream while it plays
3599 render.wait_ready.assert_awaited_once()
3600 render.wait_finished.assert_not_awaited()
3601
3602
3603class _AnnounceSetup(NamedTuple):
3604 """A player announcing through a linked protocol output, with the calls it makes mocked."""
3605
3606 controller: PlayerController
3607 parent: MockPlayer
3608 output: MockPlayer
3609 volume_set: AsyncMock
3610 play_announcement: AsyncMock
3611
3612
3613@pytest.mark.usefixtures("running_background_tasks")
3614class TestNativeAnnouncementVolumeRouting:
3615 """The announcement volume is applied through the control that owns it."""
3616
3617 ANNOUNCE_VOLUME = 45
3618
3619 def _make_setup(self, mock_mass: MagicMock, volume_control: str) -> _AnnounceSetup:
3620 """
3621 Create a player announcing through a linked protocol output.
3622
3623 The parent holds a sibling interface and a bridge riding on the announcing
3624 output, so any of them can be named as its volume control.
3625
3626 :param volume_control: Value of the parent's volume control config entry.
3627 """
3628 controller = PlayerController(mock_mass)
3629 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3630 parent = MockPlayer(provider, "parent", "Parent")
3631 output = MockPlayer(provider, "output", "Output")
3632 output._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3633 output._attr_supported_features.add(PlayerFeature.VOLUME_SET)
3634 sibling = MockPlayer(provider, "sibling", "Sibling")
3635 sibling._attr_supported_features.add(PlayerFeature.VOLUME_SET)
3636 sibling._attr_volume_level = 20
3637 bridge = MockPlayer(provider, "bridge", "Bridge")
3638 bridge._attr_supported_features.add(PlayerFeature.VOLUME_SET)
3639 bridge._attr_underlying_player_id = "output"
3640 bridge._attr_volume_level = 20
3641 controller._players = {
3642 "parent": parent,
3643 "output": output,
3644 "sibling": sibling,
3645 "bridge": bridge,
3646 }
3647 # an external control (e.g. a Home Assistant volume entity) is not a player
3648 controller._controls = {
3649 "ha_volume": PlayerControl(
3650 id="ha_volume",
3651 provider="hass",
3652 name="Amplifier volume",
3653 supports_volume=True,
3654 volume_level=20,
3655 )
3656 }
3657 mock_mass.players = controller
3658 mock_mass.config.get_raw_player_config_value = MagicMock(
3659 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: volume_control})
3660 )
3661 for player in controller._players.values():
3662 player._cache.clear()
3663 player.update_state(signal_event=False)
3664 play_announcement = AsyncMock()
3665 volume_set = AsyncMock()
3666 output.play_announcement = play_announcement # type: ignore[method-assign]
3667 controller._handle_cmd_volume_set = volume_set # type: ignore[method-assign]
3668 return _AnnounceSetup(controller, parent, output, volume_set, play_announcement)
3669
3670 async def _announce(self, setup: _AnnounceSetup) -> None:
3671 """Play an announcement on the parent, rendered by the linked output."""
3672 await setup.controller._play_native_announcement(
3673 setup.parent, setup.output, _announcement(), self.ANNOUNCE_VOLUME
3674 )
3675
3676 async def test_external_control_applies_the_announcement_volume(
3677 self, mock_mass: MagicMock
3678 ) -> None:
3679 """A sibling interface owning the volume gets the announcement volume, not the output."""
3680 setup = self._make_setup(mock_mass, "sibling")
3681
3682 await self._announce(setup)
3683
3684 # the output cannot attenuate what another control is already attenuating,
3685 # so the level goes through that control and the output announces at unity
3686 assert setup.volume_set.await_args_list == [
3687 call("parent", self.ANNOUNCE_VOLUME),
3688 call("parent", 20),
3689 ]
3690 setup.play_announcement.assert_awaited_once_with(ANY, None)
3691
3692 async def test_player_control_applies_the_announcement_volume(
3693 self, mock_mass: MagicMock
3694 ) -> None:
3695 """A player control owning the volume (e.g. an HA entity) gets the announcement volume."""
3696 setup = self._make_setup(mock_mass, "ha_volume")
3697
3698 await self._announce(setup)
3699
3700 assert setup.volume_set.await_args_list == [
3701 call("parent", self.ANNOUNCE_VOLUME),
3702 call("parent", 20),
3703 ]
3704 setup.play_announcement.assert_awaited_once_with(ANY, None)
3705
3706 async def test_external_control_volume_restored_when_the_announcement_fails(
3707 self, mock_mass: MagicMock
3708 ) -> None:
3709 """The temporary volume is restored even when the announcement itself fails."""
3710 setup = self._make_setup(mock_mass, "sibling")
3711 setup.play_announcement.side_effect = RuntimeError("boom")
3712
3713 with pytest.raises(RuntimeError):
3714 await self._announce(setup)
3715
3716 assert setup.volume_set.await_args_list[-1] == call("parent", 20)
3717
3718 async def test_announcing_output_keeps_the_announcement_volume(
3719 self, mock_mass: MagicMock
3720 ) -> None:
3721 """An output that owns the volume applies the announcement volume itself."""
3722 setup = self._make_setup(mock_mass, "output")
3723
3724 await self._announce(setup)
3725
3726 setup.volume_set.assert_not_awaited()
3727 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3728
3729 async def test_bridge_on_the_announcing_output_keeps_the_announcement_volume(
3730 self, mock_mass: MagicMock
3731 ) -> None:
3732 """A bridge riding on the announcing output forwards the volume to it."""
3733 setup = self._make_setup(mock_mass, "bridge")
3734
3735 await self._announce(setup)
3736
3737 setup.volume_set.assert_not_awaited()
3738 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3739
3740 async def test_native_volume_is_applied_through_the_parent(self, mock_mass: MagicMock) -> None:
3741 """
3742 A native volume lives on the parent, so the parent applies and restores it.
3743
3744 The rendering output has no way to reach a native parent volume, and its own
3745 idea of the level can be stale, so routing through the parent keeps both the
3746 announcement level and the restore on the control that actually knows it.
3747 """
3748 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
3749 setup.parent._attr_volume_level = 20
3750 setup.parent._cache.clear()
3751 setup.parent.update_state(signal_event=False)
3752
3753 await self._announce(setup)
3754
3755 assert setup.volume_set.await_args_list == [
3756 call("parent", self.ANNOUNCE_VOLUME),
3757 call("parent", 20),
3758 ]
3759 setup.play_announcement.assert_awaited_once_with(ANY, None)
3760
3761 async def test_native_volume_on_its_own_output_is_kept_by_the_player(
3762 self, mock_mass: MagicMock
3763 ) -> None:
3764 """A player announcing on its own output can apply its native volume itself."""
3765 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
3766 play_announcement = AsyncMock()
3767 setup.parent.play_announcement = play_announcement # type: ignore[method-assign]
3768
3769 await setup.controller._play_native_announcement(
3770 setup.parent, setup.parent, _announcement(), self.ANNOUNCE_VOLUME
3771 )
3772
3773 setup.volume_set.assert_not_awaited()
3774 play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3775
3776 async def test_without_volume_control_no_volume_is_applied(self, mock_mass: MagicMock) -> None:
3777 """Nothing in the signal path can set a volume, so the announcement plays as-is."""
3778 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NONE)
3779
3780 await self._announce(setup)
3781
3782 setup.volume_set.assert_not_awaited()
3783 setup.play_announcement.assert_awaited_once_with(ANY, None)
3784
3785
3786class TestPlayAnnouncementMessage:
3787 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
3788
3789 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
3790
3791 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
3792 """Create a TTS engine that renders every message to the given path."""
3793 engine = MagicMock()
3794 engine.uid = "tts_plugin/voice"
3795 engine.id = "voice"
3796 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
3797 return engine
3798
3799 def _make_player(
3800 self, mock_mass: MagicMock, announcements: dict[str, object]
3801 ) -> tuple[PlayerController, AsyncMock]:
3802 """Create a controller and a player with native announcement support."""
3803 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
3804 mock_mass, announcements
3805 )
3806 announce = AsyncMock()
3807 player.play_announcement = announce # type: ignore[method-assign]
3808 return controller, announce
3809
3810 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
3811 """A message is rendered by the default engine and announced as the rendered audio."""
3812 announcements: dict[str, object] = {}
3813 controller, announce = self._make_player(mock_mass, announcements)
3814 engine = self._make_engine()
3815
3816 with patch(
3817 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3818 ):
3819 await controller.play_announcement("player_1", message="dinner is ready")
3820
3821 # no language is sent, so the engine speaks in the language it is configured for
3822 engine.provider.get_tts_message.assert_awaited_once_with(
3823 "dinner is ready", language=None, engine_id="voice", options=None
3824 )
3825 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3826 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3827 announce.assert_awaited_once()
3828
3829 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
3830 """A message can name the language to speak it in."""
3831 announcements: dict[str, object] = {}
3832 controller, _announce = self._make_player(mock_mass, announcements)
3833 engine = self._make_engine()
3834
3835 with patch(
3836 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3837 ):
3838 await controller.play_announcement(
3839 "player_1", message="het eten is klaar", language="nl-NL"
3840 )
3841
3842 engine.provider.get_tts_message.assert_awaited_once_with(
3843 "het eten is klaar", language="nl-NL", engine_id="voice", options=None
3844 )
3845
3846 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
3847 """An engine that rejects the language speaks the message in its default voice."""
3848 announcements: dict[str, object] = {}
3849 controller, announce = self._make_player(mock_mass, announcements)
3850 engine = self._make_engine()
3851 engine.provider.get_tts_message = AsyncMock(
3852 side_effect=[
3853 RuntimeError("unsupported language"),
3854 SimpleNamespace(path="http://speech/spoken.mp3"),
3855 ]
3856 )
3857
3858 with patch(
3859 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3860 ):
3861 await controller.play_announcement(
3862 "player_1", message="dinner is ready", language="en-US"
3863 )
3864
3865 first_call, second_call = engine.provider.get_tts_message.await_args_list
3866 assert first_call.kwargs["language"] == "en-US"
3867 assert second_call.kwargs["language"] is None
3868 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3869 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3870 announce.assert_awaited_once()
3871
3872 @pytest.mark.parametrize(
3873 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
3874 )
3875 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
3876 self, mock_mass: MagicMock, error: Exception
3877 ) -> None:
3878 """A timeout or a structured failure is no language rejection, so it is not retried."""
3879 announcements: dict[str, object] = {}
3880 controller, announce = self._make_player(mock_mass, announcements)
3881 engine = self._make_engine()
3882 engine.provider.get_tts_message = AsyncMock(side_effect=error)
3883
3884 with (
3885 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3886 pytest.raises(MusicAssistantError),
3887 ):
3888 await controller.play_announcement("player_1", message="dinner is ready")
3889
3890 engine.provider.get_tts_message.assert_awaited_once()
3891 announce.assert_not_awaited()
3892
3893 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
3894 """A message names the engine to speak it, overriding the configured default."""
3895 announcements: dict[str, object] = {}
3896 controller, _announce = self._make_player(mock_mass, announcements)
3897 engine = self._make_engine()
3898
3899 with (
3900 patch(
3901 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
3902 ) as resolve,
3903 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
3904 ):
3905 await controller.play_announcement(
3906 "player_1", message="hello", tts_engine="tts_plugin/voice"
3907 )
3908
3909 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
3910 select.assert_not_awaited()
3911
3912 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
3913 """A spoken message uses the player's pre-announce setting without sniffing the url."""
3914 announcements: dict[str, object] = {}
3915 controller, _announce = self._make_player(mock_mass, announcements)
3916 engine = self._make_engine()
3917 mock_mass.config.get_raw_player_config_value = MagicMock(
3918 side_effect=lambda _player_id, key, default=None: (
3919 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
3920 )
3921 )
3922
3923 with patch(
3924 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3925 ):
3926 await controller.play_announcement("player_1", message="dinner is ready")
3927
3928 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3929 assert registered["pre_announce"] is True
3930
3931 async def test_the_engine_gets_the_shorter_announcement_timeout(
3932 self, mock_mass: MagicMock
3933 ) -> None:
3934 """The engine is capped well below the background default, it holds the player lock."""
3935 announcements: dict[str, object] = {}
3936 controller, _announce = self._make_player(mock_mass, announcements)
3937 engine = self._make_engine()
3938 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
3939
3940 with (
3941 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3942 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
3943 ):
3944 await controller.play_announcement("player_1", message="hello")
3945
3946 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
3947 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
3948
3949 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
3950 """Naming an engine for a url announcement is rejected instead of silently ignored."""
3951 announcements: dict[str, object] = {}
3952 controller, _announce = self._make_player(mock_mass, announcements)
3953
3954 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
3955 await controller.play_announcement(
3956 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
3957 )
3958
3959 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
3960 """Naming a language for a url announcement is rejected instead of silently ignored."""
3961 announcements: dict[str, object] = {}
3962 controller, _announce = self._make_player(mock_mass, announcements)
3963
3964 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
3965 await controller.play_announcement(
3966 "player_1", url="http://test/clip.mp3", language="nl-NL"
3967 )
3968
3969 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
3970 """An engine that fails to speak the message fails the announcement."""
3971 announcements: dict[str, object] = {}
3972 controller, announce = self._make_player(mock_mass, announcements)
3973 engine = self._make_engine()
3974 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
3975
3976 with (
3977 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3978 pytest.raises(PlayerCommandFailed),
3979 ):
3980 await controller.play_announcement("player_1", message="hello")
3981
3982 announce.assert_not_awaited()
3983 mock_mass.streams.announcement_renderer.register.assert_not_called()
3984
3985 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
3986 """An announcement with neither a url nor a message is rejected."""
3987 announcements: dict[str, object] = {}
3988 controller, _announce = self._make_player(mock_mass, announcements)
3989
3990 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
3991 await controller.play_announcement("player_1")
3992
3993 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
3994 """An announcement carrying both a url and a message is rejected."""
3995 announcements: dict[str, object] = {}
3996 controller, _announce = self._make_player(mock_mass, announcements)
3997
3998 with pytest.raises(PlayerCommandFailed, match="not both"):
3999 await controller.play_announcement(
4000 "player_1", url="http://test/clip.mp3", message="hello"
4001 )
4002
4003 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
4004 """A message naming an engine that does not exist fails instead of using another."""
4005 announcements: dict[str, object] = {}
4006 controller, _announce = self._make_player(mock_mass, announcements)
4007
4008 with (
4009 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
4010 pytest.raises(PlayerCommandFailed, match="is not available"),
4011 ):
4012 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
4013
4014 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
4015 """A message fails clearly when no TTS engine is set up at all."""
4016 announcements: dict[str, object] = {}
4017 controller, _announce = self._make_player(mock_mass, announcements)
4018
4019 with (
4020 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
4021 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
4022 ):
4023 await controller.play_announcement("player_1", message="hello")
4024
4025 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
4026 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
4027 announcements: dict[str, object] = {}
4028 controller, _announce = self._make_player(mock_mass, announcements)
4029 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
4030
4031 with (
4032 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4033 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
4034 ):
4035 await controller.play_announcement("player_1", message="hello")
4036
4037 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
4038 """The message is spoken once and every group member announces the resulting audio."""
4039 announcements: dict[str, object] = {}
4040 use_real_create_task(mock_mass)
4041 controller, _announce = self._make_player(mock_mass, announcements)
4042 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4043 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
4044 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4045 group._attr_group_members = ["player_1"]
4046 group._cache.clear()
4047 controller._players["group_1"] = group
4048 group.update_state(signal_event=False)
4049 engine = self._make_engine()
4050
4051 with patch(
4052 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4053 ):
4054 await controller.play_announcement("group_1", message="dinner is ready")
4055
4056 # rendered once for the group, then handed to the member as plain audio
4057 engine.provider.get_tts_message.assert_awaited_once()
4058 member_call = next(
4059 call_args
4060 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
4061 if call_args.args[0] == "player_1"
4062 )
4063 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
4064
4065
4066class TestNativeAnnouncementRouting:
4067 """Announcement routing respects the player's own support and its active output."""
4068
4069 def _make_player_with_linked_child(
4070 self,
4071 mock_mass: MagicMock,
4072 playback_state: PlaybackState,
4073 *,
4074 parent_supports_announce: bool = False,
4075 active_protocol: str | None = None,
4076 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
4077 """Create a controller, a player, its linked protocol child and the two path mocks."""
4078 controller = PlayerController(mock_mass)
4079 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4080 player = MockPlayer(provider, "player_1", "Player 1")
4081 player._attr_playback_state = playback_state
4082 if parent_supports_announce:
4083 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4084 proto_provider = MockProvider("airplay", mass=mock_mass)
4085 proto = MockPlayer(
4086 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
4087 )
4088 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4089 controller._players = {"player_1": player, "proto_1": proto}
4090 mock_mass.players = controller
4091 player.set_linked_output_protocols(
4092 [
4093 LinkedOutputProtocol(
4094 output_protocol_id="proto_1",
4095 protocol_domain="airplay",
4096 priority=40,
4097 )
4098 ]
4099 )
4100 if active_protocol is not None:
4101 player.set_active_output_protocol(active_protocol)
4102 render = MagicMock()
4103 render.wait_ready = AsyncMock(return_value=True)
4104 renderer = mock_mass.streams.announcement_renderer
4105 renderer.register = MagicMock(return_value=render)
4106 renderer.unregister = AsyncMock()
4107 mock_mass.streams.get_announcement_url = MagicMock(
4108 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
4109 )
4110 proto.update_state(signal_event=False)
4111 player.update_state(signal_event=False)
4112 native_path = AsyncMock()
4113 generic_path = AsyncMock()
4114 controller._play_native_announcement = native_path # type: ignore[method-assign]
4115 controller._play_announcement = generic_path # type: ignore[method-assign]
4116 return controller, player, proto, native_path, generic_path
4117
4118 async def test_playing_player_does_not_route_to_an_idle_linked_child(
4119 self, mock_mass: MagicMock
4120 ) -> None:
4121 """
4122 A player rendering through one output must not announce through another.
4123
4124 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
4125 the announcement to the child would seize the device from the native
4126 output, with nothing restoring that playback afterwards.
4127 """
4128 controller, _player, _proto, native_path, generic_path = (
4129 self._make_player_with_linked_child(
4130 mock_mass, PlaybackState.PLAYING, active_protocol="native"
4131 )
4132 )
4133
4134 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4135
4136 native_path.assert_not_awaited()
4137 generic_path.assert_awaited_once()
4138
4139 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
4140 """An idle player announces natively through any capable linked protocol."""
4141 controller, _player, proto, native_path, _generic_path = (
4142 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
4143 )
4144
4145 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4146
4147 native_path.assert_awaited_once()
4148 assert native_path.call_args.args[1] is proto
4149
4150 async def test_active_protocol_child_beats_own_native_support(
4151 self, mock_mass: MagicMock
4152 ) -> None:
4153 """
4154 The output that is actively rendering wins over the player's own support.
4155
4156 E.g. a Sonos playing through its AirPlay child: the announcement rides
4157 the same audio path as the music (mixed into the live stream, in sync
4158 with the rest of a group) instead of a second mechanism firing beside
4159 the playback.
4160 """
4161 controller, _player, proto, native_path, _generic_path = (
4162 self._make_player_with_linked_child(
4163 mock_mass,
4164 PlaybackState.PLAYING,
4165 parent_supports_announce=True,
4166 active_protocol="proto_1",
4167 )
4168 )
4169
4170 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4171
4172 native_path.assert_awaited_once()
4173 assert native_path.call_args.args[1] is proto
4174
4175 async def test_own_native_support_wins_when_playing_natively(
4176 self, mock_mass: MagicMock
4177 ) -> None:
4178 """A player rendering through its own native output announces natively."""
4179 controller, player, _proto, native_path, _generic_path = (
4180 self._make_player_with_linked_child(
4181 mock_mass,
4182 PlaybackState.PLAYING,
4183 parent_supports_announce=True,
4184 active_protocol="native",
4185 )
4186 )
4187
4188 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4189
4190 native_path.assert_awaited_once()
4191 assert native_path.call_args.args[1] is player
4192
4193 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
4194 self, mock_mass: MagicMock
4195 ) -> None:
4196 """Without active playback the player's own announcement support wins."""
4197 controller, player, _proto, native_path, _generic_path = (
4198 self._make_player_with_linked_child(
4199 mock_mass,
4200 PlaybackState.IDLE,
4201 parent_supports_announce=True,
4202 )
4203 )
4204
4205 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4206
4207 native_path.assert_awaited_once()
4208 assert native_path.call_args.args[1] is player
4209
4210
4211@pytest.mark.usefixtures("running_background_tasks")
4212class TestPlayAnnouncementRestore:
4213 """Test the state restore of the default (fallback) announcement implementation."""
4214
4215 def _make_player(
4216 self, mock_mass: MagicMock, prev_media: PlayerMedia
4217 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4218 """Create a controller and a playing player, returning the patched resume handler."""
4219 controller = PlayerController(mock_mass)
4220 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4221 player = MockPlayer(provider, "player_1", "Player 1")
4222 player._attr_playback_state = PlaybackState.PLAYING
4223 player._attr_current_media = prev_media
4224 player._cache.clear()
4225 controller._players = {"player_1": player}
4226 mock_mass.players = controller
4227 mock_mass.player_queues.get = MagicMock(return_value=None)
4228 resume_mock = AsyncMock()
4229 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
4230 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
4231 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
4232 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
4233 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4234 player.set_initialized()
4235 player.update_state(signal_event=False)
4236 return controller, player, resume_mock
4237
4238 @staticmethod
4239 def _add_group(
4240 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
4241 ) -> MockPlayer:
4242 """Register a powered group player that holds the given player as its member."""
4243 group = MockPlayer(
4244 cast("MockProvider", player.provider),
4245 "group_1",
4246 "Group 1",
4247 player_type=PlayerType.GROUP,
4248 )
4249 group._attr_powered = True
4250 group._attr_group_members = [player.player_id]
4251 if supports_set_members:
4252 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4253 controller._players[group.player_id] = group
4254 group.set_initialized()
4255 group._cache.clear()
4256 group.update_state(signal_event=False)
4257 # the member has no own input change to trigger a recalculation of its
4258 # (group derived) state, so force it here - just like register() does
4259 player._cache.clear()
4260 player.update_state(force_update=True, signal_event=False)
4261 assert player.state.active_group == group.player_id
4262 return group
4263
4264 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
4265 """Content that was playing before the announcement is resumed afterwards."""
4266 controller, player, resume_mock = self._make_player(
4267 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4268 )
4269
4270 await controller._play_announcement(player, _announcement())
4271
4272 resume_mock.assert_awaited_once()
4273
4274 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
4275 """A player still busy with an earlier announcement has no playback to restore."""
4276 controller, player, resume_mock = self._make_player(
4277 mock_mass,
4278 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
4279 )
4280
4281 await controller._play_announcement(player, _announcement())
4282
4283 resume_mock.assert_not_awaited()
4284
4285 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4286 """A failing announcement never leaves the player at the raised volume."""
4287 controller, player, _ = self._make_player(
4288 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4289 )
4290 player._attr_volume_level = 20
4291 player._cache.clear()
4292 player.update_state(force_update=True, signal_event=False)
4293 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4294 volume_mock = AsyncMock()
4295 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4296 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4297 side_effect=PlayerCommandFailed("player went away")
4298 )
4299
4300 with pytest.raises(PlayerCommandFailed):
4301 await controller._play_announcement(player, _announcement())
4302
4303 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
4304
4305 async def test_zero_announcement_volume_is_applied_and_restored(
4306 self, mock_mass: MagicMock
4307 ) -> None:
4308 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
4309 controller, player, _ = self._make_player(
4310 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4311 )
4312 player._attr_volume_level = 20
4313 player._cache.clear()
4314 player.update_state(force_update=True, signal_event=False)
4315 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
4316 volume_mock = AsyncMock()
4317 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4318
4319 await controller._play_announcement(player, _announcement())
4320
4321 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
4322
4323 async def test_playback_is_restored_when_duration_is_unknown(
4324 self, mock_mass: MagicMock
4325 ) -> None:
4326 """An announcement of unknown length still hands the player back to its content."""
4327 controller, player, resume_mock = self._make_player(
4328 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4329 )
4330 announcement = _announcement()
4331 announcement.duration = None
4332
4333 # an unknown length waits for the player to report it finished instead of failing
4334 await controller._play_announcement(player, announcement)
4335
4336 resume_mock.assert_awaited_once()
4337
4338 async def test_group_membership_is_restored_when_playback_fails(
4339 self, mock_mass: MagicMock
4340 ) -> None:
4341 """A failing announcement never leaves the player out of its group player."""
4342 controller, player, _ = self._make_player(
4343 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4344 )
4345 group = self._add_group(controller, player, supports_set_members=True)
4346 group.set_members = AsyncMock() # type: ignore[method-assign]
4347 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4348 side_effect=PlayerCommandFailed("player went away")
4349 )
4350
4351 with pytest.raises(PlayerCommandFailed):
4352 await controller._play_announcement(player, _announcement())
4353
4354 assert group.set_members.await_args_list == [
4355 call(player_ids_to_remove=["player_1"]),
4356 call(player_ids_to_add=["player_1"]),
4357 ]
4358
4359 async def test_restore_failure_does_not_mask_the_announcement_error(
4360 self, mock_mass: MagicMock
4361 ) -> None:
4362 """A provider blowing up during the restore must not hide why the announcement failed."""
4363 controller, player, _ = self._make_player(
4364 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4365 )
4366 group = self._add_group(controller, player, supports_set_members=True)
4367 # set_members is a raw provider call: whatever its client library raises comes
4368 # through unwrapped, so the ungroup succeeds and the regroup times out
4369 group.set_members = AsyncMock( # type: ignore[method-assign]
4370 side_effect=[None, TimeoutError("provider timeout")]
4371 )
4372 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4373 side_effect=PlayerCommandFailed("player went away")
4374 )
4375
4376 with pytest.raises(PlayerCommandFailed, match="player went away"):
4377 await controller._play_announcement(player, _announcement())
4378
4379 async def test_group_without_set_members_is_the_one_powered_off(
4380 self, mock_mass: MagicMock
4381 ) -> None:
4382 """A group that can not release members is powered off, not the announcement target."""
4383 controller, player, _ = self._make_player(
4384 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4385 )
4386 self._add_group(controller, player, supports_set_members=False)
4387 power_mock = AsyncMock()
4388 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
4389 play_mock = AsyncMock()
4390 controller.cmd_play = play_mock # type: ignore[method-assign]
4391
4392 await controller._play_announcement(player, _announcement())
4393
4394 # the group is switched off for the announcement and restarted afterwards
4395 power_mock.assert_awaited_once_with("group_1", False)
4396 play_mock.assert_awaited_once_with("group_1")
4397
4398 async def test_idle_player_without_power_control_is_regrouped(
4399 self, mock_mass: MagicMock
4400 ) -> None:
4401 """An idle player that has no power state to restore is still put back in its group."""
4402 controller, player, _ = self._make_player(
4403 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4404 )
4405 player._attr_playback_state = PlaybackState.IDLE
4406 player._attr_powered = None
4407 group = self._add_group(controller, player, supports_set_members=True)
4408 assert player.state.power_control == PLAYER_CONTROL_NONE
4409 group.set_members = AsyncMock() # type: ignore[method-assign]
4410
4411 await controller._play_announcement(player, _announcement())
4412
4413 assert group.set_members.await_args_list == [
4414 call(player_ids_to_remove=["player_1"]),
4415 call(player_ids_to_add=["player_1"]),
4416 ]
4417
4418 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4419 """A muted player hears the announcement and is muted again afterwards."""
4420 controller, player, _ = self._make_player(
4421 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4422 )
4423 mute_mock = _mute_natively(player)
4424
4425 await controller._play_announcement(player, _announcement())
4426
4427 assert mute_mock.await_args_list == [call(False), call(True)]
4428
4429 async def test_player_without_volume_control_is_still_unmuted(
4430 self, mock_mass: MagicMock
4431 ) -> None:
4432 """A player that can only be muted is unmuted for the announcement all the same."""
4433 mock_mass.config.get_raw_player_config_value = MagicMock(
4434 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
4435 )
4436 controller, player, _ = self._make_player(
4437 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4438 )
4439 mute_mock = _mute_natively(player)
4440 assert player.state.volume_control == PLAYER_CONTROL_NONE
4441
4442 await controller._play_announcement(player, _announcement())
4443
4444 assert mute_mock.await_args_list == [call(False), call(True)]
4445
4446 async def test_mute_is_restored_before_the_player_is_regrouped(
4447 self, mock_mass: MagicMock
4448 ) -> None:
4449 """A player is handed back to its group already muted, holding on to its mute lock."""
4450 controller, player, _ = self._make_player(
4451 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4452 )
4453 group = self._add_group(controller, player, supports_set_members=True)
4454 real_set_members = group.set_members
4455
4456 async def _set_members(**kwargs: list[str]) -> None:
4457 # let the membership really change, so the player is ungrouped while the
4458 # announcement plays - just like it is in production. neither player picks
4459 # the new membership up on its own here, so publish it on both.
4460 await real_set_members(**kwargs)
4461 group.update_state(force_update=True, signal_event=False)
4462 player._cache.clear()
4463 player.update_state(force_update=True, signal_event=False)
4464
4465 set_members = AsyncMock(side_effect=_set_members)
4466 group.set_members = set_members # type: ignore[method-assign]
4467 player.extra_data[ATTR_MUTE_LOCK] = True
4468 recorder = MagicMock()
4469 recorder.attach_mock(_mute_natively(player), "mute")
4470 recorder.attach_mock(set_members, "set_members")
4471
4472 await controller._play_announcement(player, _announcement())
4473
4474 assert recorder.mock_calls == [
4475 call.set_members(player_ids_to_remove=["player_1"]),
4476 call.mute(False),
4477 call.mute(True),
4478 call.set_members(player_ids_to_add=["player_1"]),
4479 ]
4480 # the lock survives the announcement, so the regroup does not unmute the player
4481 assert player.extra_data[ATTR_MUTE_LOCK] is True
4482
4483 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4484 """A player that was not muted is never sent a mute command."""
4485 controller, player, _ = self._make_player(
4486 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4487 )
4488 mute_mock = _mute_natively(player)
4489 player._attr_volume_muted = False
4490 player._cache.clear()
4491 player.update_state(force_update=True, signal_event=False)
4492 mute_mock.reset_mock()
4493
4494 await controller._play_announcement(player, _announcement())
4495
4496 mute_mock.assert_not_awaited()
4497
4498 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4499 """A failing announcement never leaves the player unmuted."""
4500 controller, player, _ = self._make_player(
4501 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4502 )
4503 mute_mock = _mute_natively(player)
4504 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4505 side_effect=PlayerCommandFailed("player went away")
4506 )
4507
4508 with pytest.raises(PlayerCommandFailed):
4509 await controller._play_announcement(player, _announcement())
4510
4511 assert mute_mock.await_args_list == [call(False), call(True)]
4512
4513 async def test_muted_sync_group_members_all_hear_the_announcement(
4514 self, mock_mass: MagicMock
4515 ) -> None:
4516 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4517 controller, leader, _ = self._make_player(
4518 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4519 )
4520 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4521 controller._players["player_2"] = member
4522 member.set_initialized()
4523 leader._attr_group_members = ["player_1", "player_2"]
4524 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4525 # both members were muted while grouped, so both hold a mute lock
4526 for player in (leader, member):
4527 player.extra_data[ATTR_MUTE_LOCK] = True
4528
4529 await controller._play_announcement(leader, _announcement())
4530
4531 for player_id, mute_mock in mute_mocks.items():
4532 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4533 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4534
4535 async def test_fake_muted_player_announces_at_its_real_volume(
4536 self, mock_mass: MagicMock
4537 ) -> None:
4538 """A fake muted player announces at its real volume, not at the zero it is parked on."""
4539 mock_mass.config.get_raw_player_config_value = MagicMock(
4540 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4541 )
4542 controller, player, _ = self._make_player(
4543 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4544 )
4545
4546 def _apply_volume(volume: int) -> None:
4547 player._attr_volume_level = volume
4548 player.update_state(signal_event=False)
4549
4550 player._attr_volume_level = 40
4551 volume_set = AsyncMock(side_effect=_apply_volume)
4552 player.volume_set = volume_set # type: ignore[method-assign]
4553 player._cache.clear()
4554 player.update_state(force_update=True, signal_event=False)
4555 await controller.cmd_volume_mute("player_1", True)
4556 assert player.state.volume_muted is True
4557 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4558
4559 await controller._play_announcement(player, _announcement())
4560
4561 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
4562 assert volume_set.await_args_list == [
4563 call(0),
4564 call(40),
4565 call(80),
4566 call(40),
4567 call(0),
4568 ]
4569 assert player.state.volume_muted is True
4570 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4571
4572
4573@pytest.mark.usefixtures("running_background_tasks")
4574class TestPlayNativeAnnouncement:
4575 """Test the mute handling around an announcement that a player plays natively."""
4576
4577 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4578 """Create a controller and a player with native announcement support."""
4579 controller = PlayerController(mock_mass)
4580 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4581 player = MockPlayer(provider, "player_1", "Player 1")
4582 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4583 player._cache.clear()
4584 controller._players = {"player_1": player}
4585 mock_mass.players = controller
4586 mock_mass.player_queues.get = MagicMock(return_value=None)
4587 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4588 announce_mock = AsyncMock()
4589 player.play_announcement = announce_mock # type: ignore[method-assign]
4590 player.set_initialized()
4591 player.update_state(signal_event=False)
4592 return controller, player, announce_mock
4593
4594 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4595 """A muted player hears the announcement and is muted again afterwards."""
4596 controller, player, announce_mock = self._make_player(mock_mass)
4597 recorder = MagicMock()
4598 recorder.attach_mock(_mute_natively(player), "mute")
4599 recorder.attach_mock(announce_mock, "announce")
4600
4601 await controller._play_native_announcement(player, player, _announcement(), None)
4602
4603 assert recorder.mock_calls == [
4604 call.mute(False),
4605 call.announce(ANY, None),
4606 call.mute(True),
4607 ]
4608
4609 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4610 """A player that was not muted is never sent a mute command."""
4611 controller, player, _ = self._make_player(mock_mass)
4612 mute_mock = _mute_natively(player)
4613 player._attr_volume_muted = False
4614 player._cache.clear()
4615 player.update_state(force_update=True, signal_event=False)
4616 mute_mock.reset_mock()
4617
4618 await controller._play_native_announcement(player, player, _announcement(), None)
4619
4620 mute_mock.assert_not_awaited()
4621
4622 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
4623 """A failing announcement never leaves the player unmuted."""
4624 controller, player, announce_mock = self._make_player(mock_mass)
4625 mute_mock = _mute_natively(player)
4626 announce_mock.side_effect = PlayerCommandFailed("player went away")
4627
4628 with pytest.raises(PlayerCommandFailed):
4629 await controller._play_native_announcement(player, player, _announcement(), None)
4630
4631 assert mute_mock.await_args_list == [call(False), call(True)]
4632
4633 async def test_muted_sync_group_members_all_hear_the_announcement(
4634 self, mock_mass: MagicMock
4635 ) -> None:
4636 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4637 controller, leader, _ = self._make_player(mock_mass)
4638 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4639 controller._players["player_2"] = member
4640 member.set_initialized()
4641 leader._attr_group_members = ["player_1", "player_2"]
4642 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4643 # both members were muted while grouped, so both hold a mute lock
4644 for player in (leader, member):
4645 player.extra_data[ATTR_MUTE_LOCK] = True
4646
4647 await controller._play_native_announcement(leader, leader, _announcement(), None)
4648
4649 for player_id, mute_mock in mute_mocks.items():
4650 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4651 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4652
4653 async def test_fake_muted_player_announces_at_its_real_volume(
4654 self, mock_mass: MagicMock
4655 ) -> None:
4656 """The announcement volume is resolved after the unmute, not from the parked zero."""
4657 mock_mass.config.get_raw_player_config_value = MagicMock(
4658 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4659 )
4660 controller, player, announce_mock = self._make_player(mock_mass)
4661
4662 def _apply_volume(volume: int) -> None:
4663 player._attr_volume_level = volume
4664 player.update_state(signal_event=False)
4665
4666 player._attr_volume_level = 40
4667 volume_set = AsyncMock(side_effect=_apply_volume)
4668 player.volume_set = volume_set # type: ignore[method-assign]
4669 player._cache.clear()
4670 player.update_state(force_update=True, signal_event=False)
4671 await controller.cmd_volume_mute("player_1", True)
4672 assert player.state.volume_muted is True
4673 # stand in for the configured strategy, which reads the volume of the player
4674 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
4675 side_effect=lambda _player_id, _volume_level: player.state.volume_level
4676 )
4677
4678 await controller._play_native_announcement(player, player, _announcement(), None)
4679
4680 assert announce_mock.await_args == call(ANY, 40)
4681 # unmute to 40 for the announcement, park back on 0 for the fake mute
4682 assert volume_set.await_args_list == [call(0), call(40), call(0)]
4683 assert player.state.volume_muted is True
4684 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4685
4686
4687class TestScheduleActiveOutputProtocolClear:
4688 """Test the deferred clear of a player's active output protocol."""
4689
4690 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
4691 """Scheduling defers the clear to a single, per-player, cancellable task."""
4692 controller = PlayerController(mock_mass)
4693 player = MagicMock()
4694 player.player_id = "player_1"
4695
4696 controller.schedule_active_output_protocol_clear(player)
4697
4698 mock_mass.create_task.assert_called_once()
4699 # close the coroutine passed to the mocked create_task to avoid a
4700 # "coroutine was never awaited" warning
4701 mock_mass.create_task.call_args.args[0].close()
4702 # no abort_existing: a duplicate schedule must reuse the pending clear
4703 # (deduped by task_id) instead of replacing it with an untracked task
4704 assert mock_mass.create_task.call_args.kwargs == {
4705 "task_id": "clear_active_protocol_player_1",
4706 }
4707
4708 @pytest.mark.asyncio
4709 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
4710 """The protocol is cleared after waiting for the player to reach IDLE."""
4711 controller = PlayerController(mock_mass)
4712 player = MagicMock()
4713 player.player_id = "player_1"
4714
4715 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
4716 await controller._clear_active_output_protocol_when_idle(player)
4717
4718 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
4719 player.set_active_output_protocol.assert_called_once_with(None)
4720
4721
4722@contextlib.asynccontextmanager
4723async def _skip_player_update_wait(
4724 *_args: object,
4725 **_kwargs: object,
4726) -> AsyncIterator[None]:
4727 """Skip provider-driven state propagation in command-routing tests."""
4728 yield
4729
4730
4731class TestRemovePlayerControl:
4732 """Test removing a registered player control."""
4733
4734 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
4735 """Test that only the players configured to use the removed control are refreshed."""
4736 mock_mass.loop = MagicMock()
4737 controller = PlayerController(mock_mass)
4738 using_control = MagicMock()
4739 using_control.state.power_control = "switch.amp"
4740 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
4741 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
4742 unrelated = MagicMock()
4743 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
4744 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
4745 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
4746 controller._players = {"using_control": using_control, "unrelated": unrelated}
4747 controller._controls = {
4748 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4749 }
4750
4751 controller.remove_player_control("switch.amp")
4752
4753 assert controller.player_controls() == []
4754 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
4755
4756 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
4757 """Test that a control removed and registered again re-attaches to its player."""
4758 # run the scheduled refresh straight away so each step is observable
4759 mock_mass.loop = MagicMock()
4760 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
4761 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
4762 {CONF_POWER_CONTROL: "switch.amp"}
4763 )
4764 controller = PlayerController(mock_mass)
4765 mock_mass.players = controller
4766 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4767 mock_mass.get_provider.return_value = provider
4768 player = MockPlayer(provider, "player", "Player")
4769 controller._players = {"player": player}
4770 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4771
4772 await controller.register_or_update_player_control(control)
4773 assert player.state.power_control == "switch.amp"
4774
4775 # the Home Assistant plugin drops and re-registers its controls around a reload
4776 controller.remove_player_control(control.id)
4777 assert player.state.power_control == PLAYER_CONTROL_NONE
4778
4779 await controller.register_or_update_player_control(control)
4780 assert player.state.power_control == "switch.amp"
4781
4782 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
4783 """Test that removing a control that was never registered is a no-op."""
4784 mock_mass.loop = MagicMock()
4785 controller = PlayerController(mock_mass)
4786 player = MagicMock()
4787 player.state.power_control = PLAYER_CONTROL_NATIVE
4788 player.state.volume_control = PLAYER_CONTROL_NATIVE
4789 player.state.mute_control = PLAYER_CONTROL_NATIVE
4790 controller._players = {"player": player}
4791
4792 controller.remove_player_control("switch.gone")
4793
4794 mock_mass.loop.call_soon.assert_not_called()
4795
4796
4797class _FailingTeardownPlayer(MockPlayer):
4798 """Player whose provider fails to release it."""
4799
4800 unloaded = False
4801
4802 async def on_unload(self) -> None:
4803 """Handle logic when the player is unloaded from the Player controller."""
4804 self.unloaded = True
4805 msg = "device is gone"
4806 raise RuntimeError(msg)
4807
4808
4809class TestUnregisterTeardown:
4810 """Test that a failing player teardown stays contained."""
4811
4812 async def test_failing_on_unload_still_unregisters_the_player(
4813 self, mock_mass: MagicMock
4814 ) -> None:
4815 """Test that a provider raising while releasing its player does not break unregister."""
4816 controller = PlayerController(mock_mass)
4817 mock_mass.players = controller
4818 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4819 player = _FailingTeardownPlayer(provider, "boom", "Boom")
4820 controller._players = {"boom": player}
4821
4822 await controller.unregister("boom")
4823
4824 assert "boom" not in controller._players
4825 assert player.unloaded
4826
4827
4828class TestConfigChangeRestartsPlayback:
4829 """Test that a changed player setting which needs a reload restarts playback."""
4830
4831 @staticmethod
4832 def _config(*, requires_reload: bool) -> PlayerConfig:
4833 """Build a PlayerConfig holding a single output codec entry."""
4834 return PlayerConfig(
4835 provider="test_prov",
4836 player_id="player_1",
4837 values={
4838 CONF_OUTPUT_CODEC: ConfigEntry(
4839 key=CONF_OUTPUT_CODEC,
4840 type=ConfigEntryType.STRING,
4841 label="Output codec",
4842 value="flac",
4843 requires_reload=requires_reload,
4844 )
4845 },
4846 )
4847
4848 @staticmethod
4849 def _prepare(mock_mass: MagicMock, queue_state: PlaybackState) -> PlayerController:
4850 """Register a player whose active queue is in the given state."""
4851 controller = PlayerController(mock_mass)
4852 player = MagicMock()
4853 player.state.active_source = "player_1"
4854 player.on_config_updated = AsyncMock()
4855 controller._players = {"player_1": player}
4856 queue = MagicMock()
4857 queue.queue_id = "player_1"
4858 queue.state = queue_state
4859 mock_mass.player_queues.get = MagicMock(return_value=queue)
4860 mock_mass.player_queues.stop = AsyncMock()
4861 return controller
4862
4863 async def test_reload_setting_restarts_playback(self, mock_mass: MagicMock) -> None:
4864 """Test that changing a reload-requiring setting stops and resumes the queue."""
4865 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
4866
4867 await controller.on_player_config_change(
4868 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
4869 )
4870
4871 mock_mass.player_queues.stop.assert_awaited_once_with("player_1")
4872 mock_mass.call_later.assert_called_once_with(
4873 1, mock_mass.player_queues.resume, "player_1", False
4874 )
4875
4876 async def test_plain_setting_does_not_restart_playback(self, mock_mass: MagicMock) -> None:
4877 """Test that a setting which applies on the fly leaves playback alone."""
4878 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
4879
4880 await controller.on_player_config_change(
4881 self._config(requires_reload=False), {f"values/{CONF_OUTPUT_CODEC}"}
4882 )
4883
4884 mock_mass.player_queues.stop.assert_not_awaited()
4885 mock_mass.call_later.assert_not_called()
4886
4887 async def test_untouched_reload_setting_does_not_restart_playback(
4888 self, mock_mass: MagicMock
4889 ) -> None:
4890 """Test that only a changed reload-requiring setting restarts playback."""
4891 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
4892
4893 await controller.on_player_config_change(
4894 self._config(requires_reload=True), {f"values/{CONF_ICON}"}
4895 )
4896
4897 mock_mass.player_queues.stop.assert_not_awaited()
4898 mock_mass.call_later.assert_not_called()
4899
4900 async def test_idle_queue_is_left_alone(self, mock_mass: MagicMock) -> None:
4901 """Test that a reload-requiring change does not start playback on an idle queue."""
4902 controller = self._prepare(mock_mass, PlaybackState.IDLE)
4903
4904 await controller.on_player_config_change(
4905 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
4906 )
4907
4908 mock_mass.player_queues.stop.assert_not_awaited()
4909 mock_mass.call_later.assert_not_called()
4910
4911
4912if __name__ == "__main__":
4913 pytest.main([__file__, "-v"])
4914