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