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