/
/
1"""
2Tests for PlayerController high-level operations.
3
4This module tests:
5- cmd_set_members validation and execution
6- Group/ungroup commands
7- Player state management
8- Cache invalidation after grouping operations
9"""
10
11from __future__ import annotations
12
13import asyncio
14import contextlib
15import time
16from collections.abc import AsyncIterator, Callable, Iterator
17from types import SimpleNamespace
18from typing import Any, NamedTuple, cast
19from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
20
21import pytest
22from music_assistant_models.auth import User, UserRole
23from music_assistant_models.config_entries import ConfigEntry, CoreConfig, 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
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 provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
1579 universal_provider = MockProvider(
1580 "universal_player", instance_id="universal_player", mass=mock_mass
1581 )
1582 mock_mass.config.get_base_player_config.return_value = create_mock_config("Universal")
1583 universal = UniversalPlayer(
1584 cast("Any", universal_provider), "universal_1", "Universal", DeviceInfo(), ["child"]
1585 )
1586 universal.set_initialized()
1587 controller._players["universal_1"] = universal
1588 universal.update_state(signal_event=False)
1589 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1590 child.set_protocol_parent_id("universal_1")
1591 universal.set_linked_output_protocols(
1592 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1593 )
1594 migrated: list[tuple[str, str]] = []
1595
1596 with patch.object(
1597 controller,
1598 "_migrate_universal_player_config",
1599 side_effect=lambda old, new: migrated.append((old, new)),
1600 ):
1601 child._attr_type = PlayerType.PLAYER
1602 await controller.register_or_update(child)
1603
1604 # a leftover active link makes the wrapper refuse the handover to the player it
1605 # is being replaced by, stranding the user's settings on a player on its way out
1606 assert migrated == [("universal_1", "child")]
1607 assert child.protocol_parent_id is None
1608
1609 async def test_player_to_protocol_detaches_its_children(self, mock_mass: MagicMock) -> None:
1610 """A player that becomes a protocol child releases the protocols it owned."""
1611 controller = self._prepare(mock_mass)
1612 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1613 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1614 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1615 child.set_protocol_parent_id("parent")
1616 parent.set_linked_output_protocols(
1617 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1618 )
1619
1620 parent._attr_type = PlayerType.PROTOCOL
1621 await controller.register_or_update(parent)
1622
1623 # a protocol player cannot own protocol players of its own
1624 assert parent.linked_output_protocols == []
1625 assert child.protocol_parent_id is None
1626
1627
1628class TestCmdUngroupNewBranches:
1629 """
1630 Regression tests for the post-refactor cmd_ungroup flow.
1631
1632 The refactor changed two things:
1633
1634 - ``cmd_ungroup`` on a group player no longer calls ``cmd_set_members``
1635 (which would hit the "Cannot remove static member" guard); instead it
1636 stops or powers off the group entirely.
1637 - ``cmd_ungroup`` on a static member of a group recurses to ungroup the
1638 group, because static members cannot be released individually.
1639 """
1640
1641 @pytest.mark.asyncio
1642 async def test_ungroup_group_player_with_power_uses_power_off(
1643 self, mock_mass: MagicMock
1644 ) -> None:
1645 """Ungroup on a group with explicit power control routes through cmd_power(False)."""
1646 controller = PlayerController(mock_mass)
1647 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1648 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1649 group._attr_powered = True
1650 group._attr_group_members = ["member"]
1651 group._attr_supported_features = {PlayerFeature.POWER}
1652
1653 # ensure power_control resolves to NATIVE so cmd_ungroup uses the power path
1654 mock_mass.config.get_raw_player_config_value = MagicMock(
1655 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
1656 )
1657
1658 controller._players = {"g1": group}
1659 mock_mass.players = controller
1660
1661 # populate state.type / state.power_control / state.group_members
1662 group.set_initialized()
1663 group._cache.clear()
1664 group.update_state(signal_event=False)
1665
1666 called: dict[str, bool | str] = {}
1667
1668 async def _power(
1669 player_id: str,
1670 powered: bool,
1671 skip_auto_play: bool = False, # noqa: ARG001
1672 ) -> None:
1673 called["player_id"] = player_id
1674 called["powered"] = powered
1675
1676 controller._handle_cmd_power = _power # type: ignore[method-assign]
1677
1678 await controller.cmd_ungroup("g1")
1679
1680 assert called == {"player_id": "g1", "powered": False}
1681
1682 @pytest.mark.asyncio
1683 async def test_ungroup_powerless_group_calls_stop(self, mock_mass: MagicMock) -> None:
1684 """Ungroup on a powerless group falls through to _handle_cmd_stop."""
1685 controller = PlayerController(mock_mass)
1686 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1687 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1688 group._attr_powered = None # no power control
1689 group._attr_group_members = ["member"]
1690 # no POWER feature → power_control auto-selects to NONE
1691
1692 controller._players = {"g1": group}
1693 mock_mass.players = controller
1694
1695 group.set_initialized()
1696 group._cache.clear()
1697 group.update_state(signal_event=False)
1698
1699 stop_called: list[str] = []
1700
1701 async def _stop(player_id: str) -> None:
1702 stop_called.append(player_id)
1703
1704 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1705 # also stub power to make sure we did NOT go down that branch
1706 power_called: list[str] = []
1707
1708 async def _power(
1709 player_id: str,
1710 powered: bool, # noqa: ARG001
1711 skip_auto_play: bool = False, # noqa: ARG001
1712 ) -> None:
1713 power_called.append(player_id)
1714
1715 controller._handle_cmd_power = _power # type: ignore[method-assign]
1716
1717 await controller.cmd_ungroup("g1")
1718
1719 assert stop_called == ["g1"]
1720 assert power_called == [] # powerless group → never goes through cmd_power
1721
1722
1723class TestExternalPowerOffUnsync:
1724 """
1725 Tests for unsyncing a player when its power is turned off outside of MA.
1726
1727 When a player's (final) power state flips on->off because its linked power
1728 control was switched off directly - rather than via an MA power command -
1729 the player must be removed from any (sync)group it is part of.
1730 """
1731
1732 def _make_synced_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
1733 """Build a controller with a player synced to a registered leader."""
1734 controller = PlayerController(mock_mass)
1735 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1736 leader = MockPlayer(provider, "leader", "Leader")
1737 leader._attr_group_members = ["leader", "p1"]
1738 player = MockPlayer(provider, "p1", "Player")
1739 controller._players = {"leader": leader, "p1": player}
1740 mock_mass.players = controller
1741 for _player in (leader, player):
1742 _player.set_initialized()
1743 _player._cache.clear()
1744 _player.update_state(signal_event=False)
1745 # isolate the unsync branch from the unrelated state-forwarding machinery
1746 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1747 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1748 return controller, player
1749
1750 def test_power_off_unsyncs_synced_player(self, mock_mass: MagicMock) -> None:
1751 """An on->off power transition ungroups a synced player."""
1752 controller, player = self._make_synced_player(mock_mass)
1753 assert player.state.synced_to == "leader"
1754
1755 controller.signal_player_state_update(player, {"powered": (True, False)})
1756
1757 controller.cmd_ungroup.assert_called_once_with("p1") # type: ignore[attr-defined]
1758
1759 def test_power_on_does_not_unsync(self, mock_mass: MagicMock) -> None:
1760 """An off->on power transition leaves the player synced."""
1761 controller, player = self._make_synced_player(mock_mass)
1762
1763 controller.signal_player_state_update(player, {"powered": (False, True)})
1764
1765 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1766
1767 def test_no_power_control_is_ignored(self, mock_mass: MagicMock) -> None:
1768 """A None->off transition (player without power control) is ignored."""
1769 controller, player = self._make_synced_player(mock_mass)
1770
1771 controller.signal_player_state_update(player, {"powered": (None, False)})
1772
1773 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1774
1775 def test_power_off_ungrouped_player_is_noop(self, mock_mass: MagicMock) -> None:
1776 """Powering off a player that is not in any group does nothing."""
1777 controller = PlayerController(mock_mass)
1778 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1779 player = MockPlayer(provider, "p1", "Player")
1780 controller._players = {"p1": player}
1781 mock_mass.players = controller
1782 player.set_initialized()
1783 player._cache.clear()
1784 player.update_state(signal_event=False)
1785 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1786 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1787
1788 controller.signal_player_state_update(player, {"powered": (True, False)})
1789
1790 controller.cmd_ungroup.assert_not_called()
1791
1792
1793class TestPlayMediaOverride:
1794 """
1795 Tests for the new CONF_PLAY_MEDIA_OVERRIDES_GROUP behavior.
1796
1797 When a captured child player receives an explicit play_media command, the
1798 default behavior is to *release* it from the active group/sync and play
1799 directly on the targeted player. The legacy behavior (forward to the
1800 leader) is preserved via the per-player config opt-out.
1801 """
1802
1803 @pytest.mark.asyncio
1804 async def test_override_disabled_redirects_to_group(self, mock_mass: MagicMock) -> None:
1805 """With override disabled, play_media on a captured child redirects to the group."""
1806 controller = PlayerController(mock_mass)
1807 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1808 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1809
1810 class _SessionedGroup(MockPlayer):
1811 @property
1812 def is_active_session(self) -> bool:
1813 return True
1814
1815 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1816 group._attr_powered = None
1817 group._attr_group_members = ["member"]
1818
1819 member = MockPlayer(member_provider, "member", "Member")
1820
1821 controller._players = {"g1": group, "member": member}
1822 mock_mass.players = controller
1823
1824 group.set_initialized()
1825 member.set_initialized()
1826 group.update_state(signal_event=False)
1827 member.update_state(signal_event=False)
1828 # sanity: the member is captured by the group
1829 assert member.state.active_group == "g1"
1830
1831 _set_play_media_override(mock_mass, False)
1832
1833 played_on: list[str] = []
1834
1835 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1836 played_on.append(player_id)
1837
1838 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1839 # the play_media wrapper acquires a playback lock; stub it out
1840 controller._player_command_locks = {}
1841
1842 media = MagicMock(uri="x", source_id="src")
1843 await controller.play_media("member", media)
1844
1845 # legacy behavior: redirected to the group leader
1846 assert played_on == ["g1"]
1847
1848 @pytest.mark.asyncio
1849 async def test_override_releases_dynamic_member(self, mock_mass: MagicMock) -> None:
1850 """With override enabled, play_media on a dynamic group member releases it first."""
1851 controller = PlayerController(mock_mass)
1852 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1853 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1854
1855 class _SessionedGroup(MockPlayer):
1856 @property
1857 def is_active_session(self) -> bool:
1858 return True
1859
1860 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1861 group._attr_powered = None
1862 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1863 group._attr_group_members = ["member"]
1864 # NOT a static member ⇒ dynamic — can be removed via set_members
1865 group._attr_static_group_members = []
1866
1867 member = MockPlayer(member_provider, "member", "Member")
1868
1869 controller._players = {"g1": group, "member": member}
1870 mock_mass.players = controller
1871
1872 group.set_initialized()
1873 member.set_initialized()
1874 group.update_state(signal_event=False)
1875 member.update_state(signal_event=False)
1876 assert member.state.active_group == "g1"
1877
1878 # default: override enabled
1879 _set_play_media_override(mock_mass, True)
1880
1881 set_members_calls: list[dict[str, object]] = []
1882
1883 async def _cmd_set_members(
1884 target_player: str,
1885 player_ids_to_add: list[str] | None = None, # noqa: ARG001
1886 player_ids_to_remove: list[str] | None = None,
1887 ) -> None:
1888 set_members_calls.append(
1889 {"player_id": target_player, "remove": player_ids_to_remove or []}
1890 )
1891
1892 controller.cmd_set_members = _cmd_set_members # type: ignore[method-assign]
1893
1894 played_on: list[str] = []
1895
1896 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1897 played_on.append(player_id)
1898
1899 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1900 controller._player_command_locks = {}
1901
1902 media = MagicMock(uri="x", source_id="src")
1903 with patch.object(
1904 controller,
1905 "wait_for_player_update",
1906 _skip_player_update_wait,
1907 ):
1908 await controller.play_media("member", media)
1909
1910 # the member was removed from the group ...
1911 assert set_members_calls == [{"player_id": "g1", "remove": ["member"]}]
1912 # ... and then play_media was issued directly on the member, NOT on the group
1913 assert played_on == ["member"]
1914
1915 @pytest.mark.asyncio
1916 async def test_override_stops_static_group(self, mock_mass: MagicMock) -> None:
1917 """With override enabled, play_media on a STATIC group member stops the group."""
1918 controller = PlayerController(mock_mass)
1919 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1920 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1921
1922 class _SessionedGroup(MockPlayer):
1923 @property
1924 def is_active_session(self) -> bool:
1925 return True
1926
1927 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1928 group._attr_powered = None # no power control ⇒ stop, not power-off
1929 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1930 group._attr_group_members = ["member"]
1931 # static member - cannot be removed individually
1932 group._attr_static_group_members = ["member"]
1933
1934 member = MockPlayer(member_provider, "member", "Member")
1935
1936 controller._players = {"g1": group, "member": member}
1937 mock_mass.players = controller
1938
1939 group.set_initialized()
1940 member.set_initialized()
1941 group.update_state(signal_event=False)
1942 member.update_state(signal_event=False)
1943 assert member.state.active_group == "g1"
1944
1945 _set_play_media_override(mock_mass, True)
1946
1947 stop_calls: list[str] = []
1948 power_calls: list[tuple[str, bool]] = []
1949
1950 async def _stop(player_id: str) -> None:
1951 stop_calls.append(player_id)
1952
1953 async def _power(
1954 player_id: str,
1955 powered: bool,
1956 skip_auto_play: bool = False, # noqa: ARG001
1957 ) -> None:
1958 power_calls.append((player_id, powered))
1959
1960 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1961 controller._handle_cmd_power = _power # type: ignore[method-assign]
1962
1963 played_on: list[str] = []
1964
1965 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1966 played_on.append(player_id)
1967
1968 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1969 controller._player_command_locks = {}
1970
1971 media = MagicMock(uri="x", source_id="src")
1972 with patch.object(
1973 controller,
1974 "wait_for_player_update",
1975 _skip_player_update_wait,
1976 ):
1977 await controller.play_media("member", media)
1978
1979 # powerless group + static member: we should have stopped the group ...
1980 assert stop_calls == ["g1"]
1981 # ... not powered it off ...
1982 assert power_calls == []
1983 # ... and play_media was issued directly on the member
1984 assert played_on == ["member"]
1985
1986
1987class TestExternalSourcePlayPause:
1988 """Pause/play handling for externally-initiated sources (no active output protocol)."""
1989
1990 @staticmethod
1991 def _make_external_source_player(
1992 provider: MockProvider,
1993 controller: PlayerController,
1994 mock_mass: MagicMock,
1995 *,
1996 playback_state: PlaybackState,
1997 can_play_pause: bool = True,
1998 supports_pause: bool = True,
1999 ) -> MockPlayer:
2000 """Build a player playing a passive external source, with no active output protocol."""
2001 player = MockPlayer(provider, "player_1", "Test Player")
2002 player._attr_supported_features = {PlayerFeature.PAUSE} if supports_pause else set()
2003 player._attr_source_list = [
2004 PlayerSource(
2005 id="spotify",
2006 name="Spotify",
2007 passive=True,
2008 can_play_pause=can_play_pause,
2009 can_next_previous=True,
2010 can_seek=True,
2011 )
2012 ]
2013 player._attr_active_source = "spotify"
2014 player._attr_playback_state = playback_state
2015 player._cache.clear()
2016 controller._players = {"player_1": player}
2017 mock_mass.players = controller
2018 mock_mass.player_queues = MagicMock()
2019 mock_mass.player_queues.get = MagicMock(return_value=None)
2020 player.update_state(signal_event=False)
2021 return player
2022
2023 def test_pause_external_source_forwards_to_player(
2024 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
2025 ) -> None:
2026 """Pausing a pausable external source forwards to the player, not STOP."""
2027 player = self._make_external_source_player(
2028 provider, controller, mock_mass, playback_state=PlaybackState.PLAYING
2029 )
2030 player.pause = AsyncMock() # type: ignore[method-assign]
2031 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2032
2033 asyncio.run(controller._handle_cmd_pause("player_1"))
2034
2035 player.pause.assert_awaited_once()
2036 controller._handle_cmd_stop.assert_not_called()
2037
2038 def test_play_external_source_unpauses_player(
2039 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
2040 ) -> None:
2041 """Unpausing a paused external source forwards to the player, not a restart."""
2042 player = self._make_external_source_player(
2043 provider, controller, mock_mass, playback_state=PlaybackState.PAUSED
2044 )
2045 player.play = AsyncMock() # type: ignore[method-assign]
2046 player.play_media = AsyncMock() # type: ignore[method-assign]
2047 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
2048
2049 asyncio.run(controller._handle_cmd_play("player_1"))
2050
2051 player.play.assert_awaited_once()
2052 player.play_media.assert_not_called()
2053 controller._handle_select_source.assert_not_called()
2054
2055 def test_pause_falls_back_to_stop_without_pause_support(
2056 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
2057 ) -> None:
2058 """A player that cannot pause natively still falls back to STOP."""
2059 player = self._make_external_source_player(
2060 provider,
2061 controller,
2062 mock_mass,
2063 playback_state=PlaybackState.PLAYING,
2064 supports_pause=False,
2065 )
2066 player.pause = AsyncMock() # type: ignore[method-assign]
2067 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2068
2069 asyncio.run(controller._handle_cmd_pause("player_1"))
2070
2071 controller._handle_cmd_stop.assert_awaited_once()
2072 player.pause.assert_not_called()
2073
2074
2075class TestProtocolOutputPlayPause:
2076 """Play/pause on a player rendering through a linked output protocol."""
2077
2078 @staticmethod
2079 def _make_player_on_protocol(
2080 mock_mass: MagicMock,
2081 controller: PlayerController,
2082 *,
2083 playback_state: PlaybackState,
2084 ) -> MockPlayer:
2085 """Build a player playing the MA queue through a protocol that cannot pause."""
2086 native_provider = MockProvider("chromecast", mass=mock_mass)
2087 player = MockPlayer(native_provider, "player_1", "Test Player")
2088 player._attr_supported_features.add(PlayerFeature.PAUSE)
2089 player._attr_playback_state = playback_state
2090
2091 protocol_provider = MockProvider("sendspin", mass=mock_mass)
2092 protocol_player = MockPlayer(
2093 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
2094 )
2095 protocol_player._attr_playback_state = playback_state
2096
2097 controller._players = {"player_1": player, "proto_1": protocol_player}
2098 mock_mass.players = controller
2099 mock_mass.player_queues = MagicMock()
2100 # a non-empty queue, so the MA queue source advertises play/pause support
2101 queue = MagicMock()
2102 queue.items = [MagicMock()]
2103 mock_mass.player_queues.get = MagicMock(return_value=queue)
2104 player.set_linked_output_protocols(
2105 [
2106 LinkedOutputProtocol(
2107 output_protocol_id="proto_1",
2108 protocol_domain="sendspin",
2109 priority=40,
2110 )
2111 ]
2112 )
2113 player.set_active_output_protocol("proto_1")
2114 player.set_active_mass_source("player_1")
2115 protocol_player.update_state(signal_event=False)
2116 player.refresh_state(signal_event=False)
2117 return player
2118
2119 async def test_pause_on_protocol_without_pause_falls_back_to_stop(
2120 self, mock_mass: MagicMock, controller: PlayerController
2121 ) -> None:
2122 """The native transport has no session to pause while a protocol renders the audio."""
2123 player = self._make_player_on_protocol(
2124 mock_mass, controller, playback_state=PlaybackState.PLAYING
2125 )
2126 player.pause = AsyncMock() # type: ignore[method-assign]
2127 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2128
2129 await controller._handle_cmd_pause("player_1")
2130
2131 player.pause.assert_not_called()
2132 # STOP goes to the visible player, not the protocol player
2133 controller._handle_cmd_stop.assert_awaited_once_with("player_1")
2134
2135 async def test_play_on_protocol_without_pause_does_not_unpause_natively(
2136 self, mock_mass: MagicMock, controller: PlayerController
2137 ) -> None:
2138 """Unpausing must not hit the native transport either; the source is restarted."""
2139 player = self._make_player_on_protocol(
2140 mock_mass, controller, playback_state=PlaybackState.PAUSED
2141 )
2142 player.play = AsyncMock() # type: ignore[method-assign]
2143 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
2144
2145 await controller._handle_cmd_play("player_1")
2146
2147 player.play.assert_not_called()
2148 # the MA queue source is restarted, not some other source
2149 controller._handle_select_source.assert_awaited_once_with("player_1", "player_1")
2150
2151
2152class TestMirrorsParentMedia:
2153 """Tests for _mirrors_parent_media (palette-fetch gating for grouped players)."""
2154
2155 @staticmethod
2156 def _fake_player(
2157 *,
2158 player_id: str = "p1",
2159 active_group: str | None = None,
2160 synced_to: str | None = None,
2161 player_type: PlayerType = PlayerType.PLAYER,
2162 protocol_parent_id: str | None = None,
2163 ) -> SimpleNamespace:
2164 return SimpleNamespace(
2165 player_id=player_id,
2166 state=SimpleNamespace(active_group=active_group, synced_to=synced_to, type=player_type),
2167 protocol_parent_id=protocol_parent_id,
2168 )
2169
2170 def test_standalone_player_owns_media(self, controller: PlayerController) -> None:
2171 """A standalone player resolves its own media (and palette)."""
2172 assert controller._mirrors_parent_media(self._fake_player()) is False # type: ignore[arg-type]
2173
2174 def test_group_member_mirrors(self, controller: PlayerController) -> None:
2175 """A group member borrows its parent's media."""
2176 assert controller._mirrors_parent_media(self._fake_player(active_group="g1")) is True # type: ignore[arg-type]
2177
2178 def test_synced_member_mirrors(self, controller: PlayerController) -> None:
2179 """A synced member borrows its leader's media."""
2180 assert controller._mirrors_parent_media(self._fake_player(synced_to="leader")) is True # type: ignore[arg-type]
2181
2182 def test_protocol_child_mirrors(self, controller: PlayerController) -> None:
2183 """A protocol child borrows its parent's media."""
2184 player = self._fake_player(player_type=PlayerType.PROTOCOL, protocol_parent_id="parent")
2185 assert controller._mirrors_parent_media(player) is True # type: ignore[arg-type]
2186
2187 def test_protocol_player_without_parent_owns_media(self, controller: PlayerController) -> None:
2188 """A protocol player with no parent resolves its own media."""
2189 player = self._fake_player(player_type=PlayerType.PROTOCOL)
2190 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
2191
2192 def test_self_referential_parent_owns_media(self, controller: PlayerController) -> None:
2193 """A self-referential active_group/synced_to is not a real parent, so resolve locally."""
2194 player = self._fake_player(player_id="p1", synced_to="p1", active_group="p1")
2195 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
2196
2197
2198class TestVolumeScalingOnRedirect:
2199 """min/max volume scaling must survive a redirect to a protocol player or external control."""
2200
2201 @staticmethod
2202 def _volume_player(
2203 player_id: str,
2204 volume_control: str,
2205 volume_set: AsyncMock | None = None,
2206 ) -> SimpleNamespace:
2207 return SimpleNamespace(
2208 player_id=player_id,
2209 type=PlayerType.PLAYER,
2210 protocol_parent_id=None,
2211 extra_data={},
2212 volume_control=volume_control,
2213 mute_control=PLAYER_CONTROL_NONE,
2214 volume_set=volume_set or AsyncMock(),
2215 update_state=MagicMock(),
2216 provider=MagicMock(),
2217 state=SimpleNamespace(
2218 name=player_id,
2219 volume_control=volume_control,
2220 volume_muted=False,
2221 mute_control=PLAYER_CONTROL_NONE,
2222 ),
2223 )
2224
2225 @pytest.mark.asyncio
2226 async def test_protocol_redirect_forwards_scaled_volume(
2227 self, controller: PlayerController, mock_mass: MagicMock
2228 ) -> None:
2229 """A volume command redirected to a protocol player honors the user-facing max_volume."""
2230
2231 def _conf(player_id: str, key: str, default: object = None) -> object:
2232 if key == "min_volume":
2233 return 0
2234 if key == "max_volume":
2235 # user-facing player caps at 50, the protocol player has no limits of its own
2236 return 50 if player_id == "user_player" else 100
2237 return default
2238
2239 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2240
2241 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
2242 user = self._volume_player("user_player", "protocol_player")
2243 players = {"user_player": user, "protocol_player": protocol}
2244
2245 with (
2246 patch.object(controller, "get_player", side_effect=players.get),
2247 patch.object(controller, "_get_active_audio_source", return_value=None),
2248 ):
2249 controller._controls = {}
2250 await controller._handle_cmd_volume_set("user_player", 100)
2251
2252 # logical 100 with a max_volume of 50 must reach the protocol player as 50, not the raw 100
2253 protocol.volume_set.assert_awaited_once_with(50)
2254
2255 @pytest.mark.asyncio
2256 async def test_protocol_redirect_applies_the_limits_only_once(
2257 self, controller: PlayerController, mock_mass: MagicMock
2258 ) -> None:
2259 """Limits configured on the protocol player do not scale the command a second time."""
2260
2261 def _conf(_player_id: str, key: str, default: object = None) -> object:
2262 if key == "min_volume":
2263 return 0
2264 if key == "max_volume":
2265 # both players carry a limit; only the addressed one may apply
2266 return 50
2267 return default
2268
2269 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2270
2271 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
2272 user = self._volume_player("user_player", "protocol_player")
2273 players = {"user_player": user, "protocol_player": protocol}
2274
2275 with (
2276 patch.object(controller, "get_player", side_effect=players.get),
2277 patch.object(controller, "_get_active_audio_source", return_value=None),
2278 ):
2279 controller._controls = {}
2280 await controller._handle_cmd_volume_set("user_player", 100)
2281
2282 protocol.volume_set.assert_awaited_once_with(50)
2283
2284 @pytest.mark.asyncio
2285 async def test_external_control_redirect_forwards_scaled_volume(
2286 self, controller: PlayerController, mock_mass: MagicMock
2287 ) -> None:
2288 """A volume command redirected to an external control honors the user-facing max_volume."""
2289 mock_mass.config.get_raw_player_config_value = MagicMock(
2290 side_effect=_player_config_stub(max_volume=50)
2291 )
2292
2293 volume_set = AsyncMock()
2294 control = PlayerControl(
2295 id="ext_control",
2296 provider="test",
2297 name="External Amp",
2298 supports_volume=True,
2299 volume_set=volume_set,
2300 )
2301 user = self._volume_player("user_player", "ext_control")
2302 players = {"user_player": user}
2303
2304 with (
2305 patch.object(controller, "get_player", side_effect=players.get),
2306 patch.object(controller, "_get_active_audio_source", return_value=None),
2307 ):
2308 controller._controls = {"ext_control": control}
2309 await controller._handle_cmd_volume_set("user_player", 100)
2310
2311 volume_set.assert_awaited_once_with(50)
2312
2313 @pytest.mark.asyncio
2314 async def test_external_control_without_volume_support_raises(
2315 self, controller: PlayerController, mock_mass: MagicMock
2316 ) -> None:
2317 """A volume command redirected to a control lacking volume support is rejected."""
2318 mock_mass.config.get_raw_player_config_value = MagicMock(
2319 side_effect=_player_config_stub(max_volume=50)
2320 )
2321
2322 volume_set = AsyncMock()
2323 control = PlayerControl(
2324 id="ext_control",
2325 provider="test",
2326 name="External Amp",
2327 supports_volume=False,
2328 volume_set=volume_set,
2329 )
2330 user = self._volume_player("user_player", "ext_control")
2331 players = {"user_player": user}
2332
2333 with (
2334 patch.object(controller, "get_player", side_effect=players.get),
2335 patch.object(controller, "_get_active_audio_source", return_value=None),
2336 ):
2337 controller._controls = {"ext_control": control}
2338 with pytest.raises(UnsupportedFeaturedException):
2339 await controller._handle_cmd_volume_set("user_player", 100)
2340
2341 volume_set.assert_not_awaited()
2342
2343
2344class TestExternalPowerControl:
2345 """Power commands redirected to an external PlayerControl must forward and gate correctly."""
2346
2347 def _make_player(
2348 self, mock_mass: MagicMock, control: PlayerControl
2349 ) -> tuple[PlayerController, MockPlayer]:
2350 """Build a controller with a single player whose power control is the given control."""
2351 mock_mass.config.get_raw_player_config_value = MagicMock(
2352 side_effect=_player_config_stub({CONF_POWER_CONTROL: control.id})
2353 )
2354 controller = PlayerController(mock_mass)
2355 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2356 player = MockPlayer(provider, "player_1", "Player 1")
2357 controller._controls = {control.id: control}
2358 controller._players = {"player_1": player}
2359 mock_mass.players = controller
2360 # auto-play would otherwise resume the (mocked) player queue on power on
2361 config_get_value = player.config.get_value
2362 player.config.get_value = MagicMock( # type: ignore[method-assign]
2363 side_effect=lambda key, *args, **kwargs: (
2364 False if key == CONF_AUTO_PLAY else config_get_value(key, *args, **kwargs)
2365 )
2366 )
2367 player.set_initialized()
2368 player.update_state(signal_event=False)
2369 return controller, player
2370
2371 async def test_power_on_forwards_to_control(self, mock_mass: MagicMock) -> None:
2372 """Powering on a player redirects to its external control's power_on callback."""
2373 power_on = AsyncMock()
2374 power_off = AsyncMock()
2375 control = PlayerControl(
2376 id="ext_power",
2377 provider="test",
2378 name="External Power",
2379 supports_power=True,
2380 power_on=power_on,
2381 power_off=power_off,
2382 )
2383
2384 def _report_powered_on() -> None:
2385 control.power_state = True
2386
2387 # the control only reports on once switched on, which releases wait_for_power_on
2388 power_on.side_effect = _report_powered_on
2389 controller, player = self._make_player(mock_mass, control)
2390 assert player.state.powered is False
2391
2392 await controller._handle_cmd_power("player_1", True)
2393
2394 power_on.assert_awaited_once()
2395 power_off.assert_not_awaited()
2396
2397 async def test_power_on_waits_on_the_control(self, mock_mass: MagicMock) -> None:
2398 """Powering on waits for the control to report on, not for the player itself."""
2399 control = PlayerControl(
2400 id="ext_power",
2401 provider="test",
2402 name="External Power",
2403 supports_power=True,
2404 power_on=AsyncMock(),
2405 power_off=AsyncMock(),
2406 )
2407 controller, player = self._make_player(mock_mass, control)
2408 assert player.state.powered is False
2409
2410 with patch(
2411 "music_assistant.controllers.players.controller.wait_for_power_on", AsyncMock()
2412 ) as wait_for_power_on:
2413 await controller._handle_cmd_power("player_1", True)
2414
2415 wait_for_power_on.assert_awaited_once()
2416 assert wait_for_power_on.await_args is not None
2417 assert wait_for_power_on.await_args.args[2] is control
2418
2419 async def test_power_off_forwards_to_control(self, mock_mass: MagicMock) -> None:
2420 """Powering off a player redirects to its external control's power_off callback."""
2421 power_on = AsyncMock()
2422 power_off = AsyncMock()
2423 control = PlayerControl(
2424 id="ext_power",
2425 provider="test",
2426 name="External Power",
2427 supports_power=True,
2428 power_state=True,
2429 power_on=power_on,
2430 power_off=power_off,
2431 )
2432 controller, player = self._make_player(mock_mass, control)
2433 assert player.state.powered is True
2434
2435 await controller._handle_cmd_power("player_1", False)
2436
2437 power_off.assert_awaited_once()
2438 power_on.assert_not_awaited()
2439
2440 async def test_control_without_power_support_raises(self, mock_mass: MagicMock) -> None:
2441 """A power command redirected to a control lacking power support is rejected."""
2442 power_on = AsyncMock()
2443 power_off = AsyncMock()
2444 control = PlayerControl(
2445 id="ext_power",
2446 provider="test",
2447 name="External Power",
2448 supports_power=False,
2449 power_on=power_on,
2450 power_off=power_off,
2451 )
2452 controller, player = self._make_player(mock_mass, control)
2453 assert player.state.powered is False
2454
2455 with pytest.raises(UnsupportedFeaturedException):
2456 await controller._handle_cmd_power("player_1", True)
2457
2458 power_on.assert_not_awaited()
2459 power_off.assert_not_awaited()
2460
2461
2462class TestEnforceVolumeLimits:
2463 """External volume changes outside the min/max range must be corrected."""
2464
2465 @staticmethod
2466 def _set_limits(mock_mass: MagicMock, min_volume: int, max_volume: int) -> None:
2467 mock_mass.config.get_raw_player_config_value = MagicMock(
2468 side_effect=_player_config_stub(min_volume=min_volume, max_volume=max_volume)
2469 )
2470
2471 @staticmethod
2472 def _player(logical_volume: int | None) -> SimpleNamespace:
2473 return SimpleNamespace(
2474 player_id="user_player",
2475 state=SimpleNamespace(volume_level=logical_volume),
2476 )
2477
2478 def test_out_of_range_volume_is_corrected(
2479 self, controller: PlayerController, mock_mass: MagicMock
2480 ) -> None:
2481 """A device volume above max_volume (logical > 100) is clamped back to logical 100."""
2482 self._set_limits(mock_mass, 0, 80)
2483 # device volume 100 with max 80 resolves to logical 125
2484 player = self._player(125)
2485 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2486 controller._enforce_volume_limits(cast("MockPlayer", player))
2487 cmd.assert_called_once_with("user_player", 100)
2488 mock_mass.create_task.assert_called_once()
2489
2490 def test_below_min_volume_is_corrected(
2491 self, controller: PlayerController, mock_mass: MagicMock
2492 ) -> None:
2493 """A device volume below min_volume (logical < 0) is clamped back to logical 0."""
2494 self._set_limits(mock_mass, 20, 100)
2495 # device volume 10 with min 20 resolves to a negative logical volume
2496 player = self._player(-13)
2497 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2498 controller._enforce_volume_limits(cast("MockPlayer", player))
2499 cmd.assert_called_once_with("user_player", 0)
2500
2501 def test_in_range_volume_is_untouched(
2502 self, controller: PlayerController, mock_mass: MagicMock
2503 ) -> None:
2504 """A logical volume within 0-100 needs no correction."""
2505 self._set_limits(mock_mass, 0, 80)
2506 player = self._player(100)
2507 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2508 controller._enforce_volume_limits(cast("MockPlayer", player))
2509 cmd.assert_not_called()
2510
2511 def test_no_limits_configured_is_noop(
2512 self, controller: PlayerController, mock_mass: MagicMock
2513 ) -> None:
2514 """Default 0-100 limits skip enforcement entirely."""
2515 self._set_limits(mock_mass, 0, 100)
2516 player = self._player(100)
2517 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2518 controller._enforce_volume_limits(cast("MockPlayer", player))
2519 cmd.assert_not_called()
2520
2521 def test_unknown_volume_is_noop(
2522 self, controller: PlayerController, mock_mass: MagicMock
2523 ) -> None:
2524 """A player without a resolved volume level is left alone."""
2525 self._set_limits(mock_mass, 0, 80)
2526 player = self._player(None)
2527 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2528 controller._enforce_volume_limits(cast("MockPlayer", player))
2529 cmd.assert_not_called()
2530
2531
2532class TestFakeMuteControl:
2533 """Fake mute must report the muted state and restore the volume on unmute."""
2534
2535 def _make_player(
2536 self, mock_mass: MagicMock, volume_level: int | None = 40
2537 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
2538 """
2539 Build a controller with a single player using fake mute control.
2540
2541 :param mock_mass: the mocked MusicAssistant instance.
2542 :param volume_level: initial volume level of the player, None if unknown.
2543 """
2544 mock_mass.config.get_raw_player_config_value = MagicMock(
2545 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
2546 )
2547 controller = PlayerController(mock_mass)
2548 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2549 player = MockPlayer(provider, "player_1", "Player 1")
2550 controller._players = {"player_1": player}
2551 mock_mass.players = controller
2552 mock_mass.player_queues.get = MagicMock(return_value=None)
2553 player.set_initialized()
2554 player._attr_volume_level = volume_level
2555 # let the mocked native volume control behave like a real device
2556 volume_set = AsyncMock(
2557 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2558 )
2559 player.volume_set = volume_set # type: ignore[method-assign]
2560 player.update_state(signal_event=False)
2561 return controller, player, volume_set
2562
2563 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
2564 """Muting reports volume_muted=True and unmuting restores the previous volume."""
2565 controller, player, volume_set = self._make_player(mock_mass)
2566
2567 await controller.cmd_volume_mute("player_1", True)
2568 muted_state = player.state
2569 assert muted_state.volume_muted is True
2570 assert muted_state.volume_level == 0
2571 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2572
2573 await controller.cmd_volume_mute("player_1", False)
2574 volume_set.assert_awaited_with(40)
2575 # simulate the device reporting back its state after the volume command
2576 player.update_state()
2577 unmuted_state = player.state
2578 assert unmuted_state.volume_muted is False
2579 assert unmuted_state.volume_level == 40
2580
2581 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
2582 """A repeated mute command must not overwrite the stored volume with 0."""
2583 controller, player, volume_set = self._make_player(mock_mass)
2584
2585 await controller.cmd_volume_mute("player_1", True)
2586 await controller.cmd_volume_mute("player_1", True)
2587 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2588 assert player.state.volume_muted is True
2589
2590 await controller.cmd_volume_mute("player_1", False)
2591 volume_set.assert_awaited_with(40)
2592
2593 async def test_unmute_with_unknown_previous_volume(self, mock_mass: MagicMock) -> None:
2594 """Unmuting a player whose volume was unknown at mute time restores a low volume."""
2595 controller, player, volume_set = self._make_player(mock_mass, volume_level=None)
2596
2597 await controller.cmd_volume_mute("player_1", True)
2598 assert player.extra_data[ATTR_PREVIOUS_VOLUME] is None
2599
2600 await controller.cmd_volume_mute("player_1", False)
2601 volume_set.assert_awaited_with(1)
2602 player.update_state()
2603 assert player.state.volume_muted is False
2604
2605 async def test_unmute_of_unmuted_player_keeps_volume(self, mock_mass: MagicMock) -> None:
2606 """An unmute command for a player that is not muted may not touch the volume."""
2607 controller, player, volume_set = self._make_player(mock_mass, volume_level=50)
2608
2609 await controller.cmd_volume_mute("player_1", False)
2610 volume_set.assert_not_awaited()
2611 assert player.state.volume_level == 50
2612
2613 async def test_unmute_restores_a_stored_zero_volume(self, mock_mass: MagicMock) -> None:
2614 """A player that was already silent stays silent after mute and unmute."""
2615 controller, _player, volume_set = self._make_player(mock_mass, volume_level=0)
2616
2617 await controller.cmd_volume_mute("player_1", True)
2618 await controller.cmd_volume_mute("player_1", False)
2619 volume_set.assert_awaited_with(0)
2620
2621 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
2622 """A regular volume change while fake muted implies an unmute."""
2623 controller, player, _volume_set = self._make_player(mock_mass)
2624
2625 await controller.cmd_volume_mute("player_1", True)
2626 muted_state = player.state
2627 assert muted_state.volume_muted is True
2628
2629 await controller.cmd_volume_set("player_1", 25)
2630 # simulate the device reporting back its state after the volume command
2631 player.update_state()
2632 unmuted_state = player.state
2633 assert unmuted_state.volume_muted is False
2634 assert unmuted_state.volume_level == 25
2635
2636
2637class TestVolumeStep:
2638 """The volume_step core config setting controls the size of a single volume nudge."""
2639
2640 def _make_player(
2641 self, mock_mass: MagicMock, step: int | None, volume_level: int
2642 ) -> tuple[PlayerController, MockPlayer]:
2643 """Build a controller with a single player and the given volume_step config."""
2644 controller = PlayerController(mock_mass)
2645 controller.config = _volume_step_config(step)
2646 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2647 player = MockPlayer(provider, "player_1", "Player 1")
2648 player._attr_volume_level = volume_level
2649 # let the mocked native volume control behave like a real device
2650 player.volume_set = AsyncMock( # type: ignore[method-assign]
2651 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2652 )
2653 controller._players = {"player_1": player}
2654 mock_mass.players = controller
2655 mock_mass.player_queues.get = MagicMock(return_value=None)
2656 player.set_initialized()
2657 player.update_state(signal_event=False)
2658 return controller, player
2659
2660 def _make_synced_pair(
2661 self, mock_mass: MagicMock, step: int | None
2662 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2663 """Build a leader synced to one member, both at volume 50, with a volume_step config."""
2664 controller = PlayerController(mock_mass)
2665 controller.config = _volume_step_config(step)
2666 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2667 players: dict[str, MockPlayer] = {}
2668 for player_id in ("leader", "member"):
2669 player = MockPlayer(provider, player_id, player_id.title())
2670 player._attr_volume_level = 50
2671 # let the mocked native volume control behave like a real device
2672 player.volume_set = AsyncMock( # type: ignore[method-assign]
2673 side_effect=lambda volume, _player=player: setattr(
2674 _player, "_attr_volume_level", volume
2675 )
2676 )
2677 players[player_id] = player
2678 players["leader"]._attr_group_members = ["member"]
2679 controller._players = dict(players)
2680 mock_mass.players = controller
2681 mock_mass.player_queues.get = MagicMock(return_value=None)
2682 for player in players.values():
2683 player.set_initialized()
2684 player._cache.clear()
2685 player.update_state(signal_event=False)
2686 # a second, forced pass: update_state() only recalculates when a player's own
2687 # attributes changed, so the leader must be forced to re-derive its group_volume
2688 # from the now-initialized member.
2689 for player in players.values():
2690 player.update_state(force_update=True, signal_event=False)
2691 return controller, players
2692
2693 @pytest.mark.parametrize(
2694 ("start", "expected"),
2695 [(5, 6), (20, 22), (50, 53), (80, 82), (95, 96)],
2696 )
2697 async def test_default_step_up_matches_the_adaptive_ladder(
2698 self, mock_mass: MagicMock, start: int, expected: int
2699 ) -> None:
2700 """With volume_step at its default (0), volume_up keeps today's adaptive ladder."""
2701 controller, player = self._make_player(mock_mass, None, start)
2702
2703 await controller.cmd_volume_up("player_1")
2704
2705 player.update_state()
2706 assert player.state.volume_level == expected
2707
2708 @pytest.mark.parametrize(
2709 ("start", "expected"),
2710 [(5, 4), (20, 18), (50, 47), (80, 78), (95, 94)],
2711 )
2712 async def test_default_step_down_matches_the_adaptive_ladder(
2713 self, mock_mass: MagicMock, start: int, expected: int
2714 ) -> None:
2715 """With volume_step at its default (0), volume_down keeps today's adaptive ladder."""
2716 controller, player = self._make_player(mock_mass, None, start)
2717
2718 await controller.cmd_volume_down("player_1")
2719
2720 player.update_state()
2721 assert player.state.volume_level == expected
2722
2723 async def test_configured_step_moves_up_by_a_flat_amount_mid_range(
2724 self, mock_mass: MagicMock
2725 ) -> None:
2726 """A configured flat step of 5 moves by exactly 5 in the middle of the range."""
2727 controller, player = self._make_player(mock_mass, 5, 50)
2728
2729 await controller.cmd_volume_up("player_1")
2730
2731 player.update_state()
2732 assert player.state.volume_level == 55
2733
2734 async def test_configured_step_moves_up_by_a_flat_amount_near_the_extreme(
2735 self, mock_mass: MagicMock
2736 ) -> None:
2737 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2738 controller, player = self._make_player(mock_mass, 5, 5)
2739
2740 await controller.cmd_volume_up("player_1")
2741
2742 player.update_state()
2743 assert player.state.volume_level == 10
2744
2745 async def test_configured_step_moves_down_by_a_flat_amount_mid_range(
2746 self, mock_mass: MagicMock
2747 ) -> None:
2748 """A configured flat step of 5 moves down by exactly 5 in the middle of the range."""
2749 controller, player = self._make_player(mock_mass, 5, 50)
2750
2751 await controller.cmd_volume_down("player_1")
2752
2753 player.update_state()
2754 assert player.state.volume_level == 45
2755
2756 async def test_configured_step_moves_down_by_a_flat_amount_near_the_extreme(
2757 self, mock_mass: MagicMock
2758 ) -> None:
2759 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2760 controller, player = self._make_player(mock_mass, 5, 95)
2761
2762 await controller.cmd_volume_down("player_1")
2763
2764 player.update_state()
2765 assert player.state.volume_level == 90
2766
2767 async def test_large_configured_step_clamps_up_at_the_maximum(
2768 self, mock_mass: MagicMock
2769 ) -> None:
2770 """A large configured step clamps volume_up at 100."""
2771 controller, player = self._make_player(mock_mass, 10, 95)
2772
2773 await controller.cmd_volume_up("player_1")
2774
2775 player.update_state()
2776 assert player.state.volume_level == 100
2777
2778 async def test_large_configured_step_clamps_down_at_zero(self, mock_mass: MagicMock) -> None:
2779 """A large configured step clamps volume_down at 0."""
2780 controller, player = self._make_player(mock_mass, 10, 5)
2781
2782 await controller.cmd_volume_down("player_1")
2783
2784 player.update_state()
2785 assert player.state.volume_level == 0
2786
2787 async def test_group_volume_up_with_default_step_uses_the_ladder(
2788 self, mock_mass: MagicMock
2789 ) -> None:
2790 """cmd_group_volume_up honours the default (0) adaptive ladder too."""
2791 controller, players = self._make_synced_pair(mock_mass, None)
2792
2793 await controller.cmd_group_volume_up("leader")
2794
2795 for player in players.values():
2796 player.update_state()
2797 assert player.state.volume_level == 53
2798
2799 async def test_group_volume_down_with_default_step_uses_the_ladder(
2800 self, mock_mass: MagicMock
2801 ) -> None:
2802 """cmd_group_volume_down honours the default (0) adaptive ladder too."""
2803 controller, players = self._make_synced_pair(mock_mass, None)
2804
2805 await controller.cmd_group_volume_down("leader")
2806
2807 for player in players.values():
2808 player.update_state()
2809 assert player.state.volume_level == 47
2810
2811 async def test_group_volume_up_with_configured_step(self, mock_mass: MagicMock) -> None:
2812 """cmd_group_volume_up honours a configured flat step."""
2813 controller, players = self._make_synced_pair(mock_mass, 5)
2814
2815 await controller.cmd_group_volume_up("leader")
2816
2817 for player in players.values():
2818 player.update_state()
2819 assert player.state.volume_level == 55
2820
2821 async def test_group_volume_down_with_configured_step(self, mock_mass: MagicMock) -> None:
2822 """cmd_group_volume_down honours a configured flat step."""
2823 controller, players = self._make_synced_pair(mock_mass, 5)
2824
2825 await controller.cmd_group_volume_down("leader")
2826
2827 for player in players.values():
2828 player.update_state()
2829 assert player.state.volume_level == 45
2830
2831 async def test_config_entry_exposes_default_and_range(
2832 self, controller: PlayerController
2833 ) -> None:
2834 """get_config_entries returns the volume_step entry with its default and range."""
2835 entries = await controller.get_config_entries()
2836
2837 entry = next(entry for entry in entries if entry.key == CONF_VOLUME_STEP)
2838 assert entry.type == ConfigEntryType.INTEGER
2839 assert entry.default_value == 0
2840 assert entry.range == (0, 10)
2841
2842
2843class SlowDevice(NamedTuple):
2844 """A mocked device that takes its time to answer its first volume command."""
2845
2846 player_id: str
2847 reached: asyncio.Event
2848
2849
2850class TestGroupVolumeOrdering:
2851 """Group volume commands that overlap are handled one after the other."""
2852
2853 def _make_synced_pair(
2854 self,
2855 mock_mass: MagicMock,
2856 slow_device: SlowDevice | None = None,
2857 volumes: dict[str, int] | None = None,
2858 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2859 """
2860 Build a mute capable leader synced to one member.
2861
2862 :param slow_device: When given, the named player takes its time to answer its
2863 first volume command and reports as soon as it received that command.
2864 :param volumes: Volume level per player, defaults to 50 for both.
2865 """
2866 controller = PlayerController(mock_mass)
2867 controller.config = _volume_step_config(5)
2868 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2869 players: dict[str, MockPlayer] = {}
2870 for player_id in ("leader", "member"):
2871 player = MockPlayer(provider, player_id, player_id.title())
2872 player._attr_supported_features = {
2873 PlayerFeature.VOLUME_SET,
2874 PlayerFeature.VOLUME_MUTE,
2875 }
2876 player._attr_volume_level = (volumes or {}).get(player_id, 50)
2877
2878 # let the mocked native volume control behave like a real device, that may
2879 # take long enough to answer for a later command to overtake it
2880 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
2881 if (
2882 slow_device is not None
2883 and slow_device.player_id == _player.player_id
2884 and not slow_device.reached.is_set()
2885 ):
2886 slow_device.reached.set()
2887 await asyncio.sleep(0.2)
2888 _player._attr_volume_level = volume
2889
2890 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
2891 player.volume_mute = AsyncMock( # type: ignore[method-assign]
2892 side_effect=lambda muted, _player=player: setattr(
2893 _player, "_attr_volume_muted", muted
2894 )
2895 )
2896 players[player_id] = player
2897 players["leader"]._attr_group_members = ["member"]
2898 controller._players = dict(players)
2899 mock_mass.players = controller
2900 mock_mass.player_queues.get = MagicMock(return_value=None)
2901 for player in players.values():
2902 player.set_initialized()
2903 player._cache.clear()
2904 player.update_state(signal_event=False)
2905 # a second, forced pass so the leader derives its group volume from the member
2906 for player in players.values():
2907 player.update_state(force_update=True, signal_event=False)
2908 return controller, players
2909
2910 async def test_the_last_command_decides_the_group_volume(self, mock_mass: MagicMock) -> None:
2911 """A slow command may not overrule the volume of a later, faster one."""
2912 slow_leader = SlowDevice("leader", asyncio.Event())
2913 controller, players = self._make_synced_pair(mock_mass, slow_leader)
2914
2915 first = asyncio.create_task(controller.cmd_group_volume("leader", 80))
2916 # only send the second command once the first one reached the device
2917 await slow_leader.reached.wait()
2918 second = asyncio.create_task(controller.cmd_group_volume("leader", 30))
2919 await asyncio.gather(first, second)
2920
2921 for player in players.values():
2922 player.update_state()
2923 assert player.state.volume_level == 30
2924
2925 async def test_a_command_for_a_member_waits_for_one_for_its_leader(
2926 self, mock_mass: MagicMock
2927 ) -> None:
2928 """Addressing the same group by member or by leader may not overlap."""
2929 slow_leader = SlowDevice("leader", asyncio.Event())
2930 controller, players = self._make_synced_pair(mock_mass, slow_leader)
2931 in_flight = 0
2932 overlapped = False
2933 set_group_volume = controller.set_group_volume
2934
2935 async def _track_overlap(group_player: Player, volume_level: int) -> None:
2936 nonlocal in_flight, overlapped
2937 in_flight += 1
2938 overlapped = overlapped or in_flight > 1
2939 try:
2940 await set_group_volume(group_player, volume_level)
2941 finally:
2942 in_flight -= 1
2943
2944 controller.set_group_volume = _track_overlap # type: ignore[method-assign]
2945
2946 first = asyncio.create_task(controller.cmd_group_volume("leader", 80))
2947 await slow_leader.reached.wait()
2948 # a synced member adjusts the very same group as its leader
2949 second = asyncio.create_task(controller.cmd_group_volume("member", 30))
2950 await asyncio.gather(first, second)
2951
2952 assert overlapped is False
2953 for player in players.values():
2954 player.update_state()
2955 assert player.state.volume_level == 30
2956
2957 async def test_a_nudge_from_a_member_steps_the_volume_of_the_group(
2958 self, mock_mass: MagicMock
2959 ) -> None:
2960 """A group nudge addressed to a member steps the group, not the member itself."""
2961 controller, players = self._make_synced_pair(mock_mass, volumes={"member": 40})
2962 # the group sits at the volume of its loudest member, the member at its own
2963 assert players["leader"].state.group_volume == 50
2964 assert players["member"].state.group_volume == 40
2965
2966 await controller.cmd_group_volume_up("member")
2967
2968 for player in players.values():
2969 player.update_state()
2970 assert players["leader"].state.volume_level == 55
2971
2972 async def test_an_individual_volume_command_waits_for_the_group(
2973 self, mock_mass: MagicMock
2974 ) -> None:
2975 """A member's own volume command may not be overtaken by a group change."""
2976 slow_member = SlowDevice("member", asyncio.Event())
2977 controller, players = self._make_synced_pair(mock_mass, slow_member)
2978
2979 group = asyncio.create_task(controller.cmd_group_volume("leader", 80))
2980 await slow_member.reached.wait()
2981 individual = asyncio.create_task(controller.cmd_volume_set("member", 10))
2982 await asyncio.gather(group, individual)
2983
2984 for player in players.values():
2985 player.update_state()
2986 assert players["member"].state.volume_level == 10
2987 assert players["leader"].state.volume_level == 80
2988
2989 async def test_a_muted_leader_keeps_its_mute_on_a_group_volume_change(
2990 self, mock_mass: MagicMock
2991 ) -> None:
2992 """A muted sync leader keeps its mute through a group volume change without blocking."""
2993 controller, players = self._make_synced_pair(mock_mass)
2994 players["leader"]._attr_volume_muted = True
2995 players["leader"].update_state(force_update=True, signal_event=False)
2996
2997 # a sync leader is a member of its own group, so the fan-out sets the volume
2998 # of the very player the group command is running for. This must not deadlock
2999 # on a nested cmd_volume_mute call under the group's own volume lock.
3000 async with asyncio.timeout(5):
3001 await controller.cmd_group_volume("leader", 30)
3002
3003 players["leader"].update_state()
3004 assert players["leader"].state.volume_muted is True
3005 assert players["leader"].state.volume_level == 30
3006
3007
3008class LateReportingDevice:
3009 """A mocked device that only reports the volume it was given back when told to."""
3010
3011 def __init__(self, command_delay: float = 0) -> None:
3012 """
3013 Initialize the mocked device.
3014
3015 :param command_delay: Seconds a single volume command takes to reach the device.
3016 """
3017 self.commands: list[int] = []
3018 self._command_delay = command_delay
3019 self._pending: dict[MockPlayer, int] = {}
3020
3021 def bind(self, player: MockPlayer) -> None:
3022 """Make the given player answer its volume commands like this device."""
3023
3024 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
3025 self.commands.append(volume)
3026 if self._command_delay:
3027 await asyncio.sleep(self._command_delay)
3028 self._pending[_player] = volume
3029
3030 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
3031
3032 def report(self) -> None:
3033 """Report the volume of every command received so far back to the player state."""
3034 for player, volume in self._pending.items():
3035 player._attr_volume_level = volume
3036 player.update_state(force_update=True, signal_event=False)
3037 self._pending.clear()
3038
3039
3040class TestVolumeNudgeTarget:
3041 """Volume nudges step from the level last commanded, not from a lagging report."""
3042
3043 def _make_player(
3044 self, mock_mass: MagicMock, command_delay: float = 0
3045 ) -> tuple[PlayerController, MockPlayer, LateReportingDevice]:
3046 """
3047 Build a single player at volume 50 whose device reports back on request.
3048
3049 :param command_delay: Seconds a single volume command takes to reach the device.
3050 """
3051 controller = PlayerController(mock_mass)
3052 controller.config = _volume_step_config(5)
3053 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3054 player = MockPlayer(provider, "player_1", "Player 1")
3055 player._attr_volume_level = 50
3056 device = LateReportingDevice(command_delay)
3057 device.bind(player)
3058 controller._players = {"player_1": player}
3059 mock_mass.players = controller
3060 mock_mass.player_queues.get = MagicMock(return_value=None)
3061 player.set_initialized()
3062 player.update_state(signal_event=False)
3063 # a second, forced pass so the player derives its group volume from its own state
3064 player.update_state(force_update=True, signal_event=False)
3065 return controller, player, device
3066
3067 def _make_synced_pair(
3068 self, mock_mass: MagicMock, command_delay: float = 0
3069 ) -> tuple[PlayerController, dict[str, MockPlayer], LateReportingDevice]:
3070 """
3071 Build a leader synced to one member, both at volume 50, both late reporting.
3072
3073 :param command_delay: Seconds a single volume command takes to reach the device.
3074 """
3075 controller = PlayerController(mock_mass)
3076 controller.config = _volume_step_config(5)
3077 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3078 device = LateReportingDevice(command_delay)
3079 players: dict[str, MockPlayer] = {}
3080 for player_id in ("leader", "member"):
3081 player = MockPlayer(provider, player_id, player_id.title())
3082 player._attr_volume_level = 50
3083 device.bind(player)
3084 players[player_id] = player
3085 players["leader"]._attr_group_members = ["member"]
3086 controller._players = dict(players)
3087 mock_mass.players = controller
3088 mock_mass.player_queues.get = MagicMock(return_value=None)
3089 for player in players.values():
3090 player.set_initialized()
3091 player._cache.clear()
3092 player.update_state(signal_event=False)
3093 # a second, forced pass so the leader derives its group volume from the member
3094 for player in players.values():
3095 player.update_state(force_update=True, signal_event=False)
3096 return controller, players, device
3097
3098 async def test_nudges_up_stack_before_the_player_reports_back(
3099 self, mock_mass: MagicMock
3100 ) -> None:
3101 """Three volume ups in a row climb, even with no report in between."""
3102 controller, _player, device = self._make_player(mock_mass)
3103
3104 for _ in range(3):
3105 await controller.cmd_volume_up("player_1")
3106
3107 assert device.commands == [55, 60, 65]
3108
3109 async def test_nudges_down_stack_before_the_player_reports_back(
3110 self, mock_mass: MagicMock
3111 ) -> None:
3112 """Three volume downs in a row descend, 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_down("player_1")
3117
3118 assert device.commands == [45, 40, 35]
3119
3120 async def test_nudges_that_overlap_each_get_their_own_step(self, mock_mass: MagicMock) -> None:
3121 """Volume ups that arrive while an earlier one is still on its way all count."""
3122 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
3123
3124 await asyncio.gather(*(controller.cmd_volume_up("player_1") for _ in range(3)))
3125
3126 assert device.commands == [55, 60, 65]
3127
3128 async def test_a_queued_nudge_does_not_undo_the_level_a_later_one_claimed(
3129 self, mock_mass: MagicMock
3130 ) -> None:
3131 """A nudge waiting for the volume lock may not take the level back down."""
3132 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
3133 tasks = [asyncio.create_task(controller.cmd_volume_up("player_1")) for _ in range(3)]
3134 # let all three claim their level; the second and third then queue on the lock
3135 await asyncio.sleep(0)
3136 # wait for the second one to reach the device, so a nudge sent now reads whatever
3137 # that (by then oldest) command left behind
3138 while len(device.commands) < 2:
3139 await asyncio.sleep(0.005)
3140 tasks.append(asyncio.create_task(controller.cmd_volume_up("player_1")))
3141
3142 await asyncio.gather(*tasks)
3143
3144 assert device.commands == [55, 60, 65, 70]
3145
3146 async def test_a_group_nudge_moves_every_member_up(self, mock_mass: MagicMock) -> None:
3147 """A group nudge up may not send a member the other way."""
3148 controller, players, device = self._make_synced_pair(mock_mass)
3149 # the loudest member is the one that was just turned down on its own, so the
3150 # level it still reports sits above the level the group is being stepped to
3151 players["member"]._attr_volume_level = 80
3152 for _ in range(3):
3153 for player in players.values():
3154 player._cache.clear()
3155 player.update_state(force_update=True, signal_event=False)
3156
3157 await controller.cmd_volume_set("member", 40)
3158 await controller.cmd_group_volume_up("leader")
3159
3160 assert device.commands == [40, 55, 46]
3161
3162 async def test_a_group_nudge_on_an_ungrouped_player_steps_its_own_volume(
3163 self, mock_mass: MagicMock
3164 ) -> None:
3165 """A group nudge falls back to the player itself, which has no group to step."""
3166 controller, _player, device = self._make_player(mock_mass)
3167
3168 for _ in range(3):
3169 await controller.cmd_group_volume_up("player_1")
3170
3171 assert device.commands == [55, 60, 65]
3172
3173 async def test_a_nudge_steps_from_the_level_the_slider_was_left_at(
3174 self, mock_mass: MagicMock
3175 ) -> None:
3176 """A nudge right after a volume set steps from that set level."""
3177 controller, _player, device = self._make_player(mock_mass)
3178
3179 await controller.cmd_volume_set("player_1", 20)
3180 await controller.cmd_volume_up("player_1")
3181
3182 assert device.commands == [20, 25]
3183
3184 async def test_a_change_on_the_device_wins_once_the_last_command_ages_out(
3185 self, mock_mass: MagicMock
3186 ) -> None:
3187 """A volume turned down on the device itself is the base of the next nudge."""
3188 controller, player, device = self._make_player(mock_mass)
3189 await controller.cmd_volume_up("player_1")
3190 device.report()
3191 # the volume is turned down on the device itself, well after that command
3192 player._attr_volume_level = 20
3193 player.update_state(force_update=True, signal_event=False)
3194
3195 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
3196 await controller.cmd_volume_up("player_1")
3197
3198 assert device.commands[-1] == 25
3199
3200 async def test_group_nudges_up_stack_before_the_players_report_back(
3201 self, mock_mass: MagicMock
3202 ) -> None:
3203 """Three group volume ups in a row climb, even with no report in between."""
3204 controller, _players, device = self._make_synced_pair(mock_mass)
3205
3206 for _ in range(3):
3207 await controller.cmd_group_volume_up("leader")
3208
3209 assert device.commands == [55, 55, 60, 60, 65, 65]
3210
3211 async def test_group_nudges_down_stack_before_the_players_report_back(
3212 self, mock_mass: MagicMock
3213 ) -> None:
3214 """Three group volume downs in a row descend, 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_down("leader")
3219
3220 assert device.commands == [45, 45, 40, 40, 35, 35]
3221
3222 async def test_a_group_nudge_after_a_member_was_set_on_its_own_keeps_the_step(
3223 self, mock_mass: MagicMock
3224 ) -> None:
3225 """Setting one member does not send the group back to the level it reports."""
3226 controller, _players, device = self._make_synced_pair(mock_mass)
3227
3228 await controller.cmd_group_volume_up("leader")
3229 await controller.cmd_volume_set("member", 10)
3230 await controller.cmd_group_volume_up("leader")
3231
3232 # the loudest member was commanded to 55, so the group steps from there, and the
3233 # member that was just turned down keeps its share of the group volume
3234 assert device.commands == [55, 55, 10, 60, 20]
3235
3236 async def test_a_group_volume_beyond_the_range_does_not_pin_the_next_nudge(
3237 self, mock_mass: MagicMock
3238 ) -> None:
3239 """An out of range group volume leaves the members at 100, not above it."""
3240 controller, _players, device = self._make_synced_pair(mock_mass)
3241
3242 await controller.cmd_group_volume("leader", 200)
3243 await controller.cmd_group_volume_down("leader")
3244
3245 assert device.commands == [100, 100, 95, 95]
3246
3247 async def test_a_group_nudge_from_a_member_shares_the_target_of_its_leader(
3248 self, mock_mass: MagicMock
3249 ) -> None:
3250 """Group nudges addressed to a member and to its leader step the same group."""
3251 controller, _players, device = self._make_synced_pair(mock_mass)
3252
3253 await controller.cmd_group_volume_up("leader")
3254 await controller.cmd_group_volume_up("member")
3255
3256 assert device.commands == [55, 55, 60, 60]
3257
3258
3259class TestGroupVolumeReference:
3260 """A group volume adjustment interpolates from the levels its members are really at."""
3261
3262 def _make_group(
3263 self, mock_mass: MagicMock, first_volume: int = 50, second_volume: int = 50
3264 ) -> tuple[PlayerController, dict[str, MockPlayer], dict[str, list[int]]]:
3265 """
3266 Build a group player with two members, at the given volumes.
3267
3268 :param first_volume: Volume level the first member starts at.
3269 :param second_volume: Volume level the second member starts at.
3270 :return: The controller, the players by id and the volumes commanded per member.
3271 """
3272 controller = PlayerController(mock_mass)
3273 controller.config = _volume_step_config(5)
3274 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3275 commands: dict[str, list[int]] = {}
3276 members: dict[str, MockPlayer] = {}
3277 for player_id, volume in (("member_1", first_volume), ("member_2", second_volume)):
3278 member = MockPlayer(provider, player_id, player_id.title())
3279 member._attr_volume_level = volume
3280 commands[player_id] = []
3281
3282 async def _volume_set(volume: int, _recorded: list[int] = commands[player_id]) -> None:
3283 _recorded.append(volume)
3284
3285 member.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
3286 members[player_id] = member
3287 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3288 group._attr_group_members = list(members)
3289 players = {"group": group, **members}
3290 controller._players = dict(players)
3291 provider.players = list(players.values())
3292 mock_mass.players = controller
3293 mock_mass.player_queues.get = MagicMock(return_value=None)
3294 for player in players.values():
3295 player.set_initialized()
3296 player._cache.clear()
3297 player.update_state(signal_event=False)
3298 # a second, forced pass so the group derives its group volume from the members
3299 for player in players.values():
3300 player.update_state(force_update=True, signal_event=False)
3301 return controller, players, commands
3302
3303 def _report(self, player: MockPlayer, volume: int) -> None:
3304 """Let the player report the given volume level, the way its provider would."""
3305 player._attr_volume_level = volume
3306 player._cache.clear()
3307 player.update_state(force_update=True)
3308
3309 async def test_a_member_turned_down_on_the_device_keeps_its_level(
3310 self, mock_mass: MagicMock
3311 ) -> None:
3312 """A group nudge up may not undo a member that was turned down on the device."""
3313 controller, players, commands = self._make_group(mock_mass)
3314 await controller.cmd_group_volume_up("group")
3315 self._report(players["member_1"], 55)
3316 self._report(players["member_2"], 55)
3317
3318 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
3319 # the member is turned down on the device itself, well after that command
3320 self._report(players["member_2"], 20)
3321 await controller.cmd_group_volume_up("group")
3322
3323 # the group steps from 55 to 60, and the member keeps its (much lower) share
3324 assert commands["member_1"] == [55, 60]
3325 assert commands["member_2"] == [55, 29]
3326
3327 async def test_a_member_reporting_the_level_it_was_given_keeps_the_balance(
3328 self, mock_mass: MagicMock
3329 ) -> None:
3330 """Members confirming a group nudge may not become the reference themselves."""
3331 controller, players, commands = self._make_group(mock_mass)
3332 await controller.cmd_volume_set("member_2", 20)
3333 self._report(players["member_2"], 20)
3334
3335 await controller.cmd_group_volume_up("group")
3336 self._report(players["member_1"], 55)
3337 self._report(players["member_2"], 28)
3338 await controller.cmd_group_volume_down("group")
3339
3340 # stepping back down to where the group started restores the balance it had
3341 assert commands["member_1"] == [55, 50]
3342 assert commands["member_2"] == [20, 28, 20]
3343
3344 async def test_a_member_that_dropped_off_no_longer_sets_the_reference(
3345 self, mock_mass: MagicMock
3346 ) -> None:
3347 """A group nudge up may not turn the group down over an unreachable member."""
3348 controller, players, commands = self._make_group(
3349 mock_mass, first_volume=30, second_volume=80
3350 )
3351 await controller.cmd_group_volume_up("group")
3352 self._report(players["member_1"], 48)
3353 self._report(players["member_2"], 85)
3354
3355 # the loudest member drops off the network; a permanent group keeps it as a member
3356 players["member_2"]._attr_available = False
3357 players["member_2"]._cache.clear()
3358 players["member_2"].update_state(force_update=True)
3359 await controller.cmd_group_volume_up("group")
3360
3361 assert commands["member_1"] == [48, 53]
3362 assert commands["member_2"] == [85]
3363
3364 async def test_a_group_reporting_its_own_volume_keeps_the_balance(
3365 self, mock_mass: MagicMock
3366 ) -> None:
3367 """A group that reports a volume of its own may not reset its own reference."""
3368 controller, players, commands = self._make_group(
3369 mock_mass, first_volume=50, second_volume=20
3370 )
3371 await controller.cmd_group_volume_up("group")
3372 self._report(players["member_1"], 55)
3373 self._report(players["member_2"], 28)
3374 # a cast group reports the volume of its members as its own
3375 self._report(players["group"], 55)
3376 await controller.cmd_group_volume_down("group")
3377
3378 assert commands["member_1"] == [55, 50]
3379 assert commands["member_2"] == [28, 20]
3380
3381 async def test_a_member_clamped_to_its_volume_limit_keeps_that_level(
3382 self, mock_mass: MagicMock
3383 ) -> None:
3384 """Correcting a volume that ran past its limit is not a level the group commanded."""
3385 mock_mass.config.get_raw_player_config_value = MagicMock(
3386 side_effect=_player_config_stub(min_volume=20)
3387 )
3388 use_real_create_task(mock_mass)
3389 # with a min volume of 20 the members report device volumes of 20-100 for 0-100
3390 controller, players, commands = self._make_group(
3391 mock_mass, first_volume=60, second_volume=60
3392 )
3393 await controller.cmd_group_volume_up("group")
3394 self._report(players["member_1"], 64)
3395 self._report(players["member_2"], 64)
3396
3397 # the member is turned below its own minimum, so it is corrected back up to 0
3398 self._report(players["member_2"], 10)
3399 await controller.cmd_group_volume_up("group")
3400
3401 assert commands["member_1"] == [64, 68]
3402 assert commands["member_2"] == [64, 20, 28]
3403
3404
3405class TestFakeMuteInGroup:
3406 """A fake muted player in a group follows the mute lock, just like a native mute."""
3407
3408 def _make_synced_pair(
3409 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
3410 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3411 """
3412 Build a leader synced to one member.
3413
3414 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
3415 """
3416
3417 def _conf(player_id: str, key: str, default: object = None) -> object:
3418 if key == CONF_MUTE_CONTROL and player_id == "member":
3419 return member_mute_control
3420 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
3421 player_id, key, default
3422 )
3423
3424 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
3425 controller = PlayerController(mock_mass)
3426 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3427 players: dict[str, MockPlayer] = {}
3428 for player_id in ("leader", "member"):
3429 player = MockPlayer(provider, player_id, player_id.title())
3430 player._attr_supported_features = {
3431 PlayerFeature.VOLUME_SET,
3432 PlayerFeature.VOLUME_MUTE,
3433 }
3434 player._attr_volume_level = 50
3435 # let the mocked native volume control behave like a real device
3436 player.volume_set = AsyncMock( # type: ignore[method-assign]
3437 side_effect=lambda volume, _player=player: setattr(
3438 _player, "_attr_volume_level", volume
3439 )
3440 )
3441 players[player_id] = player
3442 players["leader"]._attr_group_members = ["member"]
3443 controller._players = dict(players)
3444 mock_mass.players = controller
3445 mock_mass.player_queues.get = MagicMock(return_value=None)
3446 for player in players.values():
3447 player.set_initialized()
3448 player._cache.clear()
3449 player.update_state(signal_event=False)
3450 # a second pass, so the group volume of the leader accounts for its member
3451 for player in players.values():
3452 player.update_state(signal_event=False)
3453 return controller, players
3454
3455 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
3456 """A group volume change may not bring a muted fake mute pair back to life."""
3457 controller, players = self._make_synced_pair(mock_mass)
3458 await controller.cmd_group_volume_mute("leader", True)
3459
3460 await controller.cmd_group_volume("leader", 30)
3461
3462 for player in players.values():
3463 player.update_state()
3464 assert player.state.volume_muted is True
3465 assert player.state.volume_level == 0
3466
3467 async def test_a_muted_member_does_not_inflate_a_group_nudge(
3468 self, mock_mass: MagicMock
3469 ) -> None:
3470 """A volume level a muted member never receives may not step the group."""
3471 controller, players = self._make_synced_pair(mock_mass)
3472 controller.config = _volume_step_config(5)
3473 await controller.cmd_volume_mute("member", True)
3474 # let the group volume of the leader account for the muted member
3475 players["leader"].update_state(signal_event=False)
3476 # the member is held silent, so this level never reaches it
3477 await controller.cmd_volume_set("member", 60)
3478
3479 await controller.cmd_group_volume_up("leader")
3480
3481 players["leader"].update_state()
3482 assert players["leader"].state.volume_level == 55
3483
3484 async def test_a_nudge_after_unmuting_steps_from_the_restored_volume(
3485 self, mock_mass: MagicMock
3486 ) -> None:
3487 """Unmuting hands the next nudge the volume it restored, not the muted 0."""
3488 controller, players = self._make_synced_pair(mock_mass)
3489 controller.config = _volume_step_config(5)
3490 await controller.cmd_volume_mute("member", True)
3491 players["leader"].update_state(signal_event=False)
3492 # a level set while muted never reaches the member
3493 await controller.cmd_volume_set("member", 60)
3494
3495 await controller.cmd_volume_mute("member", False)
3496 await controller.cmd_volume_up("member")
3497
3498 players["member"].update_state()
3499 assert players["member"].state.volume_level == 55
3500
3501 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
3502 """Turning a group down leaves a single muted member silent, at its own volume."""
3503 controller, players = self._make_synced_pair(mock_mass)
3504 await controller.cmd_volume_mute("member", True)
3505 # let the group volume of the leader account for the muted member
3506 players["leader"].update_state(signal_event=False)
3507
3508 await controller.cmd_group_volume("leader", 25)
3509
3510 for player in players.values():
3511 player.update_state()
3512 member_state = players["member"].state
3513 assert member_state.volume_muted is True
3514 assert member_state.volume_level == 0
3515 # the player that is not muted follows the group volume as usual
3516 assert players["leader"].state.volume_level == 25
3517 # unmuting brings the member back at the volume it had before it was muted
3518 await controller.cmd_volume_mute("member", False)
3519 players["member"].update_state()
3520 assert players["member"].state.volume_level == 50
3521
3522 async def test_unmute_restores_the_volume_from_before_the_mute(
3523 self, mock_mass: MagicMock
3524 ) -> None:
3525 """A group volume change while muted may not alter the volume to restore."""
3526 controller, players = self._make_synced_pair(mock_mass)
3527 await controller.cmd_group_volume_mute("leader", True)
3528 await controller.cmd_group_volume("leader", 30)
3529
3530 await controller.cmd_group_volume_mute("leader", False)
3531
3532 for player in players.values():
3533 player.update_state()
3534 assert player.state.volume_muted is False
3535 assert player.state.volume_level == 50
3536
3537 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
3538 """Members with a different mute control stay muted alike on a group volume change."""
3539 controller, players = self._make_synced_pair(
3540 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
3541 )
3542 mute = AsyncMock(
3543 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
3544 )
3545 players["member"].volume_mute = mute # type: ignore[method-assign]
3546 await controller.cmd_group_volume_mute("leader", True)
3547
3548 await controller.cmd_group_volume("leader", 30)
3549
3550 mute.assert_awaited_once_with(True)
3551 for player in players.values():
3552 player.update_state()
3553 assert player.state.volume_muted is True
3554
3555
3556class TestMuteControlGuard:
3557 """Muting is gated on the mute control, independently of the volume control."""
3558
3559 def _make_player(
3560 self,
3561 mock_mass: MagicMock,
3562 mute_control: str,
3563 volume_control: str,
3564 controls: dict[str, PlayerControl] | None = None,
3565 features: set[PlayerFeature] | None = None,
3566 ) -> tuple[PlayerController, MockPlayer]:
3567 """Build a controller with a single player using the given control config."""
3568 mock_mass.config.get_raw_player_config_value = MagicMock(
3569 side_effect=_player_config_stub(
3570 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
3571 )
3572 )
3573 controller = PlayerController(mock_mass)
3574 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3575 player = MockPlayer(provider, "player_1", "Player 1")
3576 if features is not None:
3577 player._attr_supported_features = features
3578 controller._players = {"player_1": player}
3579 controller._controls = controls or {}
3580 mock_mass.players = controller
3581 mock_mass.player_queues.get = MagicMock(return_value=None)
3582 player.set_initialized()
3583 player.update_state(signal_event=False)
3584 return controller, player
3585
3586 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3587 """A player without volume control still mutes through an external PlayerControl."""
3588 mute_set = AsyncMock()
3589 control = PlayerControl(
3590 id="ext_mute",
3591 provider="test",
3592 name="External Mute",
3593 supports_mute=True,
3594 mute_set=mute_set,
3595 )
3596 controller, player = self._make_player(
3597 mock_mass,
3598 mute_control="ext_mute",
3599 volume_control=PLAYER_CONTROL_NONE,
3600 controls={"ext_mute": control},
3601 )
3602 assert player.mute_control == "ext_mute"
3603
3604 await controller.cmd_volume_mute("player_1", True)
3605 mute_set.assert_awaited_once_with(True)
3606
3607 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3608 """A player without volume control still mutes natively."""
3609 controller, player = self._make_player(
3610 mock_mass,
3611 mute_control=PLAYER_CONTROL_NATIVE,
3612 volume_control=PLAYER_CONTROL_NONE,
3613 # native mute is only honored while the player advertises the feature
3614 features={PlayerFeature.VOLUME_MUTE},
3615 )
3616 volume_mute = AsyncMock()
3617 player.volume_mute = volume_mute # type: ignore[method-assign]
3618
3619 await controller.cmd_volume_mute("player_1", True)
3620 volume_mute.assert_awaited_once_with(True)
3621
3622 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
3623 """A player with volume control but no mute control rejects the command."""
3624 controller, player = self._make_player(
3625 mock_mass,
3626 mute_control=PLAYER_CONTROL_NONE,
3627 volume_control=PLAYER_CONTROL_NATIVE,
3628 )
3629 volume_mute = AsyncMock()
3630 player.volume_mute = volume_mute # type: ignore[method-assign]
3631
3632 with pytest.raises(UnsupportedFeaturedException):
3633 await controller.cmd_volume_mute("player_1", True)
3634 volume_mute.assert_not_awaited()
3635
3636 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
3637 """Fake mute needs a volume control to drive, so it rejects the command outright."""
3638 controller, player = self._make_player(
3639 mock_mass,
3640 mute_control=PLAYER_CONTROL_FAKE,
3641 volume_control=PLAYER_CONTROL_NONE,
3642 )
3643 player._attr_volume_level = 40
3644
3645 with pytest.raises(UnsupportedFeaturedException):
3646 await controller.cmd_volume_mute("player_1", True)
3647 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
3648 assert ATTR_FAKE_MUTE not in player.extra_data
3649
3650 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
3651 """A mute control that disappeared after being resolved is reported, not ignored."""
3652 control = PlayerControl(
3653 id="ext_mute",
3654 provider="test",
3655 name="External Mute",
3656 supports_mute=True,
3657 mute_set=AsyncMock(),
3658 )
3659 controller, player = self._make_player(
3660 mock_mass,
3661 mute_control="ext_mute",
3662 volume_control=PLAYER_CONTROL_NONE,
3663 controls={"ext_mute": control},
3664 )
3665 # the resolved control is cached on the player, so removing it here leaves
3666 # the player pointing at a control that no longer exists
3667 assert player.mute_control == "ext_mute"
3668 controller._controls = {}
3669
3670 with pytest.raises(UnsupportedFeaturedException):
3671 await controller.cmd_volume_mute("player_1", True)
3672
3673 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
3674 """Unmuting clears a mute lock left behind by a since-removed mute control."""
3675 controller, player = self._make_player(
3676 mock_mass,
3677 mute_control=PLAYER_CONTROL_NONE,
3678 volume_control=PLAYER_CONTROL_NATIVE,
3679 )
3680 player.extra_data[ATTR_MUTE_LOCK] = True
3681
3682 with pytest.raises(UnsupportedFeaturedException):
3683 await controller.cmd_volume_mute("player_1", False)
3684 assert ATTR_MUTE_LOCK not in player.extra_data
3685
3686 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
3687 """A grouped player whose mute command failed is not left holding a mute lock."""
3688 control = PlayerControl(
3689 id="ext_mute",
3690 provider="test",
3691 name="External Mute",
3692 supports_mute=False,
3693 )
3694 controller, player = self._make_player(
3695 mock_mass,
3696 mute_control="ext_mute",
3697 volume_control=PLAYER_CONTROL_NONE,
3698 controls={"ext_mute": control},
3699 )
3700 player.state.synced_to = "leader"
3701
3702 with pytest.raises(UnsupportedFeaturedException):
3703 await controller.cmd_volume_mute("player_1", True)
3704 assert ATTR_MUTE_LOCK not in player.extra_data
3705
3706 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
3707 """A failed mute leaves the lock of an earlier successful mute in place."""
3708 control = PlayerControl(
3709 id="ext_mute",
3710 provider="test",
3711 name="External Mute",
3712 supports_mute=False,
3713 )
3714 controller, player = self._make_player(
3715 mock_mass,
3716 mute_control="ext_mute",
3717 volume_control=PLAYER_CONTROL_NONE,
3718 controls={"ext_mute": control},
3719 )
3720 player.state.synced_to = "leader"
3721 player.extra_data[ATTR_MUTE_LOCK] = True
3722
3723 with pytest.raises(UnsupportedFeaturedException):
3724 await controller.cmd_volume_mute("player_1", True)
3725 assert player.extra_data[ATTR_MUTE_LOCK] is True
3726
3727
3728class TestGroupMuteMemberFilter:
3729 """Group mute skips members that have no mute control of their own."""
3730
3731 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
3732 """A member without a mute control must not fail the whole group command."""
3733 controller = PlayerController(mock_mass)
3734 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3735 leader = MockPlayer(provider, "leader", "Leader")
3736 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3737 leader._attr_group_members = ["leader", "member"]
3738 member = MockPlayer(provider, "member", "Member")
3739 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3740 controller._players = {"leader": leader, "member": member}
3741 mock_mass.players = controller
3742 mock_mass.player_queues.get = MagicMock(return_value=None)
3743 for player in (leader, member):
3744 player.set_initialized()
3745 player.update_state(signal_event=False)
3746 leader_mute = AsyncMock()
3747 leader.volume_mute = leader_mute # type: ignore[method-assign]
3748
3749 await controller.cmd_group_volume_mute("leader", True)
3750 leader_mute.assert_awaited_once_with(True)
3751
3752
3753class TestGroupPlayerMuteRedirect:
3754 """A mute command on a group player is handled at group level."""
3755
3756 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
3757 """Build a controller with a group player holding a single mute capable member."""
3758 controller = PlayerController(mock_mass)
3759 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3760 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3761 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3762 group._attr_group_members = ["member"]
3763 member = MockPlayer(provider, "member", "Member")
3764 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3765 controller._players = {"group": group, "member": member}
3766 mock_mass.players = controller
3767 mock_mass.player_queues.get = MagicMock(return_value=None)
3768 for player in (group, member):
3769 player.set_initialized()
3770 player.update_state(signal_event=False)
3771 return controller, group, member
3772
3773 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
3774 """A group player has no mute of its own, so the members must be muted instead."""
3775 controller, _group, member = self._setup(mock_mass)
3776 member_mute = AsyncMock()
3777 member.volume_mute = member_mute # type: ignore[method-assign]
3778
3779 await controller.cmd_volume_mute("group", True)
3780
3781 member_mute.assert_awaited_once_with(True)
3782
3783 async def test_mute_on_group_player_without_own_mute_control(
3784 self, mock_mass: MagicMock
3785 ) -> None:
3786 """A group that has no mute control of its own must still mute its members."""
3787 controller, group, member = self._setup(mock_mass)
3788 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
3789 group._cache.clear()
3790 group.update_state(signal_event=False)
3791 assert group.mute_control == PLAYER_CONTROL_NONE
3792 member_mute = AsyncMock()
3793 member.volume_mute = member_mute # type: ignore[method-assign]
3794
3795 await controller.cmd_volume_mute("group", True)
3796
3797 member_mute.assert_awaited_once_with(True)
3798
3799 async def test_mute_on_group_player_without_mute_capable_members(
3800 self, mock_mass: MagicMock
3801 ) -> None:
3802 """A group whose members cannot mute must not raise, just like group mute itself."""
3803 controller, _group, member = self._setup(mock_mass)
3804 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3805 member._cache.clear()
3806 member.update_state(signal_event=False)
3807
3808 await controller.cmd_volume_mute("group", True)
3809
3810
3811class TestGroupMuteOnNonGroupPlayer:
3812 """A group mute command works on any player, just like the group volume command."""
3813
3814 def _setup(
3815 self, mock_mass: MagicMock, *members: str
3816 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3817 """Build a controller with a mute capable leader synced to the given members."""
3818 controller = PlayerController(mock_mass)
3819 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3820 players: dict[str, MockPlayer] = {}
3821 for player_id in ("leader", *members):
3822 player = MockPlayer(provider, player_id, player_id.title())
3823 player._attr_supported_features = {
3824 PlayerFeature.VOLUME_SET,
3825 PlayerFeature.VOLUME_MUTE,
3826 }
3827 player._attr_volume_level = 50
3828 players[player_id] = player
3829 if members:
3830 # the leader is not listed as its own member here, so the tests also cover
3831 # that a sync leader is injected into its own final group_members
3832 players["leader"]._attr_group_members = list(members)
3833 controller._players = dict(players)
3834 mock_mass.players = controller
3835 mock_mass.player_queues.get = MagicMock(return_value=None)
3836 for player in players.values():
3837 player.set_initialized()
3838 player._cache.clear()
3839 player.update_state(signal_event=False)
3840 return controller, players
3841
3842 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
3843 """Replace the native mute command of every given player with a mock."""
3844 mutes: dict[str, AsyncMock] = {}
3845 for player_id, player in players.items():
3846 mutes[player_id] = AsyncMock()
3847 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
3848 return mutes
3849
3850 async def test_group_mute_on_synced_member_redirects_to_leader(
3851 self, mock_mass: MagicMock
3852 ) -> None:
3853 """A member of a sync group mutes the whole group through its sync leader."""
3854 controller, players = self._setup(mock_mass, "member")
3855 assert players["member"].state.synced_to == "leader"
3856 mutes = self._stub_mutes(players)
3857
3858 await controller.cmd_group_volume_mute("member", True)
3859
3860 mutes["leader"].assert_awaited_once_with(True)
3861 mutes["member"].assert_awaited_once_with(True)
3862 assert ATTR_MUTE_LOCK in players["member"].extra_data
3863
3864 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
3865 self, mock_mass: MagicMock
3866 ) -> None:
3867 """A sync leader is part of its own member list, so it must be muted only once."""
3868 controller, players = self._setup(mock_mass, "member")
3869 mutes = self._stub_mutes(players)
3870
3871 await controller.cmd_group_volume_mute("leader", True)
3872
3873 mutes["leader"].assert_awaited_once_with(True)
3874 mutes["member"].assert_awaited_once_with(True)
3875
3876 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
3877 """A player that is not grouped at all is muted as a normal player."""
3878 controller, players = self._setup(mock_mass)
3879 mutes = self._stub_mutes(players)
3880
3881 await controller.cmd_group_volume_mute("leader", True)
3882
3883 mutes["leader"].assert_awaited_once_with(True)
3884
3885 async def test_group_mute_on_plain_player_without_mute_control(
3886 self, mock_mass: MagicMock
3887 ) -> None:
3888 """A plain player that cannot mute reports that, just like a normal mute command."""
3889 controller, players = self._setup(mock_mass)
3890 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
3891 players["leader"]._cache.clear()
3892 players["leader"].update_state(signal_event=False)
3893
3894 with pytest.raises(UnsupportedFeaturedException):
3895 await controller.cmd_group_volume_mute("leader", True)
3896
3897 async def test_group_unmute_on_synced_member_redirects_to_leader(
3898 self, mock_mass: MagicMock
3899 ) -> None:
3900 """Unmuting through a member clears the mute (and mute lock) of every group member."""
3901 controller, players = self._setup(mock_mass, "member")
3902 mutes = self._stub_mutes(players)
3903 players["member"].extra_data[ATTR_MUTE_LOCK] = True
3904
3905 await controller.cmd_group_volume_mute("member", False)
3906
3907 mutes["leader"].assert_awaited_once_with(False)
3908 mutes["member"].assert_awaited_once_with(False)
3909 assert ATTR_MUTE_LOCK not in players["member"].extra_data
3910
3911 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
3912 """A sync leader is as much part of the group as its members, so it is locked too."""
3913 controller, players = self._setup(mock_mass, "member")
3914 self._stub_mutes(players)
3915
3916 await controller.cmd_group_volume_mute("leader", True)
3917
3918 assert ATTR_MUTE_LOCK in players["leader"].extra_data
3919 assert ATTR_MUTE_LOCK in players["member"].extra_data
3920
3921 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
3922 """A group volume change may not half-unmute a muted pair of directly synced players."""
3923 controller, players = self._setup(mock_mass, "member")
3924 self._stub_mutes(players)
3925 await controller.cmd_group_volume_mute("leader", True)
3926 # the mock players do not act on the mute command, so reflect it in their state
3927 for player in players.values():
3928 player._attr_volume_muted = True
3929 player.update_state(signal_event=False)
3930 player.volume_set = AsyncMock() # type: ignore[method-assign]
3931 # re-stub so only the mute commands of the group volume change are counted
3932 mutes = self._stub_mutes(players)
3933
3934 await controller.cmd_group_volume("leader", 30)
3935
3936 for mute in mutes.values():
3937 mute.assert_not_awaited()
3938
3939
3940class TestMuteLockAfterUngroup:
3941 """
3942 Mute persistence across a volume-set command.
3943
3944 A native mute is never lifted by a volume command, grouped or not. A fake mute
3945 lock is honored only while the player it belongs to is still grouped.
3946 """
3947
3948 def _make_synced_pair(
3949 self, mock_mass: MagicMock, member_mute_control: str
3950 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3951 """
3952 Build a leader with one synced member.
3953
3954 :param member_mute_control: Mute control to configure on both players.
3955 """
3956 mock_mass.config.get_raw_player_config_value = MagicMock(
3957 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
3958 )
3959 controller = PlayerController(mock_mass)
3960 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3961 players: dict[str, MockPlayer] = {}
3962 for player_id in ("leader", "member"):
3963 player = MockPlayer(provider, player_id, player_id.title())
3964 player._attr_supported_features = {
3965 PlayerFeature.VOLUME_SET,
3966 PlayerFeature.VOLUME_MUTE,
3967 }
3968 player._attr_volume_level = 50
3969 player.volume_set = AsyncMock( # type: ignore[method-assign]
3970 side_effect=lambda volume, _player=player: setattr(
3971 _player, "_attr_volume_level", volume
3972 )
3973 )
3974 players[player_id] = player
3975 players["leader"]._attr_group_members = ["member"]
3976 controller._players = dict(players)
3977 mock_mass.players = controller
3978 mock_mass.player_queues.get = MagicMock(return_value=None)
3979 for player in players.values():
3980 player.set_initialized()
3981 player._cache.clear()
3982 player.update_state(signal_event=False)
3983 return controller, players
3984
3985 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
3986 """Drop the sync group, the way a provider side topology change does."""
3987 players["leader"]._attr_group_members = []
3988 for player in players.values():
3989 player.refresh_state(signal_event=False)
3990
3991 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
3992 """A fake muted player is no longer forced silent once its group is gone."""
3993 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3994 await controller.cmd_volume_mute("member", True)
3995 self._dissolve_group(players)
3996
3997 await controller.cmd_volume_set("member", 70)
3998
3999 players["member"].update_state()
4000 assert players["member"].state.volume_level == 70
4001 assert players["member"].state.volume_muted is False
4002
4003 async def test_natively_muted_player_keeps_its_mute_after_ungroup(
4004 self, mock_mass: MagicMock
4005 ) -> None:
4006 """A natively muted player keeps its mute on a volume change, group gone or not."""
4007 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
4008 mute = AsyncMock(
4009 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
4010 )
4011 players["member"].volume_mute = mute # type: ignore[method-assign]
4012 await controller.cmd_volume_mute("member", True)
4013 self._dissolve_group(players)
4014
4015 await controller.cmd_volume_set("member", 70)
4016
4017 mute.assert_awaited_once_with(True)
4018 players["member"].update_state()
4019 assert players["member"].state.volume_level == 70
4020 assert players["member"].state.volume_muted is True
4021
4022 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
4023 """A muted player that is still grouped stays silent on a volume change."""
4024 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
4025 await controller.cmd_volume_mute("member", True)
4026
4027 await controller.cmd_volume_set("member", 70)
4028
4029 players["member"].update_state()
4030 assert players["member"].state.volume_level == 0
4031 assert players["member"].state.volume_muted is True
4032
4033 async def test_protocol_player_follows_the_lock_of_its_parent(
4034 self, mock_mass: MagicMock
4035 ) -> None:
4036 """A protocol player inherits the lock of the parent it renders for, group and all."""
4037 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
4038 member = players["member"]
4039 protocol_player = MockPlayer(
4040 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
4041 "proto_member",
4042 "Member Protocol",
4043 player_type=PlayerType.PROTOCOL,
4044 )
4045 protocol_player._attr_supported_features = {
4046 PlayerFeature.VOLUME_SET,
4047 PlayerFeature.VOLUME_MUTE,
4048 }
4049 protocol_player._attr_volume_level = 50
4050 protocol_player.volume_set = AsyncMock( # type: ignore[method-assign]
4051 side_effect=lambda volume: setattr(protocol_player, "_attr_volume_level", volume)
4052 )
4053 protocol_player.set_protocol_parent_id("member")
4054 controller._players["proto_member"] = protocol_player
4055 member.set_linked_output_protocols(
4056 [
4057 LinkedOutputProtocol(
4058 output_protocol_id="proto_member",
4059 protocol_domain="sendspin",
4060 priority=40,
4061 )
4062 ]
4063 )
4064 protocol_player.set_initialized()
4065 protocol_player.update_state(signal_event=False)
4066 member.refresh_state(signal_event=False)
4067
4068 # the lock is earned by the parent while it is still grouped. The internal
4069 # handler is used to fake-mute the protocol player itself, bypassing the
4070 # public command's auto-resolve to its parent, so the fake-mute flag ends
4071 # up on the protocol player and the fake-mute volume path applies to it
4072 await controller.cmd_volume_mute("member", True)
4073 await controller._handle_cmd_volume_mute(protocol_player, PLAYER_CONTROL_FAKE, True)
4074
4075 # while the parent holds the lock, a volume command for the protocol player
4076 # is forced to 0 (stays silent) instead of releasing its fake mute
4077 await controller._handle_cmd_volume_set("proto_member", 70)
4078 protocol_player.update_state()
4079 assert protocol_player.state.volume_level == 0
4080
4081 self._dissolve_group(players)
4082 await controller._handle_cmd_volume_set("proto_member", 70)
4083
4084 protocol_player.update_state()
4085 assert protocol_player.state.volume_level == 70
4086
4087
4088class TestCurrentMediaTimeUpdates:
4089 """Playback-position anchor semantics of timing-only state updates."""
4090
4091 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
4092 """Build a controller with a single playing player with a known position anchor."""
4093 controller = PlayerController(mock_mass)
4094 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4095 player = MockPlayer(provider, "player_1", "Player 1")
4096 controller._players = {"player_1": player}
4097 mock_mass.players = controller
4098 # no queue registered: current_media resolves from the player's native media
4099 mock_mass.player_queues.get = MagicMock(return_value=None)
4100 player.set_initialized()
4101 now = time.time()
4102 player._attr_playback_state = PlaybackState.PLAYING
4103 player._attr_elapsed_time = 17
4104 player._attr_elapsed_time_last_updated = now
4105 player.set_current_media(uri="http://test/stream", title="Test")
4106 assert player._attr_current_media is not None
4107 player._attr_current_media.elapsed_time = 17
4108 player._attr_current_media.elapsed_time_last_updated = now
4109 player.update_state(signal_event=False)
4110 # isolate from the unrelated state-forwarding machinery
4111 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
4112 mock_mass.signal_event.reset_mock()
4113 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
4114 return controller, player
4115
4116 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
4117 """Return whether a PLAYER_UPDATED event was signalled."""
4118 return any(
4119 call.args and call.args[0] == EventType.PLAYER_UPDATED
4120 for call in mock_mass.signal_event.call_args_list
4121 )
4122
4123 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
4124 """A regular playback tick (position and anchor advance together) emits nothing."""
4125 _controller, player = self._make_player(mock_mass)
4126 assert player._attr_current_media is not None
4127 assert player._attr_elapsed_time_last_updated is not None
4128
4129 player._attr_elapsed_time = 18
4130 player._attr_elapsed_time_last_updated += 1
4131 player._attr_current_media.elapsed_time = 18
4132 assert player._attr_current_media.elapsed_time_last_updated is not None
4133 player._attr_current_media.elapsed_time_last_updated += 1
4134 player.update_state()
4135
4136 assert not self._player_updated_signalled(mock_mass)
4137 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
4138 # the previous anchor was preserved: steady playback changes nothing
4139 assert player.state.elapsed_time == 17
4140
4141 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
4142 """An anchor-only change (no significant corrected position change) emits nothing."""
4143 _controller, player = self._make_player(mock_mass)
4144 assert player._attr_current_media is not None
4145 assert player._attr_elapsed_time_last_updated is not None
4146
4147 player._attr_elapsed_time_last_updated += 0.5
4148 assert player._attr_current_media.elapsed_time_last_updated is not None
4149 player._attr_current_media.elapsed_time_last_updated += 0.5
4150 player.update_state()
4151
4152 assert not self._player_updated_signalled(mock_mass)
4153 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
4154
4155 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
4156 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
4157 _controller, player = self._make_player(mock_mass)
4158 assert player._attr_current_media is not None
4159
4160 player._attr_current_media.elapsed_time = 61
4161 player._attr_current_media.elapsed_time_last_updated = time.time()
4162 player.update_state()
4163
4164 assert self._player_updated_signalled(mock_mass)
4165 # the adopted anchor is visible to consumers
4166 assert player.state.current_media is not None
4167 assert player.state.current_media.elapsed_time == 61
4168
4169 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
4170 """A player-level corrected-position jump re-bases the queue timing."""
4171 controller, player = self._make_player(mock_mass)
4172
4173 player._attr_elapsed_time = 61
4174 player._attr_elapsed_time_last_updated = time.time()
4175 player.update_state()
4176
4177 # the queue is corrected and a follow-up player update is scheduled
4178 # (which re-anchors current_media onto the corrected queue time),
4179 # but no full player update is emitted for the jump itself
4180 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
4181 assert not self._player_updated_signalled(mock_mass)
4182 cast("MagicMock", controller._forward_state_update).assert_called_once()
4183 assert player.state.elapsed_time == 61
4184
4185 def test_simultaneous_player_and_media_jump_emits_immediately(
4186 self, mock_mass: MagicMock
4187 ) -> None:
4188 """A jump reaching player and current_media in one pass corrects the queue and emits."""
4189 _controller, player = self._make_player(mock_mass)
4190 assert player._attr_current_media is not None
4191 now = time.time()
4192
4193 player._attr_elapsed_time = 61
4194 player._attr_elapsed_time_last_updated = now
4195 player._attr_current_media.elapsed_time = 61
4196 player._attr_current_media.elapsed_time_last_updated = now
4197 player.update_state()
4198
4199 # the queue is re-based AND the full update is emitted right away
4200 # (current_media already holds the fresh position in the same pass)
4201 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
4202 assert self._player_updated_signalled(mock_mass)
4203
4204
4205class TestPlayAnnouncementCleanup:
4206 """Test announcement data cleanup after play_announcement."""
4207
4208 def _make_player(
4209 self, mock_mass: MagicMock, announcements: dict[str, object]
4210 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
4211 """Create a controller and a player with native announcement support."""
4212 controller = PlayerController(mock_mass)
4213 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4214 player = MockPlayer(provider, "player_1", "Player 1")
4215 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4216 player._cache.clear()
4217 controller._players = {"player_1": player}
4218 mock_mass.players = controller
4219 render = MagicMock()
4220 render.wait_ready = AsyncMock(return_value=True)
4221 render.wait_finished = AsyncMock(return_value=3.0)
4222
4223 # mimic the real renderer: it owns which announcement each player is playing
4224 def _register(player_id: str, announce_data: object) -> MagicMock:
4225 announcements[player_id] = announce_data
4226 return render
4227
4228 async def _unregister(player_id: str, _render: object) -> None:
4229 announcements.pop(player_id, None)
4230
4231 renderer = mock_mass.streams.announcement_renderer
4232 renderer.register = MagicMock(side_effect=_register)
4233 renderer.unregister = AsyncMock(side_effect=_unregister)
4234 mock_mass.streams.get_announcement_url = MagicMock(
4235 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
4236 )
4237 player.update_state(signal_event=False)
4238 return controller, player, render
4239
4240 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
4241 """The registered announcement data is released once playback finished."""
4242 announcements: dict[str, object] = {}
4243 controller, player, _render = self._make_player(mock_mass, announcements)
4244
4245 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
4246 # entry must exist while the announcement is being played/served
4247 assert "player_1" in announcements
4248
4249 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
4250
4251 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4252
4253 player.play_announcement.assert_awaited_once()
4254 assert announcements == {}
4255 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
4256
4257 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
4258 """The registered announcement data is released even when playback fails."""
4259 announcements: dict[str, object] = {}
4260 controller, player, _render = self._make_player(mock_mass, announcements)
4261 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
4262
4263 with pytest.raises(PlayerCommandFailed):
4264 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4265
4266 assert announcements == {}
4267 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
4268
4269 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
4270 """A native implementation is handed the url as soon as there is audio to serve."""
4271 announcements: dict[str, object] = {}
4272 controller, player, render = self._make_player(mock_mass, announcements)
4273 player.play_announcement = AsyncMock() # type: ignore[method-assign]
4274
4275 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4276
4277 player.play_announcement.assert_awaited_once()
4278 # waiting for the whole clip here would delay the player for a slow source;
4279 # the length is resolved downstream while it plays
4280 render.wait_ready.assert_awaited_once()
4281 render.wait_finished.assert_not_awaited()
4282
4283
4284class _AnnounceSetup(NamedTuple):
4285 """A player announcing through a linked protocol output, with the calls it makes mocked."""
4286
4287 controller: PlayerController
4288 parent: MockPlayer
4289 output: MockPlayer
4290 volume_set: AsyncMock
4291 play_announcement: AsyncMock
4292
4293
4294@pytest.mark.usefixtures("running_background_tasks")
4295class TestNativeAnnouncementVolumeRouting:
4296 """The announcement volume is applied through the control that owns it."""
4297
4298 ANNOUNCE_VOLUME = 45
4299
4300 def _make_setup(self, mock_mass: MagicMock, volume_control: str) -> _AnnounceSetup:
4301 """
4302 Create a player announcing through a linked protocol output.
4303
4304 The parent holds a sibling interface and a bridge riding on the announcing
4305 output, so any of them can be named as its volume control.
4306
4307 :param volume_control: Value of the parent's volume control config entry.
4308 """
4309 controller = PlayerController(mock_mass)
4310 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4311 parent = MockPlayer(provider, "parent", "Parent")
4312 output = MockPlayer(provider, "output", "Output")
4313 output._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4314 output._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4315 sibling = MockPlayer(provider, "sibling", "Sibling")
4316 sibling._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4317 sibling._attr_volume_level = 20
4318 bridge = MockPlayer(provider, "bridge", "Bridge")
4319 bridge._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4320 bridge._attr_underlying_player_id = "output"
4321 bridge._attr_volume_level = 20
4322 controller._players = {
4323 "parent": parent,
4324 "output": output,
4325 "sibling": sibling,
4326 "bridge": bridge,
4327 }
4328 # an external control (e.g. a Home Assistant volume entity) is not a player
4329 controller._controls = {
4330 "ha_volume": PlayerControl(
4331 id="ha_volume",
4332 provider="hass",
4333 name="Amplifier volume",
4334 supports_volume=True,
4335 volume_level=20,
4336 )
4337 }
4338 mock_mass.players = controller
4339 mock_mass.config.get_raw_player_config_value = MagicMock(
4340 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: volume_control})
4341 )
4342 for player in controller._players.values():
4343 player._cache.clear()
4344 player.update_state(signal_event=False)
4345 play_announcement = AsyncMock()
4346 volume_set = AsyncMock()
4347 output.play_announcement = play_announcement # type: ignore[method-assign]
4348 controller._handle_cmd_volume_set = volume_set # type: ignore[method-assign]
4349 return _AnnounceSetup(controller, parent, output, volume_set, play_announcement)
4350
4351 async def _announce(self, setup: _AnnounceSetup) -> None:
4352 """Play an announcement on the parent, rendered by the linked output."""
4353 await setup.controller._play_native_announcement(
4354 setup.parent, setup.output, _announcement(), self.ANNOUNCE_VOLUME
4355 )
4356
4357 async def test_external_control_applies_the_announcement_volume(
4358 self, mock_mass: MagicMock
4359 ) -> None:
4360 """A sibling interface owning the volume gets the announcement volume, not the output."""
4361 setup = self._make_setup(mock_mass, "sibling")
4362
4363 await self._announce(setup)
4364
4365 # the output cannot attenuate what another control is already attenuating,
4366 # so the level goes through that control and the output announces at unity
4367 assert setup.volume_set.await_args_list == [
4368 call("parent", self.ANNOUNCE_VOLUME),
4369 call("parent", 20),
4370 ]
4371 setup.play_announcement.assert_awaited_once_with(ANY, None)
4372
4373 async def test_player_control_applies_the_announcement_volume(
4374 self, mock_mass: MagicMock
4375 ) -> None:
4376 """A player control owning the volume (e.g. an HA entity) gets the announcement volume."""
4377 setup = self._make_setup(mock_mass, "ha_volume")
4378
4379 await self._announce(setup)
4380
4381 assert setup.volume_set.await_args_list == [
4382 call("parent", self.ANNOUNCE_VOLUME),
4383 call("parent", 20),
4384 ]
4385 setup.play_announcement.assert_awaited_once_with(ANY, None)
4386
4387 async def test_external_control_volume_restored_when_the_announcement_fails(
4388 self, mock_mass: MagicMock
4389 ) -> None:
4390 """The temporary volume is restored even when the announcement itself fails."""
4391 setup = self._make_setup(mock_mass, "sibling")
4392 setup.play_announcement.side_effect = RuntimeError("boom")
4393
4394 with pytest.raises(RuntimeError):
4395 await self._announce(setup)
4396
4397 assert setup.volume_set.await_args_list[-1] == call("parent", 20)
4398
4399 async def test_announcing_output_keeps_the_announcement_volume(
4400 self, mock_mass: MagicMock
4401 ) -> None:
4402 """An output that owns the volume applies the announcement volume itself."""
4403 setup = self._make_setup(mock_mass, "output")
4404
4405 await self._announce(setup)
4406
4407 setup.volume_set.assert_not_awaited()
4408 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4409
4410 async def test_bridge_on_the_announcing_output_keeps_the_announcement_volume(
4411 self, mock_mass: MagicMock
4412 ) -> None:
4413 """A bridge riding on the announcing output forwards the volume to it."""
4414 setup = self._make_setup(mock_mass, "bridge")
4415
4416 await self._announce(setup)
4417
4418 setup.volume_set.assert_not_awaited()
4419 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4420
4421 async def test_native_volume_is_applied_through_the_parent(self, mock_mass: MagicMock) -> None:
4422 """
4423 A native volume lives on the parent, so the parent applies and restores it.
4424
4425 The rendering output has no way to reach a native parent volume, and its own
4426 idea of the level can be stale, so routing through the parent keeps both the
4427 announcement level and the restore on the control that actually knows it.
4428 """
4429 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
4430 setup.parent._attr_volume_level = 20
4431 setup.parent._cache.clear()
4432 setup.parent.update_state(signal_event=False)
4433
4434 await self._announce(setup)
4435
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_native_volume_on_its_own_output_is_kept_by_the_player(
4443 self, mock_mass: MagicMock
4444 ) -> None:
4445 """A player announcing on its own output can apply its native volume itself."""
4446 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
4447 play_announcement = AsyncMock()
4448 setup.parent.play_announcement = play_announcement # type: ignore[method-assign]
4449
4450 await setup.controller._play_native_announcement(
4451 setup.parent, setup.parent, _announcement(), self.ANNOUNCE_VOLUME
4452 )
4453
4454 setup.volume_set.assert_not_awaited()
4455 play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4456
4457 async def test_without_volume_control_no_volume_is_applied(self, mock_mass: MagicMock) -> None:
4458 """Nothing in the signal path can set a volume, so the announcement plays as-is."""
4459 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NONE)
4460
4461 await self._announce(setup)
4462
4463 setup.volume_set.assert_not_awaited()
4464 setup.play_announcement.assert_awaited_once_with(ANY, None)
4465
4466
4467class TestPlayAnnouncementMessage:
4468 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
4469
4470 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
4471
4472 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
4473 """Create a TTS engine that renders every message to the given path."""
4474 engine = MagicMock()
4475 engine.uid = "tts_plugin/voice"
4476 engine.id = "voice"
4477 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
4478 return engine
4479
4480 def _make_player(
4481 self, mock_mass: MagicMock, announcements: dict[str, object]
4482 ) -> tuple[PlayerController, AsyncMock]:
4483 """Create a controller and a player with native announcement support."""
4484 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
4485 mock_mass, announcements
4486 )
4487 announce = AsyncMock()
4488 player.play_announcement = announce # type: ignore[method-assign]
4489 return controller, announce
4490
4491 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
4492 """A message is rendered by the default engine and announced as the rendered audio."""
4493 announcements: dict[str, object] = {}
4494 controller, announce = self._make_player(mock_mass, announcements)
4495 engine = self._make_engine()
4496
4497 with patch(
4498 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4499 ):
4500 await controller.play_announcement("player_1", message="dinner is ready")
4501
4502 # no language is sent, so the engine speaks in the language it is configured for
4503 engine.provider.get_tts_message.assert_awaited_once_with(
4504 "dinner is ready", language=None, engine_id="voice", options=None
4505 )
4506 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4507 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4508 announce.assert_awaited_once()
4509
4510 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
4511 """A message can name the language to speak it in."""
4512 announcements: dict[str, object] = {}
4513 controller, _announce = self._make_player(mock_mass, announcements)
4514 engine = self._make_engine()
4515
4516 with patch(
4517 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4518 ):
4519 await controller.play_announcement(
4520 "player_1", message="het eten is klaar", language="nl-NL"
4521 )
4522
4523 engine.provider.get_tts_message.assert_awaited_once_with(
4524 "het eten is klaar", language="nl-NL", engine_id="voice", options=None
4525 )
4526
4527 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
4528 """An engine that rejects the language speaks the message in its default voice."""
4529 announcements: dict[str, object] = {}
4530 controller, announce = self._make_player(mock_mass, announcements)
4531 engine = self._make_engine()
4532 engine.provider.get_tts_message = AsyncMock(
4533 side_effect=[
4534 RuntimeError("unsupported language"),
4535 SimpleNamespace(path="http://speech/spoken.mp3"),
4536 ]
4537 )
4538
4539 with patch(
4540 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4541 ):
4542 await controller.play_announcement(
4543 "player_1", message="dinner is ready", language="en-US"
4544 )
4545
4546 first_call, second_call = engine.provider.get_tts_message.await_args_list
4547 assert first_call.kwargs["language"] == "en-US"
4548 assert second_call.kwargs["language"] is None
4549 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4550 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4551 announce.assert_awaited_once()
4552
4553 @pytest.mark.parametrize(
4554 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
4555 )
4556 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
4557 self, mock_mass: MagicMock, error: Exception
4558 ) -> None:
4559 """A timeout or a structured failure is no language rejection, so it is not retried."""
4560 announcements: dict[str, object] = {}
4561 controller, announce = self._make_player(mock_mass, announcements)
4562 engine = self._make_engine()
4563 engine.provider.get_tts_message = AsyncMock(side_effect=error)
4564
4565 with (
4566 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4567 pytest.raises(MusicAssistantError),
4568 ):
4569 await controller.play_announcement("player_1", message="dinner is ready")
4570
4571 engine.provider.get_tts_message.assert_awaited_once()
4572 announce.assert_not_awaited()
4573
4574 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
4575 """A message names the engine to speak it, overriding the configured default."""
4576 announcements: dict[str, object] = {}
4577 controller, _announce = self._make_player(mock_mass, announcements)
4578 engine = self._make_engine()
4579
4580 with (
4581 patch(
4582 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
4583 ) as resolve,
4584 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
4585 ):
4586 await controller.play_announcement(
4587 "player_1", message="hello", tts_engine="tts_plugin/voice"
4588 )
4589
4590 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
4591 select.assert_not_awaited()
4592
4593 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
4594 """A spoken message uses the player's pre-announce setting without sniffing the url."""
4595 announcements: dict[str, object] = {}
4596 controller, _announce = self._make_player(mock_mass, announcements)
4597 engine = self._make_engine()
4598 mock_mass.config.get_raw_player_config_value = MagicMock(
4599 side_effect=lambda _player_id, key, default=None: (
4600 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
4601 )
4602 )
4603
4604 with patch(
4605 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4606 ):
4607 await controller.play_announcement("player_1", message="dinner is ready")
4608
4609 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4610 assert registered["pre_announce"] is True
4611
4612 async def test_the_engine_gets_the_shorter_announcement_timeout(
4613 self, mock_mass: MagicMock
4614 ) -> None:
4615 """The engine is capped well below the background default, it holds the player lock."""
4616 announcements: dict[str, object] = {}
4617 controller, _announce = self._make_player(mock_mass, announcements)
4618 engine = self._make_engine()
4619 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
4620
4621 with (
4622 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4623 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
4624 ):
4625 await controller.play_announcement("player_1", message="hello")
4626
4627 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
4628 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
4629
4630 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4631 """Naming an engine for a url announcement is rejected instead of silently ignored."""
4632 announcements: dict[str, object] = {}
4633 controller, _announce = self._make_player(mock_mass, announcements)
4634
4635 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
4636 await controller.play_announcement(
4637 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
4638 )
4639
4640 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4641 """Naming a language for a url announcement is rejected instead of silently ignored."""
4642 announcements: dict[str, object] = {}
4643 controller, _announce = self._make_player(mock_mass, announcements)
4644
4645 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
4646 await controller.play_announcement(
4647 "player_1", url="http://test/clip.mp3", language="nl-NL"
4648 )
4649
4650 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
4651 """An engine that fails to speak the message fails the announcement."""
4652 announcements: dict[str, object] = {}
4653 controller, announce = self._make_player(mock_mass, announcements)
4654 engine = self._make_engine()
4655 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
4656
4657 with (
4658 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4659 pytest.raises(PlayerCommandFailed),
4660 ):
4661 await controller.play_announcement("player_1", message="hello")
4662
4663 announce.assert_not_awaited()
4664 mock_mass.streams.announcement_renderer.register.assert_not_called()
4665
4666 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
4667 """An announcement with neither a url nor a message is rejected."""
4668 announcements: dict[str, object] = {}
4669 controller, _announce = self._make_player(mock_mass, announcements)
4670
4671 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
4672 await controller.play_announcement("player_1")
4673
4674 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
4675 """An announcement carrying both a url and a message is rejected."""
4676 announcements: dict[str, object] = {}
4677 controller, _announce = self._make_player(mock_mass, announcements)
4678
4679 with pytest.raises(PlayerCommandFailed, match="not both"):
4680 await controller.play_announcement(
4681 "player_1", url="http://test/clip.mp3", message="hello"
4682 )
4683
4684 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
4685 """A message naming an engine that does not exist fails instead of using another."""
4686 announcements: dict[str, object] = {}
4687 controller, _announce = self._make_player(mock_mass, announcements)
4688
4689 with (
4690 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
4691 pytest.raises(PlayerCommandFailed, match="is not available"),
4692 ):
4693 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
4694
4695 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
4696 """A message fails clearly when no TTS engine is set up at all."""
4697 announcements: dict[str, object] = {}
4698 controller, _announce = self._make_player(mock_mass, announcements)
4699
4700 with (
4701 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
4702 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
4703 ):
4704 await controller.play_announcement("player_1", message="hello")
4705
4706 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
4707 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
4708 announcements: dict[str, object] = {}
4709 controller, _announce = self._make_player(mock_mass, announcements)
4710 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
4711
4712 with (
4713 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4714 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
4715 ):
4716 await controller.play_announcement("player_1", message="hello")
4717
4718 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
4719 """The message is spoken once and every group member announces the resulting audio."""
4720 announcements: dict[str, object] = {}
4721 use_real_create_task(mock_mass)
4722 controller, _announce = self._make_player(mock_mass, announcements)
4723 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4724 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
4725 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4726 group._attr_group_members = ["player_1"]
4727 group._cache.clear()
4728 controller._players["group_1"] = group
4729 group.update_state(signal_event=False)
4730 engine = self._make_engine()
4731
4732 with patch(
4733 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4734 ):
4735 await controller.play_announcement("group_1", message="dinner is ready")
4736
4737 # rendered once for the group, then handed to the member as plain audio
4738 engine.provider.get_tts_message.assert_awaited_once()
4739 member_call = next(
4740 call_args
4741 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
4742 if call_args.args[0] == "player_1"
4743 )
4744 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
4745
4746
4747class TestNativeAnnouncementRouting:
4748 """Announcement routing respects the player's own support and its active output."""
4749
4750 def _make_player_with_linked_child(
4751 self,
4752 mock_mass: MagicMock,
4753 playback_state: PlaybackState,
4754 *,
4755 parent_supports_announce: bool = False,
4756 active_protocol: str | None = None,
4757 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
4758 """Create a controller, a player, its linked protocol child and the two path mocks."""
4759 controller = PlayerController(mock_mass)
4760 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4761 player = MockPlayer(provider, "player_1", "Player 1")
4762 player._attr_playback_state = playback_state
4763 if parent_supports_announce:
4764 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4765 proto_provider = MockProvider("airplay", mass=mock_mass)
4766 proto = MockPlayer(
4767 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
4768 )
4769 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4770 controller._players = {"player_1": player, "proto_1": proto}
4771 mock_mass.players = controller
4772 player.set_linked_output_protocols(
4773 [
4774 LinkedOutputProtocol(
4775 output_protocol_id="proto_1",
4776 protocol_domain="airplay",
4777 priority=40,
4778 )
4779 ]
4780 )
4781 if active_protocol is not None:
4782 player.set_active_output_protocol(active_protocol)
4783 render = MagicMock()
4784 render.wait_ready = AsyncMock(return_value=True)
4785 renderer = mock_mass.streams.announcement_renderer
4786 renderer.register = MagicMock(return_value=render)
4787 renderer.unregister = AsyncMock()
4788 mock_mass.streams.get_announcement_url = MagicMock(
4789 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
4790 )
4791 proto.update_state(signal_event=False)
4792 player.update_state(signal_event=False)
4793 native_path = AsyncMock()
4794 generic_path = AsyncMock()
4795 controller._play_native_announcement = native_path # type: ignore[method-assign]
4796 controller._play_announcement = generic_path # type: ignore[method-assign]
4797 return controller, player, proto, native_path, generic_path
4798
4799 async def test_playing_player_does_not_route_to_an_idle_linked_child(
4800 self, mock_mass: MagicMock
4801 ) -> None:
4802 """
4803 A player rendering through one output must not announce through another.
4804
4805 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
4806 the announcement to the child would seize the device from the native
4807 output, with nothing restoring that playback afterwards.
4808 """
4809 controller, _player, _proto, native_path, generic_path = (
4810 self._make_player_with_linked_child(
4811 mock_mass, PlaybackState.PLAYING, active_protocol="native"
4812 )
4813 )
4814
4815 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4816
4817 native_path.assert_not_awaited()
4818 generic_path.assert_awaited_once()
4819
4820 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
4821 """An idle player announces natively through any capable linked protocol."""
4822 controller, _player, proto, native_path, _generic_path = (
4823 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
4824 )
4825
4826 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4827
4828 native_path.assert_awaited_once()
4829 assert native_path.call_args.args[1] is proto
4830
4831 async def test_active_protocol_child_beats_own_native_support(
4832 self, mock_mass: MagicMock
4833 ) -> None:
4834 """
4835 The output that is actively rendering wins over the player's own support.
4836
4837 E.g. a Sonos playing through its AirPlay child: the announcement rides
4838 the same audio path as the music (mixed into the live stream, in sync
4839 with the rest of a group) instead of a second mechanism firing beside
4840 the playback.
4841 """
4842 controller, _player, proto, native_path, _generic_path = (
4843 self._make_player_with_linked_child(
4844 mock_mass,
4845 PlaybackState.PLAYING,
4846 parent_supports_announce=True,
4847 active_protocol="proto_1",
4848 )
4849 )
4850
4851 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4852
4853 native_path.assert_awaited_once()
4854 assert native_path.call_args.args[1] is proto
4855
4856 async def test_own_native_support_wins_when_playing_natively(
4857 self, mock_mass: MagicMock
4858 ) -> None:
4859 """A player rendering through its own native output announces natively."""
4860 controller, player, _proto, native_path, _generic_path = (
4861 self._make_player_with_linked_child(
4862 mock_mass,
4863 PlaybackState.PLAYING,
4864 parent_supports_announce=True,
4865 active_protocol="native",
4866 )
4867 )
4868
4869 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4870
4871 native_path.assert_awaited_once()
4872 assert native_path.call_args.args[1] is player
4873
4874 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
4875 self, mock_mass: MagicMock
4876 ) -> None:
4877 """Without active playback the player's own announcement support wins."""
4878 controller, player, _proto, native_path, _generic_path = (
4879 self._make_player_with_linked_child(
4880 mock_mass,
4881 PlaybackState.IDLE,
4882 parent_supports_announce=True,
4883 )
4884 )
4885
4886 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4887
4888 native_path.assert_awaited_once()
4889 assert native_path.call_args.args[1] is player
4890
4891
4892@pytest.mark.usefixtures("running_background_tasks")
4893class TestPlayAnnouncementRestore:
4894 """Test the state restore of the default (fallback) announcement implementation."""
4895
4896 def _make_player(
4897 self, mock_mass: MagicMock, prev_media: PlayerMedia
4898 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4899 """Create a controller and a playing player, returning the patched resume handler."""
4900 controller = PlayerController(mock_mass)
4901 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4902 player = MockPlayer(provider, "player_1", "Player 1")
4903 player._attr_playback_state = PlaybackState.PLAYING
4904 player._attr_current_media = prev_media
4905 player._cache.clear()
4906 controller._players = {"player_1": player}
4907 mock_mass.players = controller
4908 mock_mass.player_queues.get = MagicMock(return_value=None)
4909 resume_mock = AsyncMock()
4910 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
4911 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
4912 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
4913 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
4914 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4915 player.set_initialized()
4916 player.update_state(signal_event=False)
4917 return controller, player, resume_mock
4918
4919 @staticmethod
4920 def _add_group(
4921 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
4922 ) -> MockPlayer:
4923 """Register a powered group player that holds the given player as its member."""
4924 group = MockPlayer(
4925 cast("MockProvider", player.provider),
4926 "group_1",
4927 "Group 1",
4928 player_type=PlayerType.GROUP,
4929 )
4930 group._attr_powered = True
4931 group._attr_group_members = [player.player_id]
4932 if supports_set_members:
4933 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4934 controller._players[group.player_id] = group
4935 group.set_initialized()
4936 group._cache.clear()
4937 group.update_state(signal_event=False)
4938 # the member has no own input change to trigger a recalculation of its
4939 # (group derived) state, so force it here - just like register() does
4940 player._cache.clear()
4941 player.update_state(force_update=True, signal_event=False)
4942 assert player.state.active_group == group.player_id
4943 return group
4944
4945 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
4946 """Content that was playing before the announcement is resumed afterwards."""
4947 controller, player, resume_mock = self._make_player(
4948 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4949 )
4950
4951 await controller._play_announcement(player, _announcement())
4952
4953 resume_mock.assert_awaited_once()
4954
4955 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
4956 """A player still busy with an earlier announcement has no playback to restore."""
4957 controller, player, resume_mock = self._make_player(
4958 mock_mass,
4959 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
4960 )
4961
4962 await controller._play_announcement(player, _announcement())
4963
4964 resume_mock.assert_not_awaited()
4965
4966 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4967 """A failing announcement never leaves the player at the raised volume."""
4968 controller, player, _ = self._make_player(
4969 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4970 )
4971 player._attr_volume_level = 20
4972 player._cache.clear()
4973 player.update_state(force_update=True, signal_event=False)
4974 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4975 volume_mock = AsyncMock()
4976 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4977 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4978 side_effect=PlayerCommandFailed("player went away")
4979 )
4980
4981 with pytest.raises(PlayerCommandFailed):
4982 await controller._play_announcement(player, _announcement())
4983
4984 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
4985
4986 async def test_zero_announcement_volume_is_applied_and_restored(
4987 self, mock_mass: MagicMock
4988 ) -> None:
4989 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
4990 controller, player, _ = self._make_player(
4991 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4992 )
4993 player._attr_volume_level = 20
4994 player._cache.clear()
4995 player.update_state(force_update=True, signal_event=False)
4996 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
4997 volume_mock = AsyncMock()
4998 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4999
5000 await controller._play_announcement(player, _announcement())
5001
5002 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
5003
5004 async def test_playback_is_restored_when_duration_is_unknown(
5005 self, mock_mass: MagicMock
5006 ) -> None:
5007 """An announcement of unknown length still hands the player back to its content."""
5008 controller, player, resume_mock = self._make_player(
5009 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5010 )
5011 announcement = _announcement()
5012 announcement.duration = None
5013
5014 # an unknown length waits for the player to report it finished instead of failing
5015 await controller._play_announcement(player, announcement)
5016
5017 resume_mock.assert_awaited_once()
5018
5019 async def test_group_membership_is_restored_when_playback_fails(
5020 self, mock_mass: MagicMock
5021 ) -> None:
5022 """A failing announcement never leaves the player out of its group player."""
5023 controller, player, _ = self._make_player(
5024 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5025 )
5026 group = self._add_group(controller, player, supports_set_members=True)
5027 group.set_members = AsyncMock() # type: ignore[method-assign]
5028 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5029 side_effect=PlayerCommandFailed("player went away")
5030 )
5031
5032 with pytest.raises(PlayerCommandFailed):
5033 await controller._play_announcement(player, _announcement())
5034
5035 assert group.set_members.await_args_list == [
5036 call(player_ids_to_remove=["player_1"]),
5037 call(player_ids_to_add=["player_1"]),
5038 ]
5039
5040 async def test_restore_failure_does_not_mask_the_announcement_error(
5041 self, mock_mass: MagicMock
5042 ) -> None:
5043 """A provider blowing up during the restore must not hide why the announcement failed."""
5044 controller, player, _ = self._make_player(
5045 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5046 )
5047 group = self._add_group(controller, player, supports_set_members=True)
5048 # set_members is a raw provider call: whatever its client library raises comes
5049 # through unwrapped, so the ungroup succeeds and the regroup times out
5050 group.set_members = AsyncMock( # type: ignore[method-assign]
5051 side_effect=[None, TimeoutError("provider timeout")]
5052 )
5053 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5054 side_effect=PlayerCommandFailed("player went away")
5055 )
5056
5057 with pytest.raises(PlayerCommandFailed, match="player went away"):
5058 await controller._play_announcement(player, _announcement())
5059
5060 async def test_group_without_set_members_is_the_one_powered_off(
5061 self, mock_mass: MagicMock
5062 ) -> None:
5063 """A group that can not release members is powered off, not the announcement target."""
5064 controller, player, _ = self._make_player(
5065 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5066 )
5067 self._add_group(controller, player, supports_set_members=False)
5068 power_mock = AsyncMock()
5069 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
5070 play_mock = AsyncMock()
5071 controller.cmd_play = play_mock # type: ignore[method-assign]
5072
5073 await controller._play_announcement(player, _announcement())
5074
5075 # the group is switched off for the announcement and restarted afterwards
5076 power_mock.assert_awaited_once_with("group_1", False)
5077 play_mock.assert_awaited_once_with("group_1")
5078
5079 async def test_idle_player_without_power_control_is_regrouped(
5080 self, mock_mass: MagicMock
5081 ) -> None:
5082 """An idle player that has no power state to restore is still put back in its group."""
5083 controller, player, _ = self._make_player(
5084 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5085 )
5086 player._attr_playback_state = PlaybackState.IDLE
5087 player._attr_powered = None
5088 group = self._add_group(controller, player, supports_set_members=True)
5089 assert player.state.power_control == PLAYER_CONTROL_NONE
5090 group.set_members = AsyncMock() # type: ignore[method-assign]
5091
5092 await controller._play_announcement(player, _announcement())
5093
5094 assert group.set_members.await_args_list == [
5095 call(player_ids_to_remove=["player_1"]),
5096 call(player_ids_to_add=["player_1"]),
5097 ]
5098
5099 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
5100 """A muted player hears the announcement and is muted again afterwards."""
5101 controller, player, _ = self._make_player(
5102 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5103 )
5104 mute_mock = _mute_natively(player)
5105
5106 await controller._play_announcement(player, _announcement())
5107
5108 assert mute_mock.await_args_list == [call(False), call(True)]
5109
5110 async def test_player_without_volume_control_is_still_unmuted(
5111 self, mock_mass: MagicMock
5112 ) -> None:
5113 """A player that can only be muted is unmuted for the announcement all the same."""
5114 mock_mass.config.get_raw_player_config_value = MagicMock(
5115 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
5116 )
5117 controller, player, _ = self._make_player(
5118 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5119 )
5120 mute_mock = _mute_natively(player)
5121 assert player.state.volume_control == PLAYER_CONTROL_NONE
5122
5123 await controller._play_announcement(player, _announcement())
5124
5125 assert mute_mock.await_args_list == [call(False), call(True)]
5126
5127 async def test_mute_is_restored_before_the_player_is_regrouped(
5128 self, mock_mass: MagicMock
5129 ) -> None:
5130 """A player is handed back to its group already muted, holding on to its mute lock."""
5131 controller, player, _ = self._make_player(
5132 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5133 )
5134 group = self._add_group(controller, player, supports_set_members=True)
5135 real_set_members = group.set_members
5136
5137 async def _set_members(**kwargs: list[str]) -> None:
5138 # let the membership really change, so the player is ungrouped while the
5139 # announcement plays - just like it is in production. neither player picks
5140 # the new membership up on its own here, so publish it on both.
5141 await real_set_members(**kwargs)
5142 group.update_state(force_update=True, signal_event=False)
5143 player._cache.clear()
5144 player.update_state(force_update=True, signal_event=False)
5145
5146 set_members = AsyncMock(side_effect=_set_members)
5147 group.set_members = set_members # type: ignore[method-assign]
5148 player.extra_data[ATTR_MUTE_LOCK] = True
5149 recorder = MagicMock()
5150 recorder.attach_mock(_mute_natively(player), "mute")
5151 recorder.attach_mock(set_members, "set_members")
5152
5153 await controller._play_announcement(player, _announcement())
5154
5155 assert recorder.mock_calls == [
5156 call.set_members(player_ids_to_remove=["player_1"]),
5157 call.mute(False),
5158 call.mute(True),
5159 call.set_members(player_ids_to_add=["player_1"]),
5160 ]
5161 # the lock survives the announcement, so the regroup does not unmute the player
5162 assert player.extra_data[ATTR_MUTE_LOCK] is True
5163
5164 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
5165 """A player that was not muted is never sent a mute command."""
5166 controller, player, _ = self._make_player(
5167 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5168 )
5169 mute_mock = _mute_natively(player)
5170 player._attr_volume_muted = False
5171 player._cache.clear()
5172 player.update_state(force_update=True, signal_event=False)
5173 mute_mock.reset_mock()
5174
5175 await controller._play_announcement(player, _announcement())
5176
5177 mute_mock.assert_not_awaited()
5178
5179 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
5180 """A failing announcement never leaves the player unmuted."""
5181 controller, player, _ = self._make_player(
5182 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5183 )
5184 mute_mock = _mute_natively(player)
5185 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
5186 side_effect=PlayerCommandFailed("player went away")
5187 )
5188
5189 with pytest.raises(PlayerCommandFailed):
5190 await controller._play_announcement(player, _announcement())
5191
5192 assert mute_mock.await_args_list == [call(False), call(True)]
5193
5194 async def test_muted_sync_group_members_all_hear_the_announcement(
5195 self, mock_mass: MagicMock
5196 ) -> None:
5197 """Every member of a muted sync group is unmuted, keeping its mute lock."""
5198 controller, leader, _ = self._make_player(
5199 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5200 )
5201 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
5202 controller._players["player_2"] = member
5203 member.set_initialized()
5204 leader._attr_group_members = ["player_1", "player_2"]
5205 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
5206 # both members were muted while grouped, so both hold a mute lock
5207 for player in (leader, member):
5208 player.extra_data[ATTR_MUTE_LOCK] = True
5209
5210 await controller._play_announcement(leader, _announcement())
5211
5212 for player_id, mute_mock in mute_mocks.items():
5213 assert mute_mock.await_args_list == [call(False), call(True)], player_id
5214 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
5215
5216 async def test_fake_muted_player_announces_at_its_real_volume(
5217 self, mock_mass: MagicMock
5218 ) -> None:
5219 """A fake muted player announces at its real volume, not at the zero it is parked on."""
5220 mock_mass.config.get_raw_player_config_value = MagicMock(
5221 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
5222 )
5223 controller, player, _ = self._make_player(
5224 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
5225 )
5226
5227 def _apply_volume(volume: int) -> None:
5228 player._attr_volume_level = volume
5229 player.update_state(signal_event=False)
5230
5231 player._attr_volume_level = 40
5232 volume_set = AsyncMock(side_effect=_apply_volume)
5233 player.volume_set = volume_set # type: ignore[method-assign]
5234 player._cache.clear()
5235 player.update_state(force_update=True, signal_event=False)
5236 await controller.cmd_volume_mute("player_1", True)
5237 assert player.state.volume_muted is True
5238 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
5239
5240 await controller._play_announcement(player, _announcement())
5241
5242 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
5243 assert volume_set.await_args_list == [
5244 call(0),
5245 call(40),
5246 call(80),
5247 call(40),
5248 call(0),
5249 ]
5250 assert player.state.volume_muted is True
5251 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
5252
5253
5254@pytest.mark.usefixtures("running_background_tasks")
5255class TestPlayNativeAnnouncement:
5256 """Test the mute handling around an announcement that a player plays natively."""
5257
5258 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
5259 """Create a controller and a player with native announcement support."""
5260 controller = PlayerController(mock_mass)
5261 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
5262 player = MockPlayer(provider, "player_1", "Player 1")
5263 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
5264 player._cache.clear()
5265 controller._players = {"player_1": player}
5266 mock_mass.players = controller
5267 mock_mass.player_queues.get = MagicMock(return_value=None)
5268 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
5269 announce_mock = AsyncMock()
5270 player.play_announcement = announce_mock # type: ignore[method-assign]
5271 player.set_initialized()
5272 player.update_state(signal_event=False)
5273 return controller, player, announce_mock
5274
5275 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
5276 """A muted player hears the announcement and is muted again afterwards."""
5277 controller, player, announce_mock = self._make_player(mock_mass)
5278 recorder = MagicMock()
5279 recorder.attach_mock(_mute_natively(player), "mute")
5280 recorder.attach_mock(announce_mock, "announce")
5281
5282 await controller._play_native_announcement(player, player, _announcement(), None)
5283
5284 assert recorder.mock_calls == [
5285 call.mute(False),
5286 call.announce(ANY, None),
5287 call.mute(True),
5288 ]
5289
5290 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
5291 """A player that was not muted is never sent a mute command."""
5292 controller, player, _ = self._make_player(mock_mass)
5293 mute_mock = _mute_natively(player)
5294 player._attr_volume_muted = False
5295 player._cache.clear()
5296 player.update_state(force_update=True, signal_event=False)
5297 mute_mock.reset_mock()
5298
5299 await controller._play_native_announcement(player, player, _announcement(), None)
5300
5301 mute_mock.assert_not_awaited()
5302
5303 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
5304 """A failing announcement never leaves the player unmuted."""
5305 controller, player, announce_mock = self._make_player(mock_mass)
5306 mute_mock = _mute_natively(player)
5307 announce_mock.side_effect = PlayerCommandFailed("player went away")
5308
5309 with pytest.raises(PlayerCommandFailed):
5310 await controller._play_native_announcement(player, player, _announcement(), None)
5311
5312 assert mute_mock.await_args_list == [call(False), call(True)]
5313
5314 async def test_muted_sync_group_members_all_hear_the_announcement(
5315 self, mock_mass: MagicMock
5316 ) -> None:
5317 """Every member of a muted sync group is unmuted, keeping its mute lock."""
5318 controller, leader, _ = self._make_player(mock_mass)
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_native_announcement(leader, leader, _announcement(), None)
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 """The announcement volume is resolved after the unmute, not from the parked zero."""
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, announce_mock = self._make_player(mock_mass)
5342
5343 def _apply_volume(volume: int) -> None:
5344 player._attr_volume_level = volume
5345 player.update_state(signal_event=False)
5346
5347 player._attr_volume_level = 40
5348 volume_set = AsyncMock(side_effect=_apply_volume)
5349 player.volume_set = volume_set # type: ignore[method-assign]
5350 player._cache.clear()
5351 player.update_state(force_update=True, signal_event=False)
5352 await controller.cmd_volume_mute("player_1", True)
5353 assert player.state.volume_muted is True
5354 # stand in for the configured strategy, which reads the volume of the player
5355 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
5356 side_effect=lambda _player_id, _volume_level: player.state.volume_level
5357 )
5358
5359 await controller._play_native_announcement(player, player, _announcement(), None)
5360
5361 assert announce_mock.await_args == call(ANY, 40)
5362 # unmute to 40 for the announcement, park back on 0 for the fake mute
5363 assert volume_set.await_args_list == [call(0), call(40), call(0)]
5364 assert player.state.volume_muted is True
5365 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
5366
5367
5368class TestScheduleActiveOutputProtocolClear:
5369 """Test the deferred clear of a player's active output protocol."""
5370
5371 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
5372 """Scheduling defers the clear to a single, per-player, cancellable task."""
5373 controller = PlayerController(mock_mass)
5374 player = MagicMock()
5375 player.player_id = "player_1"
5376
5377 controller.schedule_active_output_protocol_clear(player)
5378
5379 mock_mass.create_task.assert_called_once()
5380 # close the coroutine passed to the mocked create_task to avoid a
5381 # "coroutine was never awaited" warning
5382 mock_mass.create_task.call_args.args[0].close()
5383 # no abort_existing: a duplicate schedule must reuse the pending clear
5384 # (deduped by task_id) instead of replacing it with an untracked task
5385 assert mock_mass.create_task.call_args.kwargs == {
5386 "task_id": "clear_active_protocol_player_1",
5387 }
5388
5389 @pytest.mark.asyncio
5390 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
5391 """The protocol is cleared after waiting for the player to reach IDLE."""
5392 controller = PlayerController(mock_mass)
5393 player = MagicMock()
5394 player.player_id = "player_1"
5395
5396 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
5397 await controller._clear_active_output_protocol_when_idle(player)
5398
5399 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
5400 player.set_active_output_protocol.assert_called_once_with(None)
5401
5402
5403@contextlib.asynccontextmanager
5404async def _skip_player_update_wait(
5405 *_args: object,
5406 **_kwargs: object,
5407) -> AsyncIterator[None]:
5408 """Skip provider-driven state propagation in command-routing tests."""
5409 yield
5410
5411
5412class TestRemovePlayerControl:
5413 """Test removing a registered player control."""
5414
5415 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
5416 """Test that only the players configured to use the removed control are refreshed."""
5417 mock_mass.loop = MagicMock()
5418 controller = PlayerController(mock_mass)
5419 using_control = MagicMock()
5420 using_control.state.power_control = "switch.amp"
5421 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
5422 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
5423 unrelated = MagicMock()
5424 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
5425 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
5426 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
5427 controller._players = {"using_control": using_control, "unrelated": unrelated}
5428 controller._controls = {
5429 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
5430 }
5431
5432 controller.remove_player_control("switch.amp")
5433
5434 assert controller.player_controls() == []
5435 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
5436
5437 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
5438 """Test that a control removed and registered again re-attaches to its player."""
5439 # run the scheduled refresh straight away so each step is observable
5440 mock_mass.loop = MagicMock()
5441 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
5442 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
5443 {CONF_POWER_CONTROL: "switch.amp"}
5444 )
5445 controller = PlayerController(mock_mass)
5446 mock_mass.players = controller
5447 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5448 mock_mass.get_provider.return_value = provider
5449 player = MockPlayer(provider, "player", "Player")
5450 controller._players = {"player": player}
5451 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
5452
5453 await controller.register_or_update_player_control(control)
5454 assert player.state.power_control == "switch.amp"
5455
5456 # the Home Assistant plugin drops and re-registers its controls around a reload
5457 controller.remove_player_control(control.id)
5458 assert player.state.power_control == PLAYER_CONTROL_NONE
5459
5460 await controller.register_or_update_player_control(control)
5461 assert player.state.power_control == "switch.amp"
5462
5463 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
5464 """Test that removing a control that was never registered is a no-op."""
5465 mock_mass.loop = MagicMock()
5466 controller = PlayerController(mock_mass)
5467 player = MagicMock()
5468 player.state.power_control = PLAYER_CONTROL_NATIVE
5469 player.state.volume_control = PLAYER_CONTROL_NATIVE
5470 player.state.mute_control = PLAYER_CONTROL_NATIVE
5471 controller._players = {"player": player}
5472
5473 controller.remove_player_control("switch.gone")
5474
5475 mock_mass.loop.call_soon.assert_not_called()
5476
5477
5478class _FailingTeardownPlayer(MockPlayer):
5479 """Player whose provider fails to release it."""
5480
5481 unloaded = False
5482
5483 async def on_unload(self) -> None:
5484 """Handle logic when the player is unloaded from the Player controller."""
5485 self.unloaded = True
5486 msg = "device is gone"
5487 raise RuntimeError(msg)
5488
5489
5490class TestUnregisterTeardown:
5491 """Test that a failing player teardown stays contained."""
5492
5493 async def test_failing_on_unload_still_unregisters_the_player(
5494 self, mock_mass: MagicMock
5495 ) -> None:
5496 """Test that a provider raising while releasing its player does not break unregister."""
5497 controller = PlayerController(mock_mass)
5498 mock_mass.players = controller
5499 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5500 player = _FailingTeardownPlayer(provider, "boom", "Boom")
5501 controller._players = {"boom": player}
5502
5503 await controller.unregister("boom")
5504
5505 assert "boom" not in controller._players
5506 assert player.unloaded
5507
5508
5509class TestDeletePlayerConfigUserFilters:
5510 """Test how a deleted player config is reflected in the user access filters."""
5511
5512 def test_removal_drops_the_player_from_the_filters(self, mock_mass: MagicMock) -> None:
5513 """A removed player is dropped from the access filter of every user."""
5514 controller = PlayerController(mock_mass)
5515 mock_mass.players = controller
5516
5517 controller.delete_player_config("sonos_1")
5518
5519 mock_mass.webserver.auth.remove_from_user_filters.assert_called_once_with(
5520 player_ids=["sonos_1"]
5521 )
5522 mock_mass.webserver.auth.replace_player_in_user_filters.assert_not_called()
5523
5524 def test_replacement_hands_the_filters_to_the_new_player(self, mock_mass: MagicMock) -> None:
5525 """A replaced player hands its access filter entries over to its replacement."""
5526 controller = PlayerController(mock_mass)
5527 mock_mass.players = controller
5528
5529 controller.delete_player_config("up_old", replacement_player_id="sonos_1")
5530
5531 mock_mass.webserver.auth.replace_player_in_user_filters.assert_called_once_with(
5532 "up_old", "sonos_1", removed_player_ids=["up_old"]
5533 )
5534 mock_mass.webserver.auth.remove_from_user_filters.assert_not_called()
5535
5536
5537class TestDeletePlayerConfigGroupMemberships:
5538 """Test how a deleted player config is reflected in the stored group member lists."""
5539
5540 @staticmethod
5541 def _config_store(mock_mass: MagicMock) -> dict[str, Any]:
5542 """Back the mocked config with a store holding a group that lists sonos_1."""
5543 config_store: dict[str, Any] = {
5544 "players": {
5545 "group_1": {
5546 "values": {
5547 "group_members": ["sonos_1", "sonos_2"],
5548 "allowed_members": ["sonos_1"],
5549 }
5550 },
5551 },
5552 }
5553 mock_mass.config.get = MagicMock(
5554 side_effect=lambda key, default=None: config_store.get(key, default)
5555 )
5556 mock_mass.config.set = MagicMock(
5557 side_effect=lambda key, value: config_store.__setitem__(key, value)
5558 )
5559 return config_store
5560
5561 def test_removal_drops_the_player_from_the_member_lists(self, mock_mass: MagicMock) -> None:
5562 """A removed player is dropped from the member lists of every group."""
5563 controller = PlayerController(mock_mass)
5564 mock_mass.players = controller
5565 config_store = self._config_store(mock_mass)
5566
5567 controller.delete_player_config("sonos_1")
5568
5569 assert config_store["players/group_1/values/group_members"] == ["sonos_2"]
5570 # the allow-list is left alone: emptying it would stop it restricting anything
5571 assert "players/group_1/values/allowed_members" not in config_store
5572
5573 def test_replacement_hands_the_membership_to_the_new_player(self, mock_mass: MagicMock) -> None:
5574 """A replaced player hands its group memberships over to its replacement."""
5575 controller = PlayerController(mock_mass)
5576 mock_mass.players = controller
5577 config_store = self._config_store(mock_mass)
5578
5579 controller.delete_player_config("sonos_1", replacement_player_id="sonos_3")
5580
5581 assert config_store["players/group_1/values/group_members"] == ["sonos_3", "sonos_2"]
5582 assert config_store["players/group_1/values/allowed_members"] == ["sonos_3"]
5583
5584 async def test_permanent_unregister_drops_the_membership(self, mock_mass: MagicMock) -> None:
5585 """A permanently removed player does not linger in a group's stored member list."""
5586 controller = PlayerController(mock_mass)
5587 mock_mass.players = controller
5588 config_store = self._config_store(mock_mass)
5589 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5590 controller._players = {"sonos_1": MockPlayer(provider, "sonos_1", "Sonos 1")}
5591
5592 await controller.unregister("sonos_1", permanent=True)
5593
5594 assert config_store["players/group_1/values/group_members"] == ["sonos_2"]
5595
5596 async def test_temporary_unregister_keeps_the_membership(self, mock_mass: MagicMock) -> None:
5597 """A player that is only temporarily gone keeps its spot in the group."""
5598 controller = PlayerController(mock_mass)
5599 mock_mass.players = controller
5600 config_store = self._config_store(mock_mass)
5601 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5602 controller._players = {"sonos_1": MockPlayer(provider, "sonos_1", "Sonos 1")}
5603
5604 await controller.unregister("sonos_1")
5605
5606 assert "players/group_1/values/group_members" not in config_store
5607
5608 async def test_a_registered_group_re_reads_its_members(self, mock_mass: MagicMock) -> None:
5609 """A registered group is told to re-read its members so its live list follows."""
5610 controller = PlayerController(mock_mass)
5611 mock_mass.players = controller
5612 self._config_store(mock_mass)
5613 scheduled: list[Any] = []
5614 mock_mass.create_task = MagicMock(side_effect=lambda task: scheduled.append(task))
5615 group = MockPlayer(MockProvider("sync_group", mass=mock_mass), "group_1", "Group")
5616 entry = ConfigEntry(key="group_members", type=ConfigEntryType.STRING, multi_value=True)
5617 entry.value = ["sonos_1", "sonos_2"]
5618 group.config.values = {"group_members": entry}
5619 controller._players = {"group_1": group}
5620
5621 with (
5622 patch.object(group, "on_config_updated", AsyncMock()) as mock_reload,
5623 patch.object(group, "refresh_state") as mock_refresh,
5624 ):
5625 controller.delete_player_config("sonos_1")
5626 for task in [t for t in scheduled if "_reload_group_members" in repr(t)]:
5627 await task
5628
5629 # the in-place config copy is pruned before the group re-reads it
5630 assert group.config.values["group_members"].value == ["sonos_2"]
5631 mock_reload.assert_awaited_once()
5632 mock_refresh.assert_called_once()
5633
5634
5635class TestConfigChangeRestartsPlayback:
5636 """Test that a changed player setting which needs a reload restarts playback."""
5637
5638 @staticmethod
5639 def _config(*, requires_reload: bool) -> PlayerConfig:
5640 """Build a PlayerConfig holding a single output codec entry."""
5641 return PlayerConfig(
5642 provider="test_prov",
5643 player_id="player_1",
5644 values={
5645 CONF_OUTPUT_CODEC: ConfigEntry(
5646 key=CONF_OUTPUT_CODEC,
5647 type=ConfigEntryType.STRING,
5648 label="Output codec",
5649 value="flac",
5650 requires_reload=requires_reload,
5651 )
5652 },
5653 )
5654
5655 @staticmethod
5656 def _prepare(mock_mass: MagicMock, queue_state: PlaybackState) -> PlayerController:
5657 """Register a player whose active queue is in the given state."""
5658 controller = PlayerController(mock_mass)
5659 player = MagicMock()
5660 player.state.active_source = "player_1"
5661 player.on_config_updated = AsyncMock()
5662 controller._players = {"player_1": player}
5663 queue = MagicMock()
5664 queue.queue_id = "player_1"
5665 queue.state = queue_state
5666 mock_mass.player_queues.get = MagicMock(return_value=queue)
5667 mock_mass.player_queues.stop = AsyncMock()
5668 return controller
5669
5670 async def test_reload_setting_restarts_playback(self, mock_mass: MagicMock) -> None:
5671 """Test that changing a reload-requiring setting stops and resumes the queue."""
5672 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5673
5674 await controller.on_player_config_change(
5675 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5676 )
5677
5678 mock_mass.player_queues.stop.assert_awaited_once_with("player_1")
5679 mock_mass.call_later.assert_called_once_with(
5680 1, mock_mass.player_queues.resume, "player_1", False
5681 )
5682
5683 async def test_plain_setting_does_not_restart_playback(self, mock_mass: MagicMock) -> None:
5684 """Test that a setting which applies on the fly leaves playback alone."""
5685 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5686
5687 await controller.on_player_config_change(
5688 self._config(requires_reload=False), {f"values/{CONF_OUTPUT_CODEC}"}
5689 )
5690
5691 mock_mass.player_queues.stop.assert_not_awaited()
5692 mock_mass.call_later.assert_not_called()
5693
5694 async def test_untouched_reload_setting_does_not_restart_playback(
5695 self, mock_mass: MagicMock
5696 ) -> None:
5697 """Test that only a changed reload-requiring setting restarts playback."""
5698 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5699
5700 await controller.on_player_config_change(
5701 self._config(requires_reload=True), {f"values/{CONF_ICON}"}
5702 )
5703
5704 mock_mass.player_queues.stop.assert_not_awaited()
5705 mock_mass.call_later.assert_not_called()
5706
5707 async def test_idle_queue_is_left_alone(self, mock_mass: MagicMock) -> None:
5708 """Test that a reload-requiring change does not start playback on an idle queue."""
5709 controller = self._prepare(mock_mass, PlaybackState.IDLE)
5710
5711 await controller.on_player_config_change(
5712 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5713 )
5714
5715 mock_mass.player_queues.stop.assert_not_awaited()
5716 mock_mass.call_later.assert_not_called()
5717
5718
5719if __name__ == "__main__":
5720 pytest.main([__file__, "-v"])
5721