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