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