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