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