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