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