/
/
/
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, 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
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 OutputProtocol, 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_MAX_VOLUME,
54 CONF_MIN_VOLUME,
55 CONF_MUTE_CONTROL,
56 CONF_POWER_CONTROL,
57 CONF_VOLUME_CONTROL,
58 CONF_VOLUME_STEP,
59)
60from music_assistant.controllers.players import PlayerController
61from music_assistant.controllers.players.announcements import ANNOUNCEMENT_TTS_TIMEOUT
62from music_assistant.controllers.webserver.helpers.auth_middleware import current_user
63from music_assistant.helpers.tts import TTS_QUERY_TIMEOUT_SECONDS
64from music_assistant.models.player_provider import PlayerProvider
65from tests.common import MockPlayer, MockProvider, create_mock_config, use_real_create_task
66
67
68def _player_config_stub(
69 values: dict[str, object] | None = None,
70 *,
71 min_volume: int = 0,
72 max_volume: int = 100,
73) -> Callable[..., object]:
74 """
75 Build a ``get_raw_player_config_value`` side effect.
76
77 :param values: Extra config keys to answer, e.g. ``{CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE}``.
78 :param min_volume: Value returned for the ``min_volume`` key.
79 :param max_volume: Value returned for the ``max_volume`` key.
80 """
81 config: dict[str, object] = {
82 CONF_MIN_VOLUME: min_volume,
83 CONF_MAX_VOLUME: max_volume,
84 **(values or {}),
85 }
86
87 def _conf(_player_id: str, key: str, default: object = None) -> object:
88 if key in config:
89 return config[key]
90 return default
91
92 return _conf
93
94
95def _announcement() -> PlayerMedia:
96 """Return the announcement to play."""
97 return PlayerMedia(
98 uri="http://ma/announcement/player_1.mp3",
99 media_type=MediaType.ANNOUNCEMENT,
100 title="Announcement",
101 duration=3,
102 )
103
104
105def _volume_step_config(step: int | None) -> CoreConfig:
106 """
107 Build a "players" CoreConfig carrying the given ``volume_step`` value.
108
109 Pass ``None`` to leave the entry unresolved and genuinely exercise the default
110 (``CoreConfig.get_value`` returns the entry's ``value`` verbatim, not its
111 ``default_value``, so a real "not configured" state is ``value=None``).
112 """
113 return CoreConfig(
114 domain="players",
115 values={
116 CONF_VOLUME_STEP: ConfigEntry(
117 key=CONF_VOLUME_STEP,
118 type=ConfigEntryType.INTEGER,
119 default_value=0,
120 value=step,
121 )
122 },
123 )
124
125
126@pytest.fixture
127def running_background_tasks(mock_mass: MagicMock) -> Iterator[None]:
128 """
129 Really run the tasks that ``mass.create_task`` is handed, and raise what they raise.
130
131 The real implementation only logs the exception of a background task, which would
132 leave a test that dispatches its work through the TaskManager passing regardless.
133 """
134 tasks: list[asyncio.Task[Any]] = []
135 use_real_create_task(mock_mass)
136 real_create_task = mock_mass.create_task
137
138 def _create_task(target: Any, *args: Any, **kwargs: Any) -> Any:
139 task = real_create_task(target, *args, **kwargs)
140 if isinstance(task, asyncio.Task):
141 tasks.append(task)
142 return task
143
144 mock_mass.create_task = MagicMock(side_effect=_create_task)
145 yield
146 errors = [
147 err
148 for task in tasks
149 if task.done() and not task.cancelled() and (err := task.exception()) is not None
150 ]
151 if len(errors) == 1:
152 raise errors[0]
153 if errors:
154 raise BaseExceptionGroup("background tasks failed", errors)
155
156
157def _mute_natively(player: MockPlayer) -> AsyncMock:
158 """Give the player a native mute control, mute it and return its mute handler."""
159 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
160 player._attr_volume_muted = True
161 mute_mock = AsyncMock(side_effect=lambda muted: setattr(player, "_attr_volume_muted", muted))
162 player.volume_mute = mute_mock # type: ignore[method-assign]
163 player._cache.clear()
164 player.update_state(force_update=True, signal_event=False)
165 assert player.state.volume_muted is True
166 return mute_mock
167
168
169@pytest.fixture
170def mock_mass() -> MagicMock:
171 """Create a mock MusicAssistant instance."""
172 mass = MagicMock()
173 mass.closing = False
174 mass.loop = None
175 mass.config = MagicMock()
176 mass.config.get = MagicMock(return_value=[])
177 mass.config.get_raw_player_config_value = MagicMock(side_effect=_player_config_stub())
178 # Return "GLOBAL" for log level config (standard default)
179 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
180 mass.config.set = MagicMock()
181 mass.signal_event = MagicMock()
182 mass.get_providers = MagicMock(return_value=[])
183 return mass
184
185
186@pytest.fixture
187def controller(mock_mass: MagicMock) -> PlayerController:
188 """Create a PlayerController instance."""
189 return PlayerController(mock_mass)
190
191
192class TestSetMembersValidation:
193 """Test cmd_set_members validation logic."""
194
195 def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
196 """Test that set_members requires SET_MEMBERS feature."""
197 controller = PlayerController(mock_mass)
198 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
199
200 leader = MockPlayer(provider, "leader", "Leader")
201 # Note: NOT adding SET_MEMBERS feature
202
203 member = MockPlayer(provider, "member", "Member")
204
205 controller._players = {"leader": leader, "member": member}
206 mock_mass.players = controller
207
208 # Should raise exception because leader doesn't support SET_MEMBERS
209 with pytest.raises(UnsupportedFeaturedException):
210 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
211
212 def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
213 """Test that incompatible players cannot be grouped."""
214 controller = PlayerController(mock_mass)
215 provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
216 provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
217
218 player_a = MockPlayer(provider_a, "player_a", "Player A")
219 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
220 player_a._attr_can_group_with = {"provider_a"} # Only same provider
221
222 player_b = MockPlayer(provider_b, "player_b", "Player B")
223
224 controller._players = {"player_a": player_a, "player_b": player_b}
225 mock_mass.players = controller
226
227 # Should raise exception because players are incompatible
228 with pytest.raises(UnsupportedFeaturedException):
229 asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
230
231
232class TestCacheInvalidationAfterGrouping:
233 """Test that caches are invalidated after grouping operations."""
234
235 async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
236 """
237 Test that all players' caches are cleared after set_members.
238
239 Regression test for: Stale can_group_with cache after grouping changes.
240 """
241 controller = PlayerController(mock_mass)
242 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
243
244 leader = MockPlayer(provider, "leader", "Leader")
245 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
246 leader._attr_can_group_with = {"test"}
247 leader._attr_group_members = []
248
249 member = MockPlayer(provider, "member", "Member")
250
251 other = MockPlayer(provider, "other", "Other")
252 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
253 other._attr_can_group_with = {"test"}
254
255 controller._players = {"leader": leader, "member": member, "other": other}
256 mock_mass.players = controller
257
258 # Populate caches
259 _ = leader.state.can_group_with
260 _ = other.state.can_group_with
261
262 # Simulate grouping (normally done by provider's set_members implementation)
263 leader._attr_group_members = ["leader", "member"]
264
265 # Call set_members to trigger cache invalidation
266 await controller._handle_set_members_with_protocols(
267 leader, player_ids_to_add=["member"], player_ids_to_remove=[]
268 )
269
270 # Note: The actual cache clearing happens via trigger_player_update
271 # which schedules update_state to be called later
272 # In a real scenario, this would clear all players' caches
273
274
275class TestNativeSetMembersGuard:
276 """Test the SET_MEMBERS feature guard on native set_members forwarding."""
277
278 async def test_native_set_members_skipped_without_feature(self, mock_mass: MagicMock) -> None:
279 """
280 Test that set_members is not called on a player without SET_MEMBERS support.
281
282 Regression test for: NotImplementedError raised from
283 _cleanup_player_memberships when removing a member from a native player
284 whose group membership is managed externally (e.g. a Google Cast group,
285 which never advertises SET_MEMBERS).
286 """
287 controller = PlayerController(mock_mass)
288 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
289
290 # a native group-like player WITHOUT SET_MEMBERS in supported_features,
291 # whose set_members behaves like the Player base class (raises)
292 parent = MockPlayer(provider, "cast_group", "Cast Group")
293 parent._attr_group_members = ["cast_group", "member"]
294 parent.set_members = AsyncMock( # type: ignore[method-assign]
295 side_effect=NotImplementedError(
296 "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
297 )
298 )
299 member = MockPlayer(provider, "member", "Member")
300
301 controller._players = {"cast_group": parent, "member": member}
302 mock_mass.players = controller
303
304 # must complete without raising NotImplementedError
305 await controller._handle_set_members_with_protocols(
306 parent, player_ids_to_add=[], player_ids_to_remove=["member"]
307 )
308 parent.set_members.assert_not_called()
309
310
311class TestGroupUngroup:
312 """Test group and ungroup commands."""
313
314 async def test_group_command(self, mock_mass: MagicMock) -> None:
315 """Test the group command (cmd_group)."""
316 controller = PlayerController(mock_mass)
317 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
318
319 leader = MockPlayer(provider, "leader", "Leader")
320 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
321 leader._attr_can_group_with = {"member"} # Leader can group with member
322
323 member = MockPlayer(provider, "member", "Member")
324 # Make sure member is already powered on to skip power handling
325 member._attr_powered = True
326
327 controller._players = {"leader": leader, "member": member}
328 mock_mass.players = controller
329
330 # Update state after modifying attributes and registering with controller
331 leader.update_state(signal_event=False)
332 member.update_state(signal_event=False)
333
334 # Track if set_members was called
335 set_members_called = False
336 original_set_members = leader.set_members
337
338 async def mock_set_members(
339 player_ids_to_add: list[str] | None = None,
340 player_ids_to_remove: list[str] | None = None,
341 ) -> None:
342 nonlocal set_members_called
343 set_members_called = True
344 # Call the original to update group_members
345 await original_set_members(player_ids_to_add, player_ids_to_remove)
346
347 leader.set_members = mock_set_members # type: ignore[method-assign]
348
349 # Mock power handling to skip power control (focus is on grouping logic)
350 async def mock_handle_cmd_power(
351 player_id: str, powered: bool, skip_auto_play: bool = False
352 ) -> None:
353 pass
354
355 controller._handle_cmd_power = mock_handle_cmd_power # type: ignore[method-assign]
356
357 # Execute group command
358 await controller.cmd_group("member", "leader")
359
360 # Verify set_members was called
361 assert set_members_called
362 # Verify member was added to leader's group
363 assert "member" in leader._attr_group_members
364
365
366class TestPlayerAvailability:
367 """Test player availability checks in grouping."""
368
369 def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
370 """Test that unavailable players are rejected when grouping."""
371 controller = PlayerController(mock_mass)
372 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
373
374 leader = MockPlayer(provider, "leader", "Leader")
375 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
376 leader._attr_can_group_with = {"test"}
377
378 member = MockPlayer(provider, "member", "Member")
379 member._attr_available = False # Mark as unavailable
380
381 controller._players = {"leader": leader, "member": member}
382 mock_mass.players = controller
383
384 # Attempting to group with unavailable player should be handled
385 # (either silently ignored or raise exception depending on implementation)
386 # This should either skip the unavailable player or raise an exception
387 with contextlib.suppress(Exception):
388 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
389
390
391def _group_with_member(
392 mock_mass: MagicMock, *, initialize_group: bool = True
393) -> tuple[PlayerController, MockPlayer, MockPlayer]:
394 """
395 Build a controller holding one group player with a single member.
396
397 :param mock_mass: The mocked MusicAssistant instance to attach the controller to.
398 :param initialize_group: Whether the group player is marked as fully registered.
399
400 :return: The controller, the group player and its member.
401 """
402 controller = PlayerController(mock_mass)
403 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
404
405 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
406 member = MockPlayer(provider, "member", "Member")
407
408 controller._players = {"group": group_player, "member": member}
409 mock_mass.players = controller
410
411 group_player._attr_group_members = ["member"]
412 member.initialized.set()
413 if initialize_group:
414 group_player.initialized.set()
415 for player in (group_player, member):
416 player.update_state(signal_event=False)
417 return controller, group_player, member
418
419
420@contextlib.contextmanager
421def _restricted_user(visible_player_ids: list[str]) -> Iterator[None]:
422 """
423 Run the wrapped block as a non-admin user that may only see the given players.
424
425 :param visible_player_ids: The player ids the user is allowed to see.
426 """
427 # the contextvar is copied into the task an API command runs in, so it stays
428 # live for everything that command reaches, internal bookkeeping included
429 token = current_user.set(
430 User(
431 user_id="user_1",
432 username="restricted",
433 role=UserRole.USER,
434 player_filter=visible_player_ids,
435 )
436 )
437 try:
438 yield
439 finally:
440 current_user.reset(token)
441
442
443class TestStateForwarding:
444 """Test forwarding of player state changes to related players."""
445
446 def test_sync_leader_updates_are_forwarded_to_sync_children(self, mock_mass: MagicMock) -> None:
447 """A regular sync leader must notify children via the sync-parent callback."""
448 controller = PlayerController(mock_mass)
449 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
450
451 leader = MockPlayer(provider, "leader", "Leader")
452 child = MockPlayer(provider, "child", "Child")
453
454 controller._players = {"leader": leader, "child": child}
455 mock_mass.players = controller
456
457 leader._attr_group_members = ["leader", "child"]
458 leader.update_state(signal_event=False)
459 child.update_state(signal_event=False)
460
461 with (
462 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
463 patch.object(child, "on_group_updated") as on_group_updated,
464 ):
465 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
466 controller._forward_state_update(leader, changed_values)
467
468 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
469 on_group_updated.assert_not_called()
470
471 def test_group_updates_are_forwarded_to_children_via_group_callback(
472 self, mock_mass: MagicMock
473 ) -> None:
474 """A group player must continue to notify children via the group callback."""
475 controller = PlayerController(mock_mass)
476 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
477
478 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
479 child = MockPlayer(provider, "child", "Child")
480
481 controller._players = {"group": group_player, "child": child}
482 mock_mass.players = controller
483
484 group_player._attr_group_members = ["group", "child"]
485 group_player.update_state(signal_event=False)
486 child.update_state(signal_event=False)
487
488 with (
489 patch.object(child, "on_group_updated") as on_group_updated,
490 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
491 ):
492 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
493 controller._forward_state_update(group_player, changed_values)
494
495 on_group_updated.assert_called_once_with(
496 group_player,
497 changed_values,
498 )
499 on_sync_parent_updated.assert_not_called()
500
501 def test_sync_leader_updates_also_reach_its_group_player(self, mock_mass: MagicMock) -> None:
502 """A sync leader must notify its group player as well as its own sync children."""
503 controller = PlayerController(mock_mass)
504 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
505
506 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
507 leader = MockPlayer(provider, "leader", "Leader")
508 follower = MockPlayer(provider, "follower", "Follower")
509
510 controller._players = {"group": group_player, "leader": leader, "follower": follower}
511 mock_mass.players = controller
512
513 group_player._attr_group_members = ["leader", "follower"]
514 leader._attr_group_members = ["leader", "follower"]
515 for player in (group_player, leader, follower):
516 # _get_player_groups only considers players the controller finished registering
517 player.initialized.set()
518 player.update_state(signal_event=False)
519
520 with (
521 patch.object(group_player, "on_group_member_updated") as on_group_member_updated,
522 patch.object(follower, "on_sync_parent_updated") as on_sync_parent_updated,
523 ):
524 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
525 controller._forward_state_update(leader, changed_values)
526
527 on_group_member_updated.assert_called_once_with(leader, changed_values)
528 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
529
530 def test_group_player_is_notified_under_restricted_user_context(
531 self, mock_mass: MagicMock
532 ) -> None:
533 """The fan-out must not be narrowed by the user that triggered the update."""
534 controller, group_player, member = _group_with_member(mock_mass)
535
536 with _restricted_user(["member"]):
537 assert group_player not in controller.all_players()
538 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
539 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
540 controller._forward_state_update(member, changed_values)
541
542 on_group_member_updated.assert_called_once_with(member, changed_values)
543
544 def test_unavailable_group_player_is_still_notified(self, mock_mass: MagicMock) -> None:
545 """A group player mirrors its members, so it must update while unavailable too."""
546 controller, group_player, member = _group_with_member(mock_mass)
547 group_player._attr_available = False
548 group_player.update_state(signal_event=False)
549 assert group_player.state.available is False
550
551 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
552 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
553 controller._forward_state_update(member, changed_values)
554
555 on_group_member_updated.assert_called_once_with(member, changed_values)
556
557 def test_disabled_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
558 """A disabled player takes no part, unlike one that is merely unavailable."""
559 controller, group_player, member = _group_with_member(mock_mass)
560 group_player._config.enabled = False
561 group_player.update_state(signal_event=False, force_update=True)
562 assert group_player.state.enabled is False
563
564 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
565 controller._forward_state_update(
566 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
567 )
568
569 on_group_member_updated.assert_not_called()
570
571 def test_uninitialized_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
572 """A player the controller is still registering is not fully set up yet."""
573 controller, group_player, member = _group_with_member(mock_mass, initialize_group=False)
574
575 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
576 controller._forward_state_update(
577 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
578 )
579
580 on_group_member_updated.assert_not_called()
581
582
583class TestUnscopedPlayerLookups:
584 """Derived player state must not depend on who triggered the recalculation."""
585
586 def test_iter_players_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
587 """iter_players is the internal view: every registered player, no user filter."""
588 controller, group_player, member = _group_with_member(mock_mass)
589
590 with _restricted_user(["member"]):
591 assert controller.all_players() == [member]
592 assert set(controller.iter_players()) == {group_player, member}
593
594 def test_provider_filter_still_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
595 """A lookup scoped to one provider must still ignore the user filter."""
596 controller, group_player, member = _group_with_member(mock_mass)
597
598 with _restricted_user(["member"]):
599 found = set(controller.iter_players(provider_filter="test"))
600
601 assert found == {group_player, member}
602
603 def test_provider_sees_all_of_its_own_players(self, mock_mass: MagicMock) -> None:
604 """A provider owns its players, so a user filter must never hide them from it."""
605 controller = PlayerController(mock_mass)
606 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
607
608 plain = MockPlayer(provider, "plain", "Plain")
609 # a protocol player id is picked from a list that hides protocol players, so it
610 # is never in a user's filter - the whole set used to disappear for any non-admin
611 protocol = MockPlayer(provider, "protocol", "Protocol", player_type=PlayerType.PROTOCOL)
612 controller._players = {"plain": plain, "protocol": protocol}
613 mock_mass.players = controller
614 for player in (plain, protocol):
615 player.initialized.set()
616 player.update_state(signal_event=False)
617
618 with _restricted_user(["plain"]):
619 owned = set(PlayerProvider.players.fget(provider)) # type: ignore[attr-defined]
620
621 assert owned == {plain, protocol}
622
623 def test_active_group_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
624 """A member must still know its group when the triggering user cannot see it."""
625 _controller, group_player, member = _group_with_member(mock_mass)
626 group_player._attr_group_members = ["member"]
627 group_player._attr_powered = True
628 group_player.update_state(signal_event=False)
629
630 with _restricted_user(["member"]):
631 member.update_state(signal_event=False, force_update=True)
632 assert member.state.active_group == "group"
633
634 def test_synced_to_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
635 """A follower must still resolve its sync leader across a user filter."""
636 controller = PlayerController(mock_mass)
637 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
638
639 leader = MockPlayer(provider, "leader", "Leader")
640 follower = MockPlayer(provider, "follower", "Follower")
641 controller._players = {"leader": leader, "follower": follower}
642 mock_mass.players = controller
643
644 leader._attr_group_members = ["leader", "follower"]
645 for player in (leader, follower):
646 player.initialized.set()
647 player.update_state(signal_event=False)
648
649 with _restricted_user(["follower"]):
650 follower.update_state(signal_event=False, force_update=True)
651 assert follower.state.synced_to == "leader"
652
653 def test_can_group_with_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
654 """Expanding a provider id into players must not drop players the user cannot see."""
655 controller = PlayerController(mock_mass)
656 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
657 mock_mass.get_provider.return_value = provider
658
659 player_a = MockPlayer(provider, "player_a", "Player A")
660 player_b = MockPlayer(provider, "player_b", "Player B")
661 controller._players = {"player_a": player_a, "player_b": player_b}
662 mock_mass.players = controller
663
664 # a provider instance id stands for "every player of that provider"
665 player_a._attr_can_group_with = {"test"}
666 for player in (player_a, player_b):
667 player.initialized.set()
668 player.update_state(signal_event=False)
669
670 with _restricted_user(["player_a"]):
671 player_a.update_state(signal_event=False, force_update=True)
672 assert "player_b" in player_a.state.can_group_with
673
674
675class TestSleepTimer:
676 """Test native sleep timer handling."""
677
678 def test_set_and_clear_sleep_timer(self, mock_mass: MagicMock) -> None:
679 """Setting a sleep timer exposes state and schedules the stop callback."""
680 controller = PlayerController(mock_mass)
681 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
682 player = MockPlayer(provider, "player_1", "Player 1")
683 controller._players = {"player_1": player}
684 mock_mass.players = controller
685
686 expires_at = controller.set_sleep_timer("player_1", 30)
687
688 assert controller.get_sleep_timer("player_1") == expires_at
689 assert player.sleep_timer_expires_at == expires_at
690 mock_mass.call_later.assert_called_with(
691 30,
692 controller._handle_sleep_timer_expired,
693 "player_1",
694 task_id="player_sleep_timer_player_1",
695 )
696 mock_mass.signal_event.assert_any_call(
697 EventType.PLAYER_SLEEP_TIMER_UPDATED,
698 object_id="player_1",
699 data=expires_at,
700 )
701
702 controller.clear_sleep_timer("player_1")
703
704 # get_sleep_timer reads the model field directly, so this also asserts it cleared
705 assert controller.get_sleep_timer("player_1") is None
706 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
707 mock_mass.signal_event.assert_any_call(
708 EventType.PLAYER_SLEEP_TIMER_UPDATED,
709 object_id="player_1",
710 data=None,
711 )
712
713 async def test_sleep_timer_removed_when_player_unregistered(self, mock_mass: MagicMock) -> None:
714 """Unregistering a player cancels and clears its sleep timer."""
715 controller = PlayerController(mock_mass)
716 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
717 player = MockPlayer(provider, "player_1", "Player 1")
718 controller._players = {"player_1": player}
719 player.set_sleep_timer_expires_at(123.0)
720
721 await controller.unregister("player_1")
722
723 assert player.sleep_timer_expires_at is None
724 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
725
726 async def test_sleep_timer_expiry_stops_player(self, mock_mass: MagicMock) -> None:
727 """An expired sleep timer clears its state and stops playback."""
728 controller = PlayerController(mock_mass)
729 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
730 player = MockPlayer(provider, "player_1", "Player 1")
731 controller._players = {"player_1": player}
732 player.set_sleep_timer_expires_at(123.0)
733 controller.cmd_stop = AsyncMock() # type: ignore[method-assign]
734
735 await controller._handle_sleep_timer_expired("player_1")
736
737 assert controller.get_sleep_timer("player_1") is None
738 assert player.sleep_timer_expires_at is None
739 controller.cmd_stop.assert_awaited_once_with("player_1")
740 mock_mass.signal_event.assert_any_call(
741 EventType.PLAYER_SLEEP_TIMER_UPDATED,
742 object_id="player_1",
743 data=None,
744 )
745
746 def test_set_sleep_timer_rejects_invalid_duration(self, mock_mass: MagicMock) -> None:
747 """A non-positive or float-overflowing duration raises and schedules nothing."""
748 controller = PlayerController(mock_mass)
749 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
750 player = MockPlayer(provider, "player_1", "Player 1")
751 controller._players = {"player_1": player}
752
753 # 0/-30 are non-positive; 10**400 exceeds the float range for the expiry math
754 for invalid in (0, -30, 10**400):
755 with pytest.raises(InvalidDataError):
756 controller.set_sleep_timer("player_1", invalid)
757
758 assert controller.get_sleep_timer("player_1") is None
759 mock_mass.call_later.assert_not_called()
760
761
762class TestUnregisterCleanup:
763 """Test that unregister cleans up leaked internal state."""
764
765 def test_command_locks_removed(self, mock_mass: MagicMock) -> None:
766 """Unregistering a player removes its command lock entries."""
767 controller = PlayerController(mock_mass)
768 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
769 player = MockPlayer(provider, "player_1", "Player 1")
770
771 controller._players = {"player_1": player}
772 controller._player_command_locks = {
773 "playback_player_1": asyncio.Lock(),
774 "volume_player_1": asyncio.Lock(),
775 }
776
777 asyncio.run(controller.unregister("player_1"))
778
779 assert "playback_player_1" not in controller._player_command_locks
780 assert "volume_player_1" not in controller._player_command_locks
781
782 def test_other_players_state_untouched(self, mock_mass: MagicMock) -> None:
783 """Unregistering one player does not affect another player's state."""
784 controller = PlayerController(mock_mass)
785 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
786 player_a = MockPlayer(provider, "player_a", "Player A")
787 player_b = MockPlayer(provider, "player_b", "Player B")
788
789 controller._players = {"player_a": player_a, "player_b": player_b}
790 controller._player_command_locks = {
791 "playback_player_a": asyncio.Lock(),
792 "playback_player_b": asyncio.Lock(),
793 }
794
795 asyncio.run(controller.unregister("player_a"))
796
797 assert "playback_player_b" in controller._player_command_locks
798 assert "playback_player_a" not in controller._player_command_locks
799
800 def test_suffix_player_id_not_over_matched(self, mock_mass: MagicMock) -> None:
801 """Removing player 'b' must not remove locks for player 'a_b' (no suffix matching)."""
802 controller = PlayerController(mock_mass)
803 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
804 player_b = MockPlayer(provider, "b", "Player B")
805 player_a_b = MockPlayer(provider, "a_b", "Player A_B")
806
807 controller._players = {"b": player_b, "a_b": player_a_b}
808 controller._player_command_locks = {
809 "playback_b": asyncio.Lock(),
810 "playback_a_b": asyncio.Lock(),
811 }
812
813 asyncio.run(controller.unregister("b"))
814
815 assert "playback_a_b" in controller._player_command_locks
816 assert "playback_b" not in controller._player_command_locks
817
818 def test_pending_protocol_evaluation_cancelled(self, mock_mass: MagicMock) -> None:
819 """Unregistering a player cancels and removes its pending protocol evaluation."""
820 controller = PlayerController(mock_mass)
821 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
822 player = MockPlayer(provider, "player_1", "Player 1")
823
824 mock_handle = MagicMock()
825 controller._players = {"player_1": player}
826 controller._pending_protocol_evaluations = {"player_1": mock_handle}
827
828 asyncio.run(controller.unregister("player_1"))
829
830 mock_handle.cancel.assert_called_once()
831 assert "player_1" not in controller._pending_protocol_evaluations
832
833 def test_unregister_nonexistent_player_is_noop(self, mock_mass: MagicMock) -> None:
834 """Unregistering a player that doesn't exist is silently ignored."""
835 controller = PlayerController(mock_mass)
836 controller._player_command_locks = {"set_members_other": asyncio.Lock()}
837
838 asyncio.run(controller.unregister("nonexistent"))
839
840 assert "set_members_other" in controller._player_command_locks
841
842
843class TestRegisterUnregisterRace:
844 """Test registration that is interrupted by an unregister of the same player."""
845
846 @staticmethod
847 def _stub_register_calls(mock_mass: MagicMock) -> None:
848 """Stub the awaited mass calls made during register/unregister."""
849 # registration reads config keys with differently typed defaults (mapping for the
850 # player config store, str | None for the cached MAC addresses)
851 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
852 mock_mass.cache.get = AsyncMock(return_value=None)
853 mock_mass.config.get_player_config = AsyncMock(return_value=create_mock_config("Player 1"))
854 mock_mass.player_queues.on_player_register = AsyncMock()
855 mock_mass.player_queues.on_player_remove = MagicMock()
856
857 @staticmethod
858 def _player_added_signalled(mock_mass: MagicMock) -> bool:
859 """Return True if a PLAYER_ADDED event was signalled."""
860 return any(
861 call_args.args and call_args.args[0] == EventType.PLAYER_ADDED
862 for call_args in mock_mass.signal_event.call_args_list
863 )
864
865 async def test_register_aborts_when_unregistered_during_config_load(
866 self, mock_mass: MagicMock
867 ) -> None:
868 """A player unregistered while its config loads is not announced as added."""
869 controller = PlayerController(mock_mass)
870 self._stub_register_calls(mock_mass)
871 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
872 player = MockPlayer(provider, "player_1", "Player 1")
873
874 async def _unregister_midway(player_id: str) -> MagicMock:
875 # stands in for a provider unload or device disconnect landing
876 # while register awaits the player config
877 await controller.unregister(player_id)
878 return create_mock_config("Player 1")
879
880 mock_mass.config.get_player_config = AsyncMock(side_effect=_unregister_midway)
881 config_hook = AsyncMock()
882
883 with (
884 patch(
885 "music_assistant.controllers.players.controller.enrich_device_mac_address",
886 AsyncMock(),
887 ),
888 patch.object(player, "on_config_updated", config_hook),
889 ):
890 await controller.register(player)
891
892 assert "player_1" not in controller._players
893 assert not player.initialized.is_set()
894 # setup stops right away: the provider hook must not run on a player
895 # whose on_unload already ran
896 config_hook.assert_not_called()
897 mock_mass.player_queues.on_player_register.assert_not_called()
898 assert not self._player_added_signalled(mock_mass)
899
900 async def test_register_aborts_when_unregistered_during_config_hook(
901 self, mock_mass: MagicMock
902 ) -> None:
903 """A player unregistered while its on_config_updated hook runs is not announced."""
904 controller = PlayerController(mock_mass)
905 self._stub_register_calls(mock_mass)
906 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
907 player = MockPlayer(provider, "player_1", "Player 1")
908
909 async def _unregister_midway() -> None:
910 await controller.unregister("player_1")
911
912 with (
913 patch(
914 "music_assistant.controllers.players.controller.enrich_device_mac_address",
915 AsyncMock(),
916 ),
917 patch.object(player, "on_config_updated", AsyncMock(side_effect=_unregister_midway)),
918 ):
919 await controller.register(player)
920
921 assert "player_1" not in controller._players
922 assert not player.initialized.is_set()
923 mock_mass.player_queues.on_player_register.assert_not_called()
924 assert not self._player_added_signalled(mock_mass)
925
926 async def test_register_drops_queue_recreated_after_unregister(
927 self, mock_mass: MagicMock
928 ) -> None:
929 """A queue restored after the unregister already removed it is dropped again."""
930 controller = PlayerController(mock_mass)
931 self._stub_register_calls(mock_mass)
932 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
933 player = MockPlayer(provider, "player_1", "Player 1")
934
935 events: list[str] = []
936
937 async def _unregister_midway(registering_player: MockPlayer) -> None:
938 # on_player_register restores the queue from cache before storing it, so an
939 # unregister can land in between and have its cleanup undone
940 await controller.unregister(registering_player.player_id)
941 events.append("queue_created")
942
943 def _track_removal(*_args: object, **_kwargs: object) -> None:
944 events.append("queue_removed")
945
946 mock_mass.player_queues.on_player_remove = MagicMock(side_effect=_track_removal)
947 mock_mass.player_queues.on_player_register = AsyncMock(side_effect=_unregister_midway)
948
949 with patch(
950 "music_assistant.controllers.players.controller.enrich_device_mac_address",
951 AsyncMock(),
952 ):
953 await controller.register(player)
954
955 assert "player_1" not in controller._players
956 # the queue recreated for the removed player must be cleaned up again
957 assert events == ["queue_removed", "queue_created", "queue_removed"]
958
959 async def test_register_or_update_marks_replacement_initialized(
960 self, mock_mass: MagicMock
961 ) -> None:
962 """Replacing a registered player carries the initialized state to the new object."""
963 controller = PlayerController(mock_mass)
964 self._stub_register_calls(mock_mass)
965 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
966 existing = MockPlayer(provider, "player_1", "Player 1")
967 existing.set_initialized()
968 controller._players = {"player_1": existing}
969 replacement = MockPlayer(provider, "player_1", "Player 1")
970
971 await controller.register_or_update(replacement)
972
973 assert controller._players["player_1"] is replacement
974 assert replacement.initialized.is_set()
975
976 async def test_register_or_update_hands_config_to_replacement(
977 self, mock_mass: MagicMock
978 ) -> None:
979 """A replacement instance inherits the config of the player it takes over."""
980 controller = PlayerController(mock_mass)
981 self._stub_register_calls(mock_mass)
982 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
983 existing = MockPlayer(provider, "player_1", "Player 1")
984 resolved_config = create_mock_config("Player 1")
985 existing.set_config(resolved_config)
986 existing.set_initialized()
987 controller._players = {"player_1": existing}
988 replacement = MockPlayer(provider, "player_1", "Player 1")
989 config_hook = AsyncMock()
990
991 with patch.object(replacement, "on_config_updated", config_hook):
992 await controller.register_or_update(replacement)
993
994 # without the resolved config the replacement would read defaults for every
995 # config backed setting (group members, flow mode, visibility, ...)
996 assert replacement.config is resolved_config
997 config_hook.assert_awaited_once()
998
999 async def test_register_or_update_aborts_when_replacement_is_unregistered(
1000 self, mock_mass: MagicMock
1001 ) -> None:
1002 """A replacement unregistered while its config hook runs is not marked ready."""
1003 controller = PlayerController(mock_mass)
1004 self._stub_register_calls(mock_mass)
1005 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1006 existing = MockPlayer(provider, "player_1", "Player 1")
1007 existing.set_config(create_mock_config("Player 1"))
1008 existing.set_initialized()
1009 controller._players = {"player_1": existing}
1010 replacement = MockPlayer(provider, "player_1", "Player 1")
1011
1012 async def _unregister_midway() -> None:
1013 await controller.unregister("player_1")
1014
1015 with patch.object(
1016 replacement, "on_config_updated", AsyncMock(side_effect=_unregister_midway)
1017 ):
1018 await controller.register_or_update(replacement)
1019
1020 assert "player_1" not in controller._players
1021 assert not replacement.initialized.is_set()
1022
1023 async def test_register_or_update_leaves_same_instance_untouched(
1024 self, mock_mass: MagicMock
1025 ) -> None:
1026 """Re-announcing the same instance does not re-run its config hook."""
1027 controller = PlayerController(mock_mass)
1028 self._stub_register_calls(mock_mass)
1029 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1030 player = MockPlayer(provider, "player_1", "Player 1")
1031 player.set_initialized()
1032 controller._players = {"player_1": player}
1033 config_hook = AsyncMock()
1034
1035 with patch.object(player, "on_config_updated", config_hook):
1036 await controller.register_or_update(player)
1037
1038 assert controller._players["player_1"] is player
1039 config_hook.assert_not_called()
1040
1041 async def test_register_or_update_waits_for_inflight_register(
1042 self, mock_mass: MagicMock
1043 ) -> None:
1044 """A player is never swapped out while register() is still setting it up."""
1045 controller = PlayerController(mock_mass)
1046 self._stub_register_calls(mock_mass)
1047 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1048 player = MockPlayer(provider, "player_1", "Player 1")
1049 replacement = MockPlayer(provider, "player_1", "Player 1")
1050 release = asyncio.Event()
1051 registering = asyncio.Event()
1052
1053 async def _blocked_config(*_args: object) -> MagicMock:
1054 registering.set()
1055 await release.wait()
1056 return create_mock_config("Player 1")
1057
1058 mock_mass.config.get_player_config = AsyncMock(side_effect=_blocked_config)
1059
1060 with patch(
1061 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1062 AsyncMock(),
1063 ):
1064 register_task = asyncio.create_task(controller.register(player))
1065 # only proceed once register() is provably inside its critical section
1066 await registering.wait()
1067 update_task = asyncio.create_task(controller.register_or_update(replacement))
1068 await _yield_to_loop()
1069
1070 # register() is still in flight, so the replacement must not be swapped in yet
1071 assert controller._players["player_1"] is player
1072 assert not update_task.done()
1073
1074 release.set()
1075 await register_task
1076 await update_task
1077
1078 assert controller._players["player_1"] is replacement
1079 assert player.initialized.is_set()
1080 assert replacement.initialized.is_set()
1081
1082
1083async def _yield_to_loop() -> None:
1084 """Give other pending tasks a chance to run up to their next suspension point."""
1085 for _ in range(5):
1086 await asyncio.sleep(0)
1087
1088
1089def _set_play_media_override(mock_mass: MagicMock, value: bool) -> None:
1090 """
1091 Configure get_raw_player_config_value to return ``value`` for the play-media override key.
1092
1093 Other keys keep the existing defaults from the shared fixture. Use this in
1094 tests for ``play_media`` override behavior so the legacy/new branch is
1095 selected deterministically.
1096 """
1097 original = mock_mass.config.get_raw_player_config_value.side_effect
1098
1099 def _side_effect(player_id: str, key: str, default: object = None) -> object:
1100 if key == "play_media_overrides_group":
1101 return value
1102 if callable(original):
1103 return original(player_id, key, default)
1104 return default
1105
1106 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_side_effect)
1107
1108
1109class TestCmdUngroupNewBranches:
1110 """
1111 Regression tests for the post-refactor cmd_ungroup flow.
1112
1113 The refactor changed two things:
1114
1115 - ``cmd_ungroup`` on a group player no longer calls ``cmd_set_members``
1116 (which would hit the "Cannot remove static member" guard); instead it
1117 stops or powers off the group entirely.
1118 - ``cmd_ungroup`` on a static member of a group recurses to ungroup the
1119 group, because static members cannot be released individually.
1120 """
1121
1122 @pytest.mark.asyncio
1123 async def test_ungroup_group_player_with_power_uses_power_off(
1124 self, mock_mass: MagicMock
1125 ) -> None:
1126 """Ungroup on a group with explicit power control routes through cmd_power(False)."""
1127 controller = PlayerController(mock_mass)
1128 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1129 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1130 group._attr_powered = True
1131 group._attr_group_members = ["member"]
1132 group._attr_supported_features = {PlayerFeature.POWER}
1133
1134 # ensure power_control resolves to NATIVE so cmd_ungroup uses the power path
1135 mock_mass.config.get_raw_player_config_value = MagicMock(
1136 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
1137 )
1138
1139 controller._players = {"g1": group}
1140 mock_mass.players = controller
1141
1142 # populate state.type / state.power_control / state.group_members
1143 group.set_initialized()
1144 group._cache.clear()
1145 group.update_state(signal_event=False)
1146
1147 called: dict[str, bool | str] = {}
1148
1149 async def _power(
1150 player_id: str,
1151 powered: bool,
1152 skip_auto_play: bool = False, # noqa: ARG001
1153 ) -> None:
1154 called["player_id"] = player_id
1155 called["powered"] = powered
1156
1157 controller._handle_cmd_power = _power # type: ignore[method-assign]
1158
1159 await controller.cmd_ungroup("g1")
1160
1161 assert called == {"player_id": "g1", "powered": False}
1162
1163 @pytest.mark.asyncio
1164 async def test_ungroup_powerless_group_calls_stop(self, mock_mass: MagicMock) -> None:
1165 """Ungroup on a powerless group falls through to _handle_cmd_stop."""
1166 controller = PlayerController(mock_mass)
1167 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1168 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1169 group._attr_powered = None # no power control
1170 group._attr_group_members = ["member"]
1171 # no POWER feature → power_control auto-selects to NONE
1172
1173 controller._players = {"g1": group}
1174 mock_mass.players = controller
1175
1176 group.set_initialized()
1177 group._cache.clear()
1178 group.update_state(signal_event=False)
1179
1180 stop_called: list[str] = []
1181
1182 async def _stop(player_id: str) -> None:
1183 stop_called.append(player_id)
1184
1185 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1186 # also stub power to make sure we did NOT go down that branch
1187 power_called: list[str] = []
1188
1189 async def _power(
1190 player_id: str,
1191 powered: bool, # noqa: ARG001
1192 skip_auto_play: bool = False, # noqa: ARG001
1193 ) -> None:
1194 power_called.append(player_id)
1195
1196 controller._handle_cmd_power = _power # type: ignore[method-assign]
1197
1198 await controller.cmd_ungroup("g1")
1199
1200 assert stop_called == ["g1"]
1201 assert power_called == [] # powerless group → never goes through cmd_power
1202
1203
1204class TestExternalPowerOffUnsync:
1205 """
1206 Tests for unsyncing a player when its power is turned off outside of MA.
1207
1208 When a player's (final) power state flips on->off because its linked power
1209 control was switched off directly - rather than via an MA power command -
1210 the player must be removed from any (sync)group it is part of.
1211 """
1212
1213 def _make_synced_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
1214 """Build a controller with a player synced to a registered leader."""
1215 controller = PlayerController(mock_mass)
1216 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1217 leader = MockPlayer(provider, "leader", "Leader")
1218 leader._attr_group_members = ["leader", "p1"]
1219 player = MockPlayer(provider, "p1", "Player")
1220 controller._players = {"leader": leader, "p1": player}
1221 mock_mass.players = controller
1222 for _player in (leader, player):
1223 _player.set_initialized()
1224 _player._cache.clear()
1225 _player.update_state(signal_event=False)
1226 # isolate the unsync branch from the unrelated state-forwarding machinery
1227 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1228 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1229 return controller, player
1230
1231 def test_power_off_unsyncs_synced_player(self, mock_mass: MagicMock) -> None:
1232 """An on->off power transition ungroups a synced player."""
1233 controller, player = self._make_synced_player(mock_mass)
1234 assert player.state.synced_to == "leader"
1235
1236 controller.signal_player_state_update(player, {"powered": (True, False)})
1237
1238 controller.cmd_ungroup.assert_called_once_with("p1") # type: ignore[attr-defined]
1239
1240 def test_power_on_does_not_unsync(self, mock_mass: MagicMock) -> None:
1241 """An off->on power transition leaves the player synced."""
1242 controller, player = self._make_synced_player(mock_mass)
1243
1244 controller.signal_player_state_update(player, {"powered": (False, True)})
1245
1246 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1247
1248 def test_no_power_control_is_ignored(self, mock_mass: MagicMock) -> None:
1249 """A None->off transition (player without power control) is ignored."""
1250 controller, player = self._make_synced_player(mock_mass)
1251
1252 controller.signal_player_state_update(player, {"powered": (None, False)})
1253
1254 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1255
1256 def test_power_off_ungrouped_player_is_noop(self, mock_mass: MagicMock) -> None:
1257 """Powering off a player that is not in any group does nothing."""
1258 controller = PlayerController(mock_mass)
1259 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1260 player = MockPlayer(provider, "p1", "Player")
1261 controller._players = {"p1": player}
1262 mock_mass.players = controller
1263 player.set_initialized()
1264 player._cache.clear()
1265 player.update_state(signal_event=False)
1266 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1267 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1268
1269 controller.signal_player_state_update(player, {"powered": (True, False)})
1270
1271 controller.cmd_ungroup.assert_not_called()
1272
1273
1274class TestPlayMediaOverride:
1275 """
1276 Tests for the new CONF_PLAY_MEDIA_OVERRIDES_GROUP behavior.
1277
1278 When a captured child player receives an explicit play_media command, the
1279 default behavior is to *release* it from the active group/sync and play
1280 directly on the targeted player. The legacy behavior (forward to the
1281 leader) is preserved via the per-player config opt-out.
1282 """
1283
1284 @pytest.mark.asyncio
1285 async def test_override_disabled_redirects_to_group(self, mock_mass: MagicMock) -> None:
1286 """With override disabled, play_media on a captured child redirects to the group."""
1287 controller = PlayerController(mock_mass)
1288 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1289 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1290
1291 class _SessionedGroup(MockPlayer):
1292 @property
1293 def is_active_session(self) -> bool:
1294 return True
1295
1296 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1297 group._attr_powered = None
1298 group._attr_group_members = ["member"]
1299
1300 member = MockPlayer(member_provider, "member", "Member")
1301
1302 controller._players = {"g1": group, "member": member}
1303 mock_mass.players = controller
1304
1305 group.set_initialized()
1306 member.set_initialized()
1307 group.update_state(signal_event=False)
1308 member.update_state(signal_event=False)
1309 # sanity: the member is captured by the group
1310 assert member.state.active_group == "g1"
1311
1312 _set_play_media_override(mock_mass, False)
1313
1314 played_on: list[str] = []
1315
1316 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1317 played_on.append(player_id)
1318
1319 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1320 # the play_media wrapper acquires a playback lock; stub it out
1321 controller._player_command_locks = {}
1322
1323 media = MagicMock(uri="x", source_id="src")
1324 await controller.play_media("member", media)
1325
1326 # legacy behavior: redirected to the group leader
1327 assert played_on == ["g1"]
1328
1329 @pytest.mark.asyncio
1330 async def test_override_releases_dynamic_member(self, mock_mass: MagicMock) -> None:
1331 """With override enabled, play_media on a dynamic group member releases it first."""
1332 controller = PlayerController(mock_mass)
1333 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1334 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1335
1336 class _SessionedGroup(MockPlayer):
1337 @property
1338 def is_active_session(self) -> bool:
1339 return True
1340
1341 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1342 group._attr_powered = None
1343 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1344 group._attr_group_members = ["member"]
1345 # NOT a static member ⇒ dynamic — can be removed via set_members
1346 group._attr_static_group_members = []
1347
1348 member = MockPlayer(member_provider, "member", "Member")
1349
1350 controller._players = {"g1": group, "member": member}
1351 mock_mass.players = controller
1352
1353 group.set_initialized()
1354 member.set_initialized()
1355 group.update_state(signal_event=False)
1356 member.update_state(signal_event=False)
1357 assert member.state.active_group == "g1"
1358
1359 # default: override enabled
1360 _set_play_media_override(mock_mass, True)
1361
1362 set_members_calls: list[dict[str, object]] = []
1363
1364 async def _cmd_set_members(
1365 target_player: str,
1366 player_ids_to_add: list[str] | None = None, # noqa: ARG001
1367 player_ids_to_remove: list[str] | None = None,
1368 ) -> None:
1369 set_members_calls.append(
1370 {"player_id": target_player, "remove": player_ids_to_remove or []}
1371 )
1372
1373 controller.cmd_set_members = _cmd_set_members # type: ignore[method-assign]
1374
1375 played_on: list[str] = []
1376
1377 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1378 played_on.append(player_id)
1379
1380 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1381 controller._player_command_locks = {}
1382
1383 media = MagicMock(uri="x", source_id="src")
1384 with patch.object(
1385 controller,
1386 "wait_for_player_update",
1387 _skip_player_update_wait,
1388 ):
1389 await controller.play_media("member", media)
1390
1391 # the member was removed from the group ...
1392 assert set_members_calls == [{"player_id": "g1", "remove": ["member"]}]
1393 # ... and then play_media was issued directly on the member, NOT on the group
1394 assert played_on == ["member"]
1395
1396 @pytest.mark.asyncio
1397 async def test_override_stops_static_group(self, mock_mass: MagicMock) -> None:
1398 """With override enabled, play_media on a STATIC group member stops the group."""
1399 controller = PlayerController(mock_mass)
1400 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1401 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1402
1403 class _SessionedGroup(MockPlayer):
1404 @property
1405 def is_active_session(self) -> bool:
1406 return True
1407
1408 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1409 group._attr_powered = None # no power control ⇒ stop, not power-off
1410 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1411 group._attr_group_members = ["member"]
1412 # static member - cannot be removed individually
1413 group._attr_static_group_members = ["member"]
1414
1415 member = MockPlayer(member_provider, "member", "Member")
1416
1417 controller._players = {"g1": group, "member": member}
1418 mock_mass.players = controller
1419
1420 group.set_initialized()
1421 member.set_initialized()
1422 group.update_state(signal_event=False)
1423 member.update_state(signal_event=False)
1424 assert member.state.active_group == "g1"
1425
1426 _set_play_media_override(mock_mass, True)
1427
1428 stop_calls: list[str] = []
1429 power_calls: list[tuple[str, bool]] = []
1430
1431 async def _stop(player_id: str) -> None:
1432 stop_calls.append(player_id)
1433
1434 async def _power(
1435 player_id: str,
1436 powered: bool,
1437 skip_auto_play: bool = False, # noqa: ARG001
1438 ) -> None:
1439 power_calls.append((player_id, powered))
1440
1441 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1442 controller._handle_cmd_power = _power # type: ignore[method-assign]
1443
1444 played_on: list[str] = []
1445
1446 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1447 played_on.append(player_id)
1448
1449 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1450 controller._player_command_locks = {}
1451
1452 media = MagicMock(uri="x", source_id="src")
1453 with patch.object(
1454 controller,
1455 "wait_for_player_update",
1456 _skip_player_update_wait,
1457 ):
1458 await controller.play_media("member", media)
1459
1460 # powerless group + static member: we should have stopped the group ...
1461 assert stop_calls == ["g1"]
1462 # ... not powered it off ...
1463 assert power_calls == []
1464 # ... and play_media was issued directly on the member
1465 assert played_on == ["member"]
1466
1467
1468class TestExternalSourcePlayPause:
1469 """Pause/play handling for externally-initiated sources (no active output protocol)."""
1470
1471 @staticmethod
1472 def _make_external_source_player(
1473 provider: MockProvider,
1474 controller: PlayerController,
1475 mock_mass: MagicMock,
1476 *,
1477 playback_state: PlaybackState,
1478 can_play_pause: bool = True,
1479 supports_pause: bool = True,
1480 ) -> MockPlayer:
1481 """Build a player playing a passive external source, with no active output protocol."""
1482 player = MockPlayer(provider, "player_1", "Test Player")
1483 player._attr_supported_features = {PlayerFeature.PAUSE} if supports_pause else set()
1484 player._attr_source_list = [
1485 PlayerSource(
1486 id="spotify",
1487 name="Spotify",
1488 passive=True,
1489 can_play_pause=can_play_pause,
1490 can_next_previous=True,
1491 can_seek=True,
1492 )
1493 ]
1494 player._attr_active_source = "spotify"
1495 player._attr_playback_state = playback_state
1496 player._cache.clear()
1497 controller._players = {"player_1": player}
1498 mock_mass.players = controller
1499 mock_mass.player_queues = MagicMock()
1500 mock_mass.player_queues.get = MagicMock(return_value=None)
1501 player.update_state(signal_event=False)
1502 return player
1503
1504 def test_pause_external_source_forwards_to_player(
1505 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1506 ) -> None:
1507 """Pausing a pausable external source forwards to the player, not STOP."""
1508 player = self._make_external_source_player(
1509 provider, controller, mock_mass, playback_state=PlaybackState.PLAYING
1510 )
1511 player.pause = AsyncMock() # type: ignore[method-assign]
1512 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1513
1514 asyncio.run(controller._handle_cmd_pause("player_1"))
1515
1516 player.pause.assert_awaited_once()
1517 controller._handle_cmd_stop.assert_not_called()
1518
1519 def test_play_external_source_unpauses_player(
1520 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1521 ) -> None:
1522 """Unpausing a paused external source forwards to the player, not a restart."""
1523 player = self._make_external_source_player(
1524 provider, controller, mock_mass, playback_state=PlaybackState.PAUSED
1525 )
1526 player.play = AsyncMock() # type: ignore[method-assign]
1527 player.play_media = AsyncMock() # type: ignore[method-assign]
1528 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1529
1530 asyncio.run(controller._handle_cmd_play("player_1"))
1531
1532 player.play.assert_awaited_once()
1533 player.play_media.assert_not_called()
1534 controller._handle_select_source.assert_not_called()
1535
1536 def test_pause_falls_back_to_stop_without_pause_support(
1537 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1538 ) -> None:
1539 """A player that cannot pause natively still falls back to STOP."""
1540 player = self._make_external_source_player(
1541 provider,
1542 controller,
1543 mock_mass,
1544 playback_state=PlaybackState.PLAYING,
1545 supports_pause=False,
1546 )
1547 player.pause = AsyncMock() # type: ignore[method-assign]
1548 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1549
1550 asyncio.run(controller._handle_cmd_pause("player_1"))
1551
1552 controller._handle_cmd_stop.assert_awaited_once()
1553 player.pause.assert_not_called()
1554
1555
1556class TestProtocolOutputPlayPause:
1557 """Play/pause on a player rendering through a linked output protocol."""
1558
1559 @staticmethod
1560 def _make_player_on_protocol(
1561 mock_mass: MagicMock,
1562 controller: PlayerController,
1563 *,
1564 playback_state: PlaybackState,
1565 ) -> MockPlayer:
1566 """Build a player playing the MA queue through a protocol that cannot pause."""
1567 native_provider = MockProvider("chromecast", mass=mock_mass)
1568 player = MockPlayer(native_provider, "player_1", "Test Player")
1569 player._attr_supported_features.add(PlayerFeature.PAUSE)
1570 player._attr_playback_state = playback_state
1571
1572 protocol_provider = MockProvider("sendspin", mass=mock_mass)
1573 protocol_player = MockPlayer(
1574 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
1575 )
1576 protocol_player._attr_playback_state = playback_state
1577
1578 controller._players = {"player_1": player, "proto_1": protocol_player}
1579 mock_mass.players = controller
1580 mock_mass.player_queues = MagicMock()
1581 # a non-empty queue, so the MA queue source advertises play/pause support
1582 queue = MagicMock()
1583 queue.items = [MagicMock()]
1584 mock_mass.player_queues.get = MagicMock(return_value=queue)
1585 player.set_linked_output_protocols(
1586 [
1587 OutputProtocol(
1588 output_protocol_id="proto_1",
1589 name="Sendspin",
1590 protocol_domain="sendspin",
1591 priority=40,
1592 )
1593 ]
1594 )
1595 player.set_active_output_protocol("proto_1")
1596 player.set_active_mass_source("player_1")
1597 protocol_player.update_state(signal_event=False)
1598 player.refresh_state(signal_event=False)
1599 return player
1600
1601 async def test_pause_on_protocol_without_pause_falls_back_to_stop(
1602 self, mock_mass: MagicMock, controller: PlayerController
1603 ) -> None:
1604 """The native transport has no session to pause while a protocol renders the audio."""
1605 player = self._make_player_on_protocol(
1606 mock_mass, controller, playback_state=PlaybackState.PLAYING
1607 )
1608 player.pause = AsyncMock() # type: ignore[method-assign]
1609 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1610
1611 await controller._handle_cmd_pause("player_1")
1612
1613 player.pause.assert_not_called()
1614 # STOP goes to the visible player, not the protocol player
1615 controller._handle_cmd_stop.assert_awaited_once_with("player_1")
1616
1617 async def test_play_on_protocol_without_pause_does_not_unpause_natively(
1618 self, mock_mass: MagicMock, controller: PlayerController
1619 ) -> None:
1620 """Unpausing must not hit the native transport either; the source is restarted."""
1621 player = self._make_player_on_protocol(
1622 mock_mass, controller, playback_state=PlaybackState.PAUSED
1623 )
1624 player.play = AsyncMock() # type: ignore[method-assign]
1625 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1626
1627 await controller._handle_cmd_play("player_1")
1628
1629 player.play.assert_not_called()
1630 # the MA queue source is restarted, not some other source
1631 controller._handle_select_source.assert_awaited_once_with("player_1", "player_1")
1632
1633
1634class TestMirrorsParentMedia:
1635 """Tests for _mirrors_parent_media (palette-fetch gating for grouped players)."""
1636
1637 @staticmethod
1638 def _fake_player(
1639 *,
1640 player_id: str = "p1",
1641 active_group: str | None = None,
1642 synced_to: str | None = None,
1643 player_type: PlayerType = PlayerType.PLAYER,
1644 protocol_parent_id: str | None = None,
1645 ) -> SimpleNamespace:
1646 return SimpleNamespace(
1647 player_id=player_id,
1648 state=SimpleNamespace(active_group=active_group, synced_to=synced_to, type=player_type),
1649 protocol_parent_id=protocol_parent_id,
1650 )
1651
1652 def test_standalone_player_owns_media(self, controller: PlayerController) -> None:
1653 """A standalone player resolves its own media (and palette)."""
1654 assert controller._mirrors_parent_media(self._fake_player()) is False # type: ignore[arg-type]
1655
1656 def test_group_member_mirrors(self, controller: PlayerController) -> None:
1657 """A group member borrows its parent's media."""
1658 assert controller._mirrors_parent_media(self._fake_player(active_group="g1")) is True # type: ignore[arg-type]
1659
1660 def test_synced_member_mirrors(self, controller: PlayerController) -> None:
1661 """A synced member borrows its leader's media."""
1662 assert controller._mirrors_parent_media(self._fake_player(synced_to="leader")) is True # type: ignore[arg-type]
1663
1664 def test_protocol_child_mirrors(self, controller: PlayerController) -> None:
1665 """A protocol child borrows its parent's media."""
1666 player = self._fake_player(player_type=PlayerType.PROTOCOL, protocol_parent_id="parent")
1667 assert controller._mirrors_parent_media(player) is True # type: ignore[arg-type]
1668
1669 def test_protocol_player_without_parent_owns_media(self, controller: PlayerController) -> None:
1670 """A protocol player with no parent resolves its own media."""
1671 player = self._fake_player(player_type=PlayerType.PROTOCOL)
1672 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1673
1674 def test_self_referential_parent_owns_media(self, controller: PlayerController) -> None:
1675 """A self-referential active_group/synced_to is not a real parent, so resolve locally."""
1676 player = self._fake_player(player_id="p1", synced_to="p1", active_group="p1")
1677 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1678
1679
1680class TestVolumeScalingOnRedirect:
1681 """min/max volume scaling must survive a redirect to a protocol player or external control."""
1682
1683 @staticmethod
1684 def _volume_player(
1685 player_id: str,
1686 volume_control: str,
1687 volume_set: AsyncMock | None = None,
1688 ) -> SimpleNamespace:
1689 return SimpleNamespace(
1690 player_id=player_id,
1691 type=PlayerType.PLAYER,
1692 protocol_parent_id=None,
1693 extra_data={},
1694 volume_control=volume_control,
1695 mute_control=PLAYER_CONTROL_NONE,
1696 volume_set=volume_set or AsyncMock(),
1697 update_state=MagicMock(),
1698 provider=MagicMock(),
1699 state=SimpleNamespace(
1700 name=player_id,
1701 volume_control=volume_control,
1702 volume_muted=False,
1703 mute_control=PLAYER_CONTROL_NONE,
1704 ),
1705 )
1706
1707 @pytest.mark.asyncio
1708 async def test_protocol_redirect_forwards_scaled_volume(
1709 self, controller: PlayerController, mock_mass: MagicMock
1710 ) -> None:
1711 """A volume command redirected to a protocol player honors the user-facing max_volume."""
1712
1713 def _conf(player_id: str, key: str, default: object = None) -> object:
1714 if key == "min_volume":
1715 return 0
1716 if key == "max_volume":
1717 # user-facing player caps at 50, the protocol player has no limits of its own
1718 return 50 if player_id == "user_player" else 100
1719 return default
1720
1721 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
1722
1723 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
1724 user = self._volume_player("user_player", "protocol_player")
1725 players = {"user_player": user, "protocol_player": protocol}
1726
1727 with (
1728 patch.object(controller, "get_player", side_effect=players.get),
1729 patch.object(controller, "_get_active_audio_source", return_value=None),
1730 ):
1731 controller._controls = {}
1732 await controller._handle_cmd_volume_set("user_player", 100)
1733
1734 # logical 100 with a max_volume of 50 must reach the protocol player as 50, not the raw 100
1735 protocol.volume_set.assert_awaited_once_with(50)
1736
1737 @pytest.mark.asyncio
1738 async def test_external_control_redirect_forwards_scaled_volume(
1739 self, controller: PlayerController, mock_mass: MagicMock
1740 ) -> None:
1741 """A volume command redirected to an external control honors the user-facing max_volume."""
1742 mock_mass.config.get_raw_player_config_value = MagicMock(
1743 side_effect=_player_config_stub(max_volume=50)
1744 )
1745
1746 volume_set = AsyncMock()
1747 control = PlayerControl(
1748 id="ext_control",
1749 provider="test",
1750 name="External Amp",
1751 supports_volume=True,
1752 volume_set=volume_set,
1753 )
1754 user = self._volume_player("user_player", "ext_control")
1755 players = {"user_player": user}
1756
1757 with (
1758 patch.object(controller, "get_player", side_effect=players.get),
1759 patch.object(controller, "_get_active_audio_source", return_value=None),
1760 ):
1761 controller._controls = {"ext_control": control}
1762 await controller._handle_cmd_volume_set("user_player", 100)
1763
1764 volume_set.assert_awaited_once_with(50)
1765
1766 @pytest.mark.asyncio
1767 async def test_external_control_without_volume_support_raises(
1768 self, controller: PlayerController, mock_mass: MagicMock
1769 ) -> None:
1770 """A volume command redirected to a control lacking volume support is rejected."""
1771 mock_mass.config.get_raw_player_config_value = MagicMock(
1772 side_effect=_player_config_stub(max_volume=50)
1773 )
1774
1775 volume_set = AsyncMock()
1776 control = PlayerControl(
1777 id="ext_control",
1778 provider="test",
1779 name="External Amp",
1780 supports_volume=False,
1781 volume_set=volume_set,
1782 )
1783 user = self._volume_player("user_player", "ext_control")
1784 players = {"user_player": user}
1785
1786 with (
1787 patch.object(controller, "get_player", side_effect=players.get),
1788 patch.object(controller, "_get_active_audio_source", return_value=None),
1789 ):
1790 controller._controls = {"ext_control": control}
1791 with pytest.raises(UnsupportedFeaturedException):
1792 await controller._handle_cmd_volume_set("user_player", 100)
1793
1794 volume_set.assert_not_awaited()
1795
1796
1797class TestExternalPowerControl:
1798 """Power commands redirected to an external PlayerControl must forward and gate correctly."""
1799
1800 def _make_player(
1801 self, mock_mass: MagicMock, control: PlayerControl
1802 ) -> tuple[PlayerController, MockPlayer]:
1803 """Build a controller with a single player whose power control is the given control."""
1804 mock_mass.config.get_raw_player_config_value = MagicMock(
1805 side_effect=_player_config_stub({CONF_POWER_CONTROL: control.id})
1806 )
1807 controller = PlayerController(mock_mass)
1808 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1809 player = MockPlayer(provider, "player_1", "Player 1")
1810 controller._controls = {control.id: control}
1811 controller._players = {"player_1": player}
1812 mock_mass.players = controller
1813 # auto-play would otherwise resume the (mocked) player queue on power on
1814 config_get_value = player.config.get_value
1815 player.config.get_value = MagicMock( # type: ignore[method-assign]
1816 side_effect=lambda key, *args, **kwargs: (
1817 False if key == CONF_AUTO_PLAY else config_get_value(key, *args, **kwargs)
1818 )
1819 )
1820 player.set_initialized()
1821 player.update_state(signal_event=False)
1822 return controller, player
1823
1824 async def test_power_on_forwards_to_control(self, mock_mass: MagicMock) -> None:
1825 """Powering on a player redirects to its external control's power_on callback."""
1826 power_on = AsyncMock()
1827 power_off = AsyncMock()
1828 control = PlayerControl(
1829 id="ext_power",
1830 provider="test",
1831 name="External Power",
1832 supports_power=True,
1833 power_on=power_on,
1834 power_off=power_off,
1835 )
1836
1837 def _report_powered_on() -> None:
1838 control.power_state = True
1839
1840 # the control only reports on once switched on, which releases wait_for_power_on
1841 power_on.side_effect = _report_powered_on
1842 controller, player = self._make_player(mock_mass, control)
1843 assert player.state.powered is False
1844
1845 await controller._handle_cmd_power("player_1", True)
1846
1847 power_on.assert_awaited_once()
1848 power_off.assert_not_awaited()
1849
1850 async def test_power_on_waits_on_the_control(self, mock_mass: MagicMock) -> None:
1851 """Powering on waits for the control to report on, not for the player itself."""
1852 control = PlayerControl(
1853 id="ext_power",
1854 provider="test",
1855 name="External Power",
1856 supports_power=True,
1857 power_on=AsyncMock(),
1858 power_off=AsyncMock(),
1859 )
1860 controller, player = self._make_player(mock_mass, control)
1861 assert player.state.powered is False
1862
1863 with patch(
1864 "music_assistant.controllers.players.controller.wait_for_power_on", AsyncMock()
1865 ) as wait_for_power_on:
1866 await controller._handle_cmd_power("player_1", True)
1867
1868 wait_for_power_on.assert_awaited_once()
1869 assert wait_for_power_on.await_args is not None
1870 assert wait_for_power_on.await_args.args[2] is control
1871
1872 async def test_power_off_forwards_to_control(self, mock_mass: MagicMock) -> None:
1873 """Powering off a player redirects to its external control's power_off callback."""
1874 power_on = AsyncMock()
1875 power_off = AsyncMock()
1876 control = PlayerControl(
1877 id="ext_power",
1878 provider="test",
1879 name="External Power",
1880 supports_power=True,
1881 power_state=True,
1882 power_on=power_on,
1883 power_off=power_off,
1884 )
1885 controller, player = self._make_player(mock_mass, control)
1886 assert player.state.powered is True
1887
1888 await controller._handle_cmd_power("player_1", False)
1889
1890 power_off.assert_awaited_once()
1891 power_on.assert_not_awaited()
1892
1893 async def test_control_without_power_support_raises(self, mock_mass: MagicMock) -> None:
1894 """A power command redirected to a control lacking power support is rejected."""
1895 power_on = AsyncMock()
1896 power_off = AsyncMock()
1897 control = PlayerControl(
1898 id="ext_power",
1899 provider="test",
1900 name="External Power",
1901 supports_power=False,
1902 power_on=power_on,
1903 power_off=power_off,
1904 )
1905 controller, player = self._make_player(mock_mass, control)
1906 assert player.state.powered is False
1907
1908 with pytest.raises(UnsupportedFeaturedException):
1909 await controller._handle_cmd_power("player_1", True)
1910
1911 power_on.assert_not_awaited()
1912 power_off.assert_not_awaited()
1913
1914
1915class TestEnforceVolumeLimits:
1916 """External volume changes outside the min/max range must be corrected."""
1917
1918 @staticmethod
1919 def _set_limits(mock_mass: MagicMock, min_volume: int, max_volume: int) -> None:
1920 mock_mass.config.get_raw_player_config_value = MagicMock(
1921 side_effect=_player_config_stub(min_volume=min_volume, max_volume=max_volume)
1922 )
1923
1924 @staticmethod
1925 def _player(logical_volume: int | None) -> SimpleNamespace:
1926 return SimpleNamespace(
1927 player_id="user_player",
1928 state=SimpleNamespace(volume_level=logical_volume),
1929 )
1930
1931 def test_out_of_range_volume_is_corrected(
1932 self, controller: PlayerController, mock_mass: MagicMock
1933 ) -> None:
1934 """A device volume above max_volume (logical > 100) is clamped back to logical 100."""
1935 self._set_limits(mock_mass, 0, 80)
1936 # device volume 100 with max 80 resolves to logical 125
1937 player = self._player(125)
1938 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1939 controller._enforce_volume_limits(cast("MockPlayer", player))
1940 cmd.assert_called_once_with("user_player", 100)
1941 mock_mass.create_task.assert_called_once()
1942
1943 def test_below_min_volume_is_corrected(
1944 self, controller: PlayerController, mock_mass: MagicMock
1945 ) -> None:
1946 """A device volume below min_volume (logical < 0) is clamped back to logical 0."""
1947 self._set_limits(mock_mass, 20, 100)
1948 # device volume 10 with min 20 resolves to a negative logical volume
1949 player = self._player(-13)
1950 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1951 controller._enforce_volume_limits(cast("MockPlayer", player))
1952 cmd.assert_called_once_with("user_player", 0)
1953
1954 def test_in_range_volume_is_untouched(
1955 self, controller: PlayerController, mock_mass: MagicMock
1956 ) -> None:
1957 """A logical volume within 0-100 needs no correction."""
1958 self._set_limits(mock_mass, 0, 80)
1959 player = self._player(100)
1960 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1961 controller._enforce_volume_limits(cast("MockPlayer", player))
1962 cmd.assert_not_called()
1963
1964 def test_no_limits_configured_is_noop(
1965 self, controller: PlayerController, mock_mass: MagicMock
1966 ) -> None:
1967 """Default 0-100 limits skip enforcement entirely."""
1968 self._set_limits(mock_mass, 0, 100)
1969 player = self._player(100)
1970 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1971 controller._enforce_volume_limits(cast("MockPlayer", player))
1972 cmd.assert_not_called()
1973
1974 def test_unknown_volume_is_noop(
1975 self, controller: PlayerController, mock_mass: MagicMock
1976 ) -> None:
1977 """A player without a resolved volume level is left alone."""
1978 self._set_limits(mock_mass, 0, 80)
1979 player = self._player(None)
1980 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1981 controller._enforce_volume_limits(cast("MockPlayer", player))
1982 cmd.assert_not_called()
1983
1984
1985class TestFakeMuteControl:
1986 """Fake mute must report the muted state and restore the volume on unmute."""
1987
1988 def _make_player(
1989 self, mock_mass: MagicMock, volume_level: int | None = 40
1990 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
1991 """
1992 Build a controller with a single player using fake mute control.
1993
1994 :param mock_mass: the mocked MusicAssistant instance.
1995 :param volume_level: initial volume level of the player, None if unknown.
1996 """
1997 mock_mass.config.get_raw_player_config_value = MagicMock(
1998 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
1999 )
2000 controller = PlayerController(mock_mass)
2001 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2002 player = MockPlayer(provider, "player_1", "Player 1")
2003 controller._players = {"player_1": player}
2004 mock_mass.players = controller
2005 mock_mass.player_queues.get = MagicMock(return_value=None)
2006 player.set_initialized()
2007 player._attr_volume_level = volume_level
2008 # let the mocked native volume control behave like a real device
2009 volume_set = AsyncMock(
2010 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2011 )
2012 player.volume_set = volume_set # type: ignore[method-assign]
2013 player.update_state(signal_event=False)
2014 return controller, player, volume_set
2015
2016 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
2017 """Muting reports volume_muted=True and unmuting restores the previous volume."""
2018 controller, player, volume_set = self._make_player(mock_mass)
2019
2020 await controller.cmd_volume_mute("player_1", True)
2021 muted_state = player.state
2022 assert muted_state.volume_muted is True
2023 assert muted_state.volume_level == 0
2024 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2025
2026 await controller.cmd_volume_mute("player_1", False)
2027 volume_set.assert_awaited_with(40)
2028 # simulate the device reporting back its state after the volume command
2029 player.update_state()
2030 unmuted_state = player.state
2031 assert unmuted_state.volume_muted is False
2032 assert unmuted_state.volume_level == 40
2033
2034 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
2035 """A repeated mute command must not overwrite the stored volume with 0."""
2036 controller, player, volume_set = self._make_player(mock_mass)
2037
2038 await controller.cmd_volume_mute("player_1", True)
2039 await controller.cmd_volume_mute("player_1", True)
2040 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2041 assert player.state.volume_muted is True
2042
2043 await controller.cmd_volume_mute("player_1", False)
2044 volume_set.assert_awaited_with(40)
2045
2046 async def test_unmute_with_unknown_previous_volume(self, mock_mass: MagicMock) -> None:
2047 """Unmuting a player whose volume was unknown at mute time restores a low volume."""
2048 controller, player, volume_set = self._make_player(mock_mass, volume_level=None)
2049
2050 await controller.cmd_volume_mute("player_1", True)
2051 assert player.extra_data[ATTR_PREVIOUS_VOLUME] is None
2052
2053 await controller.cmd_volume_mute("player_1", False)
2054 volume_set.assert_awaited_with(1)
2055 player.update_state()
2056 assert player.state.volume_muted is False
2057
2058 async def test_unmute_of_unmuted_player_keeps_volume(self, mock_mass: MagicMock) -> None:
2059 """An unmute command for a player that is not muted may not touch the volume."""
2060 controller, player, volume_set = self._make_player(mock_mass, volume_level=50)
2061
2062 await controller.cmd_volume_mute("player_1", False)
2063 volume_set.assert_not_awaited()
2064 assert player.state.volume_level == 50
2065
2066 async def test_unmute_restores_a_stored_zero_volume(self, mock_mass: MagicMock) -> None:
2067 """A player that was already silent stays silent after mute and unmute."""
2068 controller, _player, volume_set = self._make_player(mock_mass, volume_level=0)
2069
2070 await controller.cmd_volume_mute("player_1", True)
2071 await controller.cmd_volume_mute("player_1", False)
2072 volume_set.assert_awaited_with(0)
2073
2074 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
2075 """A regular volume change while fake muted implies an unmute."""
2076 controller, player, _volume_set = self._make_player(mock_mass)
2077
2078 await controller.cmd_volume_mute("player_1", True)
2079 muted_state = player.state
2080 assert muted_state.volume_muted is True
2081
2082 await controller.cmd_volume_set("player_1", 25)
2083 # simulate the device reporting back its state after the volume command
2084 player.update_state()
2085 unmuted_state = player.state
2086 assert unmuted_state.volume_muted is False
2087 assert unmuted_state.volume_level == 25
2088
2089
2090class TestVolumeStep:
2091 """The volume_step core config setting controls the size of a single volume nudge."""
2092
2093 def _make_player(
2094 self, mock_mass: MagicMock, step: int | None, volume_level: int
2095 ) -> tuple[PlayerController, MockPlayer]:
2096 """Build a controller with a single player and the given volume_step config."""
2097 controller = PlayerController(mock_mass)
2098 controller.config = _volume_step_config(step)
2099 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2100 player = MockPlayer(provider, "player_1", "Player 1")
2101 player._attr_volume_level = volume_level
2102 # let the mocked native volume control behave like a real device
2103 player.volume_set = AsyncMock( # type: ignore[method-assign]
2104 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2105 )
2106 controller._players = {"player_1": player}
2107 mock_mass.players = controller
2108 mock_mass.player_queues.get = MagicMock(return_value=None)
2109 player.set_initialized()
2110 player.update_state(signal_event=False)
2111 return controller, player
2112
2113 def _make_synced_pair(
2114 self, mock_mass: MagicMock, step: int | None
2115 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2116 """Build a leader synced to one member, both at volume 50, with a volume_step config."""
2117 controller = PlayerController(mock_mass)
2118 controller.config = _volume_step_config(step)
2119 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2120 players: dict[str, MockPlayer] = {}
2121 for player_id in ("leader", "member"):
2122 player = MockPlayer(provider, player_id, player_id.title())
2123 player._attr_volume_level = 50
2124 # let the mocked native volume control behave like a real device
2125 player.volume_set = AsyncMock( # type: ignore[method-assign]
2126 side_effect=lambda volume, _player=player: setattr(
2127 _player, "_attr_volume_level", volume
2128 )
2129 )
2130 players[player_id] = player
2131 players["leader"]._attr_group_members = ["member"]
2132 controller._players = dict(players)
2133 mock_mass.players = controller
2134 mock_mass.player_queues.get = MagicMock(return_value=None)
2135 for player in players.values():
2136 player.set_initialized()
2137 player._cache.clear()
2138 player.update_state(signal_event=False)
2139 # a second, forced pass: update_state() only recalculates when a player's own
2140 # attributes changed, so the leader must be forced to re-derive its group_volume
2141 # from the now-initialized member.
2142 for player in players.values():
2143 player.update_state(force_update=True, signal_event=False)
2144 return controller, players
2145
2146 @pytest.mark.parametrize(
2147 ("start", "expected"),
2148 [(5, 6), (20, 22), (50, 53), (80, 82), (95, 96)],
2149 )
2150 async def test_default_step_up_matches_the_adaptive_ladder(
2151 self, mock_mass: MagicMock, start: int, expected: int
2152 ) -> None:
2153 """With volume_step at its default (0), volume_up keeps today's adaptive ladder."""
2154 controller, player = self._make_player(mock_mass, None, start)
2155
2156 await controller.cmd_volume_up("player_1")
2157
2158 player.update_state()
2159 assert player.state.volume_level == expected
2160
2161 @pytest.mark.parametrize(
2162 ("start", "expected"),
2163 [(5, 4), (20, 18), (50, 47), (80, 78), (95, 94)],
2164 )
2165 async def test_default_step_down_matches_the_adaptive_ladder(
2166 self, mock_mass: MagicMock, start: int, expected: int
2167 ) -> None:
2168 """With volume_step at its default (0), volume_down keeps today's adaptive ladder."""
2169 controller, player = self._make_player(mock_mass, None, start)
2170
2171 await controller.cmd_volume_down("player_1")
2172
2173 player.update_state()
2174 assert player.state.volume_level == expected
2175
2176 async def test_configured_step_moves_up_by_a_flat_amount_mid_range(
2177 self, mock_mass: MagicMock
2178 ) -> None:
2179 """A configured flat step of 5 moves by exactly 5 in the middle of the range."""
2180 controller, player = self._make_player(mock_mass, 5, 50)
2181
2182 await controller.cmd_volume_up("player_1")
2183
2184 player.update_state()
2185 assert player.state.volume_level == 55
2186
2187 async def test_configured_step_moves_up_by_a_flat_amount_near_the_extreme(
2188 self, mock_mass: MagicMock
2189 ) -> None:
2190 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2191 controller, player = self._make_player(mock_mass, 5, 5)
2192
2193 await controller.cmd_volume_up("player_1")
2194
2195 player.update_state()
2196 assert player.state.volume_level == 10
2197
2198 async def test_configured_step_moves_down_by_a_flat_amount_mid_range(
2199 self, mock_mass: MagicMock
2200 ) -> None:
2201 """A configured flat step of 5 moves down by exactly 5 in the middle of the range."""
2202 controller, player = self._make_player(mock_mass, 5, 50)
2203
2204 await controller.cmd_volume_down("player_1")
2205
2206 player.update_state()
2207 assert player.state.volume_level == 45
2208
2209 async def test_configured_step_moves_down_by_a_flat_amount_near_the_extreme(
2210 self, mock_mass: MagicMock
2211 ) -> None:
2212 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2213 controller, player = self._make_player(mock_mass, 5, 95)
2214
2215 await controller.cmd_volume_down("player_1")
2216
2217 player.update_state()
2218 assert player.state.volume_level == 90
2219
2220 async def test_large_configured_step_clamps_up_at_the_maximum(
2221 self, mock_mass: MagicMock
2222 ) -> None:
2223 """A large configured step clamps volume_up at 100."""
2224 controller, player = self._make_player(mock_mass, 10, 95)
2225
2226 await controller.cmd_volume_up("player_1")
2227
2228 player.update_state()
2229 assert player.state.volume_level == 100
2230
2231 async def test_large_configured_step_clamps_down_at_zero(self, mock_mass: MagicMock) -> None:
2232 """A large configured step clamps volume_down at 0."""
2233 controller, player = self._make_player(mock_mass, 10, 5)
2234
2235 await controller.cmd_volume_down("player_1")
2236
2237 player.update_state()
2238 assert player.state.volume_level == 0
2239
2240 async def test_group_volume_up_with_default_step_uses_the_ladder(
2241 self, mock_mass: MagicMock
2242 ) -> None:
2243 """cmd_group_volume_up honours the default (0) adaptive ladder too."""
2244 controller, players = self._make_synced_pair(mock_mass, None)
2245
2246 await controller.cmd_group_volume_up("leader")
2247
2248 for player in players.values():
2249 player.update_state()
2250 assert player.state.volume_level == 53
2251
2252 async def test_group_volume_down_with_default_step_uses_the_ladder(
2253 self, mock_mass: MagicMock
2254 ) -> None:
2255 """cmd_group_volume_down honours the default (0) adaptive ladder too."""
2256 controller, players = self._make_synced_pair(mock_mass, None)
2257
2258 await controller.cmd_group_volume_down("leader")
2259
2260 for player in players.values():
2261 player.update_state()
2262 assert player.state.volume_level == 47
2263
2264 async def test_group_volume_up_with_configured_step(self, mock_mass: MagicMock) -> None:
2265 """cmd_group_volume_up honours a configured flat step."""
2266 controller, players = self._make_synced_pair(mock_mass, 5)
2267
2268 await controller.cmd_group_volume_up("leader")
2269
2270 for player in players.values():
2271 player.update_state()
2272 assert player.state.volume_level == 55
2273
2274 async def test_group_volume_down_with_configured_step(self, mock_mass: MagicMock) -> None:
2275 """cmd_group_volume_down honours a configured flat step."""
2276 controller, players = self._make_synced_pair(mock_mass, 5)
2277
2278 await controller.cmd_group_volume_down("leader")
2279
2280 for player in players.values():
2281 player.update_state()
2282 assert player.state.volume_level == 45
2283
2284 async def test_config_entry_exposes_default_and_range(
2285 self, controller: PlayerController
2286 ) -> None:
2287 """get_config_entries returns the volume_step entry with its default and range."""
2288 entries = await controller.get_config_entries()
2289
2290 entry = next(entry for entry in entries if entry.key == CONF_VOLUME_STEP)
2291 assert entry.type == ConfigEntryType.INTEGER
2292 assert entry.default_value == 0
2293 assert entry.range == (0, 10)
2294
2295
2296class TestFakeMuteInGroup:
2297 """A fake muted player in a group follows the mute lock, just like a native mute."""
2298
2299 def _make_synced_pair(
2300 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
2301 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2302 """
2303 Build a leader synced to one member.
2304
2305 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
2306 """
2307
2308 def _conf(player_id: str, key: str, default: object = None) -> object:
2309 if key == CONF_MUTE_CONTROL and player_id == "member":
2310 return member_mute_control
2311 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
2312 player_id, key, default
2313 )
2314
2315 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2316 controller = PlayerController(mock_mass)
2317 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2318 players: dict[str, MockPlayer] = {}
2319 for player_id in ("leader", "member"):
2320 player = MockPlayer(provider, player_id, player_id.title())
2321 player._attr_supported_features = {
2322 PlayerFeature.VOLUME_SET,
2323 PlayerFeature.VOLUME_MUTE,
2324 }
2325 player._attr_volume_level = 50
2326 # let the mocked native volume control behave like a real device
2327 player.volume_set = AsyncMock( # type: ignore[method-assign]
2328 side_effect=lambda volume, _player=player: setattr(
2329 _player, "_attr_volume_level", volume
2330 )
2331 )
2332 players[player_id] = player
2333 players["leader"]._attr_group_members = ["member"]
2334 controller._players = dict(players)
2335 mock_mass.players = controller
2336 mock_mass.player_queues.get = MagicMock(return_value=None)
2337 for player in players.values():
2338 player.set_initialized()
2339 player._cache.clear()
2340 player.update_state(signal_event=False)
2341 # a second pass, so the group volume of the leader accounts for its member
2342 for player in players.values():
2343 player.update_state(signal_event=False)
2344 return controller, players
2345
2346 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
2347 """A group volume change may not bring a muted fake mute pair back to life."""
2348 controller, players = self._make_synced_pair(mock_mass)
2349 await controller.cmd_group_volume_mute("leader", True)
2350
2351 await controller.cmd_group_volume("leader", 30)
2352
2353 for player in players.values():
2354 player.update_state()
2355 assert player.state.volume_muted is True
2356 assert player.state.volume_level == 0
2357
2358 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
2359 """Turning a group down leaves a single muted member silent, at its own volume."""
2360 controller, players = self._make_synced_pair(mock_mass)
2361 await controller.cmd_volume_mute("member", True)
2362 # let the group volume of the leader account for the muted member
2363 players["leader"].update_state(signal_event=False)
2364
2365 await controller.cmd_group_volume("leader", 25)
2366
2367 for player in players.values():
2368 player.update_state()
2369 member_state = players["member"].state
2370 assert member_state.volume_muted is True
2371 assert member_state.volume_level == 0
2372 # the player that is not muted follows the group volume as usual
2373 assert players["leader"].state.volume_level == 25
2374 # unmuting brings the member back at the volume it had before it was muted
2375 await controller.cmd_volume_mute("member", False)
2376 players["member"].update_state()
2377 assert players["member"].state.volume_level == 50
2378
2379 async def test_unmute_restores_the_volume_from_before_the_mute(
2380 self, mock_mass: MagicMock
2381 ) -> None:
2382 """A group volume change while muted may not alter the volume to restore."""
2383 controller, players = self._make_synced_pair(mock_mass)
2384 await controller.cmd_group_volume_mute("leader", True)
2385 await controller.cmd_group_volume("leader", 30)
2386
2387 await controller.cmd_group_volume_mute("leader", False)
2388
2389 for player in players.values():
2390 player.update_state()
2391 assert player.state.volume_muted is False
2392 assert player.state.volume_level == 50
2393
2394 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
2395 """Members with a different mute control stay muted alike on a group volume change."""
2396 controller, players = self._make_synced_pair(
2397 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
2398 )
2399 mute = AsyncMock(
2400 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
2401 )
2402 players["member"].volume_mute = mute # type: ignore[method-assign]
2403 await controller.cmd_group_volume_mute("leader", True)
2404
2405 await controller.cmd_group_volume("leader", 30)
2406
2407 mute.assert_awaited_once_with(True)
2408 for player in players.values():
2409 player.update_state()
2410 assert player.state.volume_muted is True
2411
2412
2413class TestMuteControlGuard:
2414 """Muting is gated on the mute control, independently of the volume control."""
2415
2416 def _make_player(
2417 self,
2418 mock_mass: MagicMock,
2419 mute_control: str,
2420 volume_control: str,
2421 controls: dict[str, PlayerControl] | None = None,
2422 ) -> tuple[PlayerController, MockPlayer]:
2423 """Build a controller with a single player using the given control config."""
2424 mock_mass.config.get_raw_player_config_value = MagicMock(
2425 side_effect=_player_config_stub(
2426 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
2427 )
2428 )
2429 controller = PlayerController(mock_mass)
2430 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2431 player = MockPlayer(provider, "player_1", "Player 1")
2432 controller._players = {"player_1": player}
2433 controller._controls = controls or {}
2434 mock_mass.players = controller
2435 mock_mass.player_queues.get = MagicMock(return_value=None)
2436 player.set_initialized()
2437 player.update_state(signal_event=False)
2438 return controller, player
2439
2440 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
2441 """A player without volume control still mutes through an external PlayerControl."""
2442 mute_set = AsyncMock()
2443 control = PlayerControl(
2444 id="ext_mute",
2445 provider="test",
2446 name="External Mute",
2447 supports_mute=True,
2448 mute_set=mute_set,
2449 )
2450 controller, player = self._make_player(
2451 mock_mass,
2452 mute_control="ext_mute",
2453 volume_control=PLAYER_CONTROL_NONE,
2454 controls={"ext_mute": control},
2455 )
2456 assert player.mute_control == "ext_mute"
2457
2458 await controller.cmd_volume_mute("player_1", True)
2459 mute_set.assert_awaited_once_with(True)
2460
2461 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
2462 """A player without volume control still mutes natively."""
2463 controller, player = self._make_player(
2464 mock_mass,
2465 mute_control=PLAYER_CONTROL_NATIVE,
2466 volume_control=PLAYER_CONTROL_NONE,
2467 )
2468 volume_mute = AsyncMock()
2469 player.volume_mute = volume_mute # type: ignore[method-assign]
2470
2471 await controller.cmd_volume_mute("player_1", True)
2472 volume_mute.assert_awaited_once_with(True)
2473
2474 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
2475 """A player with volume control but no mute control rejects the command."""
2476 controller, player = self._make_player(
2477 mock_mass,
2478 mute_control=PLAYER_CONTROL_NONE,
2479 volume_control=PLAYER_CONTROL_NATIVE,
2480 )
2481 volume_mute = AsyncMock()
2482 player.volume_mute = volume_mute # type: ignore[method-assign]
2483
2484 with pytest.raises(UnsupportedFeaturedException):
2485 await controller.cmd_volume_mute("player_1", True)
2486 volume_mute.assert_not_awaited()
2487
2488 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
2489 """Fake mute needs a volume control to drive, so it rejects the command outright."""
2490 controller, player = self._make_player(
2491 mock_mass,
2492 mute_control=PLAYER_CONTROL_FAKE,
2493 volume_control=PLAYER_CONTROL_NONE,
2494 )
2495 player._attr_volume_level = 40
2496
2497 with pytest.raises(UnsupportedFeaturedException):
2498 await controller.cmd_volume_mute("player_1", True)
2499 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
2500 assert ATTR_FAKE_MUTE not in player.extra_data
2501
2502 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
2503 """A mute control that disappeared after being resolved is reported, not ignored."""
2504 control = PlayerControl(
2505 id="ext_mute",
2506 provider="test",
2507 name="External Mute",
2508 supports_mute=True,
2509 mute_set=AsyncMock(),
2510 )
2511 controller, player = self._make_player(
2512 mock_mass,
2513 mute_control="ext_mute",
2514 volume_control=PLAYER_CONTROL_NONE,
2515 controls={"ext_mute": control},
2516 )
2517 # the resolved control is cached on the player, so removing it here leaves
2518 # the player pointing at a control that no longer exists
2519 assert player.mute_control == "ext_mute"
2520 controller._controls = {}
2521
2522 with pytest.raises(UnsupportedFeaturedException):
2523 await controller.cmd_volume_mute("player_1", True)
2524
2525 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
2526 """Unmuting clears a mute lock left behind by a since-removed mute control."""
2527 controller, player = self._make_player(
2528 mock_mass,
2529 mute_control=PLAYER_CONTROL_NONE,
2530 volume_control=PLAYER_CONTROL_NATIVE,
2531 )
2532 player.extra_data[ATTR_MUTE_LOCK] = True
2533
2534 with pytest.raises(UnsupportedFeaturedException):
2535 await controller.cmd_volume_mute("player_1", False)
2536 assert ATTR_MUTE_LOCK not in player.extra_data
2537
2538 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
2539 """A grouped player whose mute command failed is not left holding a mute lock."""
2540 control = PlayerControl(
2541 id="ext_mute",
2542 provider="test",
2543 name="External Mute",
2544 supports_mute=False,
2545 )
2546 controller, player = self._make_player(
2547 mock_mass,
2548 mute_control="ext_mute",
2549 volume_control=PLAYER_CONTROL_NONE,
2550 controls={"ext_mute": control},
2551 )
2552 player.state.synced_to = "leader"
2553
2554 with pytest.raises(UnsupportedFeaturedException):
2555 await controller.cmd_volume_mute("player_1", True)
2556 assert ATTR_MUTE_LOCK not in player.extra_data
2557
2558 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
2559 """A failed mute leaves the lock of an earlier successful mute in place."""
2560 control = PlayerControl(
2561 id="ext_mute",
2562 provider="test",
2563 name="External Mute",
2564 supports_mute=False,
2565 )
2566 controller, player = self._make_player(
2567 mock_mass,
2568 mute_control="ext_mute",
2569 volume_control=PLAYER_CONTROL_NONE,
2570 controls={"ext_mute": control},
2571 )
2572 player.state.synced_to = "leader"
2573 player.extra_data[ATTR_MUTE_LOCK] = True
2574
2575 with pytest.raises(UnsupportedFeaturedException):
2576 await controller.cmd_volume_mute("player_1", True)
2577 assert player.extra_data[ATTR_MUTE_LOCK] is True
2578
2579
2580class TestGroupMuteMemberFilter:
2581 """Group mute skips members that have no mute control of their own."""
2582
2583 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
2584 """A member without a mute control must not fail the whole group command."""
2585 controller = PlayerController(mock_mass)
2586 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2587 leader = MockPlayer(provider, "leader", "Leader")
2588 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2589 leader._attr_group_members = ["leader", "member"]
2590 member = MockPlayer(provider, "member", "Member")
2591 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
2592 controller._players = {"leader": leader, "member": member}
2593 mock_mass.players = controller
2594 mock_mass.player_queues.get = MagicMock(return_value=None)
2595 for player in (leader, member):
2596 player.set_initialized()
2597 player.update_state(signal_event=False)
2598 leader_mute = AsyncMock()
2599 leader.volume_mute = leader_mute # type: ignore[method-assign]
2600
2601 await controller.cmd_group_volume_mute("leader", True)
2602 leader_mute.assert_awaited_once_with(True)
2603
2604
2605class TestGroupPlayerMuteRedirect:
2606 """A mute command on a group player is handled at group level."""
2607
2608 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
2609 """Build a controller with a group player holding a single mute capable member."""
2610 controller = PlayerController(mock_mass)
2611 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2612 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
2613 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2614 group._attr_group_members = ["member"]
2615 member = MockPlayer(provider, "member", "Member")
2616 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2617 controller._players = {"group": group, "member": member}
2618 mock_mass.players = controller
2619 mock_mass.player_queues.get = MagicMock(return_value=None)
2620 for player in (group, member):
2621 player.set_initialized()
2622 player.update_state(signal_event=False)
2623 return controller, group, member
2624
2625 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
2626 """A group player has no mute of its own, so the members must be muted instead."""
2627 controller, _group, member = self._setup(mock_mass)
2628 member_mute = AsyncMock()
2629 member.volume_mute = member_mute # type: ignore[method-assign]
2630
2631 await controller.cmd_volume_mute("group", True)
2632
2633 member_mute.assert_awaited_once_with(True)
2634
2635 async def test_mute_on_group_player_without_own_mute_control(
2636 self, mock_mass: MagicMock
2637 ) -> None:
2638 """A group that has no mute control of its own must still mute its members."""
2639 controller, group, member = self._setup(mock_mass)
2640 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
2641 group._cache.clear()
2642 group.update_state(signal_event=False)
2643 assert group.mute_control == PLAYER_CONTROL_NONE
2644 member_mute = AsyncMock()
2645 member.volume_mute = member_mute # type: ignore[method-assign]
2646
2647 await controller.cmd_volume_mute("group", True)
2648
2649 member_mute.assert_awaited_once_with(True)
2650
2651 async def test_mute_on_group_player_without_mute_capable_members(
2652 self, mock_mass: MagicMock
2653 ) -> None:
2654 """A group whose members cannot mute must not raise, just like group mute itself."""
2655 controller, _group, member = self._setup(mock_mass)
2656 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
2657 member._cache.clear()
2658 member.update_state(signal_event=False)
2659
2660 await controller.cmd_volume_mute("group", True)
2661
2662
2663class TestGroupMuteOnNonGroupPlayer:
2664 """A group mute command works on any player, just like the group volume command."""
2665
2666 def _setup(
2667 self, mock_mass: MagicMock, *members: str
2668 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2669 """Build a controller with a mute capable leader synced to the given members."""
2670 controller = PlayerController(mock_mass)
2671 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2672 players: dict[str, MockPlayer] = {}
2673 for player_id in ("leader", *members):
2674 player = MockPlayer(provider, player_id, player_id.title())
2675 player._attr_supported_features = {
2676 PlayerFeature.VOLUME_SET,
2677 PlayerFeature.VOLUME_MUTE,
2678 }
2679 player._attr_volume_level = 50
2680 players[player_id] = player
2681 if members:
2682 # the leader is not listed as its own member here, so the tests also cover
2683 # that a sync leader is injected into its own final group_members
2684 players["leader"]._attr_group_members = list(members)
2685 controller._players = dict(players)
2686 mock_mass.players = controller
2687 mock_mass.player_queues.get = MagicMock(return_value=None)
2688 for player in players.values():
2689 player.set_initialized()
2690 player._cache.clear()
2691 player.update_state(signal_event=False)
2692 return controller, players
2693
2694 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
2695 """Replace the native mute command of every given player with a mock."""
2696 mutes: dict[str, AsyncMock] = {}
2697 for player_id, player in players.items():
2698 mutes[player_id] = AsyncMock()
2699 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
2700 return mutes
2701
2702 async def test_group_mute_on_synced_member_redirects_to_leader(
2703 self, mock_mass: MagicMock
2704 ) -> None:
2705 """A member of a sync group mutes the whole group through its sync leader."""
2706 controller, players = self._setup(mock_mass, "member")
2707 assert players["member"].state.synced_to == "leader"
2708 mutes = self._stub_mutes(players)
2709
2710 await controller.cmd_group_volume_mute("member", True)
2711
2712 mutes["leader"].assert_awaited_once_with(True)
2713 mutes["member"].assert_awaited_once_with(True)
2714 assert ATTR_MUTE_LOCK in players["member"].extra_data
2715
2716 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
2717 self, mock_mass: MagicMock
2718 ) -> None:
2719 """A sync leader is part of its own member list, so it must be muted only once."""
2720 controller, players = self._setup(mock_mass, "member")
2721 mutes = self._stub_mutes(players)
2722
2723 await controller.cmd_group_volume_mute("leader", True)
2724
2725 mutes["leader"].assert_awaited_once_with(True)
2726 mutes["member"].assert_awaited_once_with(True)
2727
2728 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
2729 """A player that is not grouped at all is muted as a normal player."""
2730 controller, players = self._setup(mock_mass)
2731 mutes = self._stub_mutes(players)
2732
2733 await controller.cmd_group_volume_mute("leader", True)
2734
2735 mutes["leader"].assert_awaited_once_with(True)
2736
2737 async def test_group_mute_on_plain_player_without_mute_control(
2738 self, mock_mass: MagicMock
2739 ) -> None:
2740 """A plain player that cannot mute reports that, just like a normal mute command."""
2741 controller, players = self._setup(mock_mass)
2742 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
2743 players["leader"]._cache.clear()
2744 players["leader"].update_state(signal_event=False)
2745
2746 with pytest.raises(UnsupportedFeaturedException):
2747 await controller.cmd_group_volume_mute("leader", True)
2748
2749 async def test_group_unmute_on_synced_member_redirects_to_leader(
2750 self, mock_mass: MagicMock
2751 ) -> None:
2752 """Unmuting through a member clears the mute (and mute lock) of every group member."""
2753 controller, players = self._setup(mock_mass, "member")
2754 mutes = self._stub_mutes(players)
2755 players["member"].extra_data[ATTR_MUTE_LOCK] = True
2756
2757 await controller.cmd_group_volume_mute("member", False)
2758
2759 mutes["leader"].assert_awaited_once_with(False)
2760 mutes["member"].assert_awaited_once_with(False)
2761 assert ATTR_MUTE_LOCK not in players["member"].extra_data
2762
2763 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
2764 """A sync leader is as much part of the group as its members, so it is locked too."""
2765 controller, players = self._setup(mock_mass, "member")
2766 self._stub_mutes(players)
2767
2768 await controller.cmd_group_volume_mute("leader", True)
2769
2770 assert ATTR_MUTE_LOCK in players["leader"].extra_data
2771 assert ATTR_MUTE_LOCK in players["member"].extra_data
2772
2773 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
2774 """A group volume change may not half-unmute a muted pair of directly synced players."""
2775 controller, players = self._setup(mock_mass, "member")
2776 self._stub_mutes(players)
2777 await controller.cmd_group_volume_mute("leader", True)
2778 # the mock players do not act on the mute command, so reflect it in their state
2779 for player in players.values():
2780 player._attr_volume_muted = True
2781 player.update_state(signal_event=False)
2782 player.volume_set = AsyncMock() # type: ignore[method-assign]
2783 # re-stub so only the mute commands of the group volume change are counted
2784 mutes = self._stub_mutes(players)
2785
2786 await controller.cmd_group_volume("leader", 30)
2787
2788 for mute in mutes.values():
2789 mute.assert_not_awaited()
2790
2791
2792class TestMuteLockAfterUngroup:
2793 """A mute lock is only honored while the player it belongs to is still grouped."""
2794
2795 def _make_synced_pair(
2796 self, mock_mass: MagicMock, member_mute_control: str
2797 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2798 """
2799 Build a leader with one synced member.
2800
2801 :param member_mute_control: Mute control to configure on both players.
2802 """
2803 mock_mass.config.get_raw_player_config_value = MagicMock(
2804 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
2805 )
2806 controller = PlayerController(mock_mass)
2807 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2808 players: dict[str, MockPlayer] = {}
2809 for player_id in ("leader", "member"):
2810 player = MockPlayer(provider, player_id, player_id.title())
2811 player._attr_supported_features = {
2812 PlayerFeature.VOLUME_SET,
2813 PlayerFeature.VOLUME_MUTE,
2814 }
2815 player._attr_volume_level = 50
2816 player.volume_set = AsyncMock( # type: ignore[method-assign]
2817 side_effect=lambda volume, _player=player: setattr(
2818 _player, "_attr_volume_level", volume
2819 )
2820 )
2821 players[player_id] = player
2822 players["leader"]._attr_group_members = ["member"]
2823 controller._players = dict(players)
2824 mock_mass.players = controller
2825 mock_mass.player_queues.get = MagicMock(return_value=None)
2826 for player in players.values():
2827 player.set_initialized()
2828 player._cache.clear()
2829 player.update_state(signal_event=False)
2830 return controller, players
2831
2832 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
2833 """Drop the sync group, the way a provider side topology change does."""
2834 players["leader"]._attr_group_members = []
2835 for player in players.values():
2836 player.refresh_state(signal_event=False)
2837
2838 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
2839 """A fake muted player is no longer forced silent once its group is gone."""
2840 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
2841 await controller.cmd_volume_mute("member", True)
2842 self._dissolve_group(players)
2843
2844 await controller.cmd_volume_set("member", 70)
2845
2846 players["member"].update_state()
2847 assert players["member"].state.volume_level == 70
2848 assert players["member"].state.volume_muted is False
2849
2850 async def test_natively_muted_player_is_unmuted_again(self, mock_mass: MagicMock) -> None:
2851 """A natively muted player is auto-unmuted by a volume change once its group is gone."""
2852 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
2853 mute = AsyncMock(
2854 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
2855 )
2856 players["member"].volume_mute = mute # type: ignore[method-assign]
2857 await controller.cmd_volume_mute("member", True)
2858 self._dissolve_group(players)
2859
2860 await controller.cmd_volume_set("member", 70)
2861
2862 assert mute.await_args_list == [call(True), call(False)]
2863 players["member"].update_state()
2864 assert players["member"].state.volume_level == 70
2865 assert players["member"].state.volume_muted is False
2866
2867 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
2868 """A muted player that is still grouped stays silent on a volume change."""
2869 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
2870 await controller.cmd_volume_mute("member", True)
2871
2872 await controller.cmd_volume_set("member", 70)
2873
2874 players["member"].update_state()
2875 assert players["member"].state.volume_level == 0
2876 assert players["member"].state.volume_muted is True
2877
2878 async def test_protocol_player_follows_the_lock_of_its_parent(
2879 self, mock_mass: MagicMock
2880 ) -> None:
2881 """A protocol player inherits the lock of the parent it renders for, group and all."""
2882 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
2883 member = players["member"]
2884 protocol_player = MockPlayer(
2885 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
2886 "proto_member",
2887 "Member Protocol",
2888 player_type=PlayerType.PROTOCOL,
2889 )
2890 protocol_player._attr_supported_features = {
2891 PlayerFeature.VOLUME_SET,
2892 PlayerFeature.VOLUME_MUTE,
2893 }
2894 protocol_player._attr_volume_muted = True
2895 protocol_player.set_protocol_parent_id("member")
2896 # an unmute on a protocol player is redirected to the parent it renders for
2897 mute = AsyncMock()
2898 member.volume_mute = mute # type: ignore[method-assign]
2899 protocol_player.volume_mute = AsyncMock() # type: ignore[method-assign]
2900 protocol_player.volume_set = AsyncMock() # type: ignore[method-assign]
2901 controller._players["proto_member"] = protocol_player
2902 member.set_linked_output_protocols(
2903 [
2904 OutputProtocol(
2905 output_protocol_id="proto_member",
2906 name="Sendspin",
2907 protocol_domain="sendspin",
2908 priority=40,
2909 )
2910 ]
2911 )
2912 protocol_player.set_initialized()
2913 protocol_player.update_state(signal_event=False)
2914 member.refresh_state(signal_event=False)
2915 member.extra_data[ATTR_MUTE_LOCK] = True
2916
2917 # a group volume change reaches the protocol player through the internal handler
2918 await controller._handle_cmd_volume_set("proto_member", 70)
2919 mute.assert_not_awaited()
2920
2921 self._dissolve_group(players)
2922 await controller._handle_cmd_volume_set("proto_member", 70)
2923
2924 mute.assert_awaited_once_with(False)
2925
2926
2927class TestCurrentMediaTimeUpdates:
2928 """Playback-position anchor semantics of timing-only state updates."""
2929
2930 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
2931 """Build a controller with a single playing player with a known position anchor."""
2932 controller = PlayerController(mock_mass)
2933 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2934 player = MockPlayer(provider, "player_1", "Player 1")
2935 controller._players = {"player_1": player}
2936 mock_mass.players = controller
2937 # no queue registered: current_media resolves from the player's native media
2938 mock_mass.player_queues.get = MagicMock(return_value=None)
2939 player.set_initialized()
2940 now = time.time()
2941 player._attr_playback_state = PlaybackState.PLAYING
2942 player._attr_elapsed_time = 17
2943 player._attr_elapsed_time_last_updated = now
2944 player.set_current_media(uri="http://test/stream", title="Test")
2945 assert player._attr_current_media is not None
2946 player._attr_current_media.elapsed_time = 17
2947 player._attr_current_media.elapsed_time_last_updated = now
2948 player.update_state(signal_event=False)
2949 # isolate from the unrelated state-forwarding machinery
2950 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
2951 mock_mass.signal_event.reset_mock()
2952 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
2953 return controller, player
2954
2955 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
2956 """Return whether a PLAYER_UPDATED event was signalled."""
2957 return any(
2958 call.args and call.args[0] == EventType.PLAYER_UPDATED
2959 for call in mock_mass.signal_event.call_args_list
2960 )
2961
2962 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
2963 """A regular playback tick (position and anchor advance together) emits nothing."""
2964 _controller, player = self._make_player(mock_mass)
2965 assert player._attr_current_media is not None
2966 assert player._attr_elapsed_time_last_updated is not None
2967
2968 player._attr_elapsed_time = 18
2969 player._attr_elapsed_time_last_updated += 1
2970 player._attr_current_media.elapsed_time = 18
2971 assert player._attr_current_media.elapsed_time_last_updated is not None
2972 player._attr_current_media.elapsed_time_last_updated += 1
2973 player.update_state()
2974
2975 assert not self._player_updated_signalled(mock_mass)
2976 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
2977 # the previous anchor was preserved: steady playback changes nothing
2978 assert player.state.elapsed_time == 17
2979
2980 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
2981 """An anchor-only change (no significant corrected position change) emits nothing."""
2982 _controller, player = self._make_player(mock_mass)
2983 assert player._attr_current_media is not None
2984 assert player._attr_elapsed_time_last_updated is not None
2985
2986 player._attr_elapsed_time_last_updated += 0.5
2987 assert player._attr_current_media.elapsed_time_last_updated is not None
2988 player._attr_current_media.elapsed_time_last_updated += 0.5
2989 player.update_state()
2990
2991 assert not self._player_updated_signalled(mock_mass)
2992 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
2993
2994 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
2995 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
2996 _controller, player = self._make_player(mock_mass)
2997 assert player._attr_current_media is not None
2998
2999 player._attr_current_media.elapsed_time = 61
3000 player._attr_current_media.elapsed_time_last_updated = time.time()
3001 player.update_state()
3002
3003 assert self._player_updated_signalled(mock_mass)
3004 # the adopted anchor is visible to consumers
3005 assert player.state.current_media is not None
3006 assert player.state.current_media.elapsed_time == 61
3007
3008 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
3009 """A player-level corrected-position jump re-bases the queue timing."""
3010 controller, player = self._make_player(mock_mass)
3011
3012 player._attr_elapsed_time = 61
3013 player._attr_elapsed_time_last_updated = time.time()
3014 player.update_state()
3015
3016 # the queue is corrected and a follow-up player update is scheduled
3017 # (which re-anchors current_media onto the corrected queue time),
3018 # but no full player update is emitted for the jump itself
3019 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3020 assert not self._player_updated_signalled(mock_mass)
3021 cast("MagicMock", controller._forward_state_update).assert_called_once()
3022 assert player.state.elapsed_time == 61
3023
3024 def test_simultaneous_player_and_media_jump_emits_immediately(
3025 self, mock_mass: MagicMock
3026 ) -> None:
3027 """A jump reaching player and current_media in one pass corrects the queue and emits."""
3028 _controller, player = self._make_player(mock_mass)
3029 assert player._attr_current_media is not None
3030 now = time.time()
3031
3032 player._attr_elapsed_time = 61
3033 player._attr_elapsed_time_last_updated = now
3034 player._attr_current_media.elapsed_time = 61
3035 player._attr_current_media.elapsed_time_last_updated = now
3036 player.update_state()
3037
3038 # the queue is re-based AND the full update is emitted right away
3039 # (current_media already holds the fresh position in the same pass)
3040 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3041 assert self._player_updated_signalled(mock_mass)
3042
3043
3044class TestPlayAnnouncementCleanup:
3045 """Test announcement data cleanup after play_announcement."""
3046
3047 def _make_player(
3048 self, mock_mass: MagicMock, announcements: dict[str, object]
3049 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
3050 """Create a controller and a player with native announcement support."""
3051 controller = PlayerController(mock_mass)
3052 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3053 player = MockPlayer(provider, "player_1", "Player 1")
3054 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3055 player._cache.clear()
3056 controller._players = {"player_1": player}
3057 mock_mass.players = controller
3058 render = MagicMock()
3059 render.wait_ready = AsyncMock(return_value=True)
3060 render.wait_finished = AsyncMock(return_value=3.0)
3061
3062 # mimic the real renderer: it owns which announcement each player is playing
3063 def _register(player_id: str, announce_data: object) -> MagicMock:
3064 announcements[player_id] = announce_data
3065 return render
3066
3067 async def _unregister(player_id: str, _render: object) -> None:
3068 announcements.pop(player_id, None)
3069
3070 renderer = mock_mass.streams.announcement_renderer
3071 renderer.register = MagicMock(side_effect=_register)
3072 renderer.unregister = AsyncMock(side_effect=_unregister)
3073 mock_mass.streams.get_announcement_url = MagicMock(
3074 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
3075 )
3076 player.update_state(signal_event=False)
3077 return controller, player, render
3078
3079 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
3080 """The registered announcement data is released once playback finished."""
3081 announcements: dict[str, object] = {}
3082 controller, player, _render = self._make_player(mock_mass, announcements)
3083
3084 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
3085 # entry must exist while the announcement is being played/served
3086 assert "player_1" in announcements
3087
3088 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
3089
3090 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3091
3092 player.play_announcement.assert_awaited_once()
3093 assert announcements == {}
3094 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3095
3096 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
3097 """The registered announcement data is released even when playback fails."""
3098 announcements: dict[str, object] = {}
3099 controller, player, _render = self._make_player(mock_mass, announcements)
3100 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
3101
3102 with pytest.raises(PlayerCommandFailed):
3103 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3104
3105 assert announcements == {}
3106 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3107
3108 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
3109 """A native implementation is handed the url as soon as there is audio to serve."""
3110 announcements: dict[str, object] = {}
3111 controller, player, render = self._make_player(mock_mass, announcements)
3112 player.play_announcement = AsyncMock() # type: ignore[method-assign]
3113
3114 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3115
3116 player.play_announcement.assert_awaited_once()
3117 # waiting for the whole clip here would delay the player for a slow source;
3118 # the length is resolved downstream while it plays
3119 render.wait_ready.assert_awaited_once()
3120 render.wait_finished.assert_not_awaited()
3121
3122
3123class TestPlayAnnouncementMessage:
3124 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
3125
3126 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
3127
3128 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
3129 """Create a TTS engine that renders every message to the given path."""
3130 engine = MagicMock()
3131 engine.uid = "tts_plugin/voice"
3132 engine.id = "voice"
3133 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
3134 return engine
3135
3136 def _make_player(
3137 self, mock_mass: MagicMock, announcements: dict[str, object]
3138 ) -> tuple[PlayerController, AsyncMock]:
3139 """Create a controller and a player with native announcement support."""
3140 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
3141 mock_mass, announcements
3142 )
3143 announce = AsyncMock()
3144 player.play_announcement = announce # type: ignore[method-assign]
3145 return controller, announce
3146
3147 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
3148 """A message is rendered by the default engine and announced as the rendered audio."""
3149 announcements: dict[str, object] = {}
3150 controller, announce = self._make_player(mock_mass, announcements)
3151 engine = self._make_engine()
3152
3153 with patch(
3154 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3155 ):
3156 await controller.play_announcement("player_1", message="dinner is ready")
3157
3158 # no language is sent, so the engine speaks in the language it is configured for
3159 engine.provider.get_tts_message.assert_awaited_once_with(
3160 "dinner is ready", language=None, engine_id="voice"
3161 )
3162 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3163 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3164 announce.assert_awaited_once()
3165
3166 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
3167 """A message can name the language to speak it in."""
3168 announcements: dict[str, object] = {}
3169 controller, _announce = self._make_player(mock_mass, announcements)
3170 engine = self._make_engine()
3171
3172 with patch(
3173 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3174 ):
3175 await controller.play_announcement(
3176 "player_1", message="het eten is klaar", language="nl-NL"
3177 )
3178
3179 engine.provider.get_tts_message.assert_awaited_once_with(
3180 "het eten is klaar", language="nl-NL", engine_id="voice"
3181 )
3182
3183 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
3184 """An engine that rejects the language speaks the message in its default voice."""
3185 announcements: dict[str, object] = {}
3186 controller, announce = self._make_player(mock_mass, announcements)
3187 engine = self._make_engine()
3188 engine.provider.get_tts_message = AsyncMock(
3189 side_effect=[
3190 RuntimeError("unsupported language"),
3191 SimpleNamespace(path="http://speech/spoken.mp3"),
3192 ]
3193 )
3194
3195 with patch(
3196 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3197 ):
3198 await controller.play_announcement(
3199 "player_1", message="dinner is ready", language="en-US"
3200 )
3201
3202 first_call, second_call = engine.provider.get_tts_message.await_args_list
3203 assert first_call.kwargs["language"] == "en-US"
3204 assert second_call.kwargs["language"] is None
3205 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3206 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3207 announce.assert_awaited_once()
3208
3209 @pytest.mark.parametrize(
3210 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
3211 )
3212 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
3213 self, mock_mass: MagicMock, error: Exception
3214 ) -> None:
3215 """A timeout or a structured failure is no language rejection, so it is not retried."""
3216 announcements: dict[str, object] = {}
3217 controller, announce = self._make_player(mock_mass, announcements)
3218 engine = self._make_engine()
3219 engine.provider.get_tts_message = AsyncMock(side_effect=error)
3220
3221 with (
3222 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3223 pytest.raises(MusicAssistantError),
3224 ):
3225 await controller.play_announcement("player_1", message="dinner is ready")
3226
3227 engine.provider.get_tts_message.assert_awaited_once()
3228 announce.assert_not_awaited()
3229
3230 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
3231 """A message names the engine to speak it, overriding the configured default."""
3232 announcements: dict[str, object] = {}
3233 controller, _announce = self._make_player(mock_mass, announcements)
3234 engine = self._make_engine()
3235
3236 with (
3237 patch(
3238 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
3239 ) as resolve,
3240 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
3241 ):
3242 await controller.play_announcement(
3243 "player_1", message="hello", tts_engine="tts_plugin/voice"
3244 )
3245
3246 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
3247 select.assert_not_awaited()
3248
3249 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
3250 """A spoken message uses the player's pre-announce setting without sniffing the url."""
3251 announcements: dict[str, object] = {}
3252 controller, _announce = self._make_player(mock_mass, announcements)
3253 engine = self._make_engine()
3254 mock_mass.config.get_raw_player_config_value = MagicMock(
3255 side_effect=lambda _player_id, key, default=None: (
3256 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
3257 )
3258 )
3259
3260 with patch(
3261 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3262 ):
3263 await controller.play_announcement("player_1", message="dinner is ready")
3264
3265 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3266 assert registered["pre_announce"] is True
3267
3268 async def test_the_engine_gets_the_shorter_announcement_timeout(
3269 self, mock_mass: MagicMock
3270 ) -> None:
3271 """The engine is capped well below the background default, it holds the player lock."""
3272 announcements: dict[str, object] = {}
3273 controller, _announce = self._make_player(mock_mass, announcements)
3274 engine = self._make_engine()
3275 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
3276
3277 with (
3278 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3279 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
3280 ):
3281 await controller.play_announcement("player_1", message="hello")
3282
3283 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
3284 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
3285
3286 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
3287 """Naming an engine for a url announcement is rejected instead of silently ignored."""
3288 announcements: dict[str, object] = {}
3289 controller, _announce = self._make_player(mock_mass, announcements)
3290
3291 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
3292 await controller.play_announcement(
3293 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
3294 )
3295
3296 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
3297 """Naming a language for a url announcement is rejected instead of silently ignored."""
3298 announcements: dict[str, object] = {}
3299 controller, _announce = self._make_player(mock_mass, announcements)
3300
3301 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
3302 await controller.play_announcement(
3303 "player_1", url="http://test/clip.mp3", language="nl-NL"
3304 )
3305
3306 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
3307 """An engine that fails to speak the message fails the announcement."""
3308 announcements: dict[str, object] = {}
3309 controller, announce = self._make_player(mock_mass, announcements)
3310 engine = self._make_engine()
3311 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
3312
3313 with (
3314 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3315 pytest.raises(PlayerCommandFailed),
3316 ):
3317 await controller.play_announcement("player_1", message="hello")
3318
3319 announce.assert_not_awaited()
3320 mock_mass.streams.announcement_renderer.register.assert_not_called()
3321
3322 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
3323 """An announcement with neither a url nor a message is rejected."""
3324 announcements: dict[str, object] = {}
3325 controller, _announce = self._make_player(mock_mass, announcements)
3326
3327 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
3328 await controller.play_announcement("player_1")
3329
3330 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
3331 """An announcement carrying both a url and a message is rejected."""
3332 announcements: dict[str, object] = {}
3333 controller, _announce = self._make_player(mock_mass, announcements)
3334
3335 with pytest.raises(PlayerCommandFailed, match="not both"):
3336 await controller.play_announcement(
3337 "player_1", url="http://test/clip.mp3", message="hello"
3338 )
3339
3340 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
3341 """A message naming an engine that does not exist fails instead of using another."""
3342 announcements: dict[str, object] = {}
3343 controller, _announce = self._make_player(mock_mass, announcements)
3344
3345 with (
3346 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
3347 pytest.raises(PlayerCommandFailed, match="is not available"),
3348 ):
3349 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
3350
3351 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
3352 """A message fails clearly when no TTS engine is set up at all."""
3353 announcements: dict[str, object] = {}
3354 controller, _announce = self._make_player(mock_mass, announcements)
3355
3356 with (
3357 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
3358 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
3359 ):
3360 await controller.play_announcement("player_1", message="hello")
3361
3362 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
3363 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
3364 announcements: dict[str, object] = {}
3365 controller, _announce = self._make_player(mock_mass, announcements)
3366 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
3367
3368 with (
3369 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3370 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
3371 ):
3372 await controller.play_announcement("player_1", message="hello")
3373
3374 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
3375 """The message is spoken once and every group member announces the resulting audio."""
3376 announcements: dict[str, object] = {}
3377 use_real_create_task(mock_mass)
3378 controller, _announce = self._make_player(mock_mass, announcements)
3379 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3380 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
3381 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3382 group._attr_group_members = ["player_1"]
3383 group._cache.clear()
3384 controller._players["group_1"] = group
3385 group.update_state(signal_event=False)
3386 engine = self._make_engine()
3387
3388 with patch(
3389 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3390 ):
3391 await controller.play_announcement("group_1", message="dinner is ready")
3392
3393 # rendered once for the group, then handed to the member as plain audio
3394 engine.provider.get_tts_message.assert_awaited_once()
3395 member_call = next(
3396 call_args
3397 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
3398 if call_args.args[0] == "player_1"
3399 )
3400 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
3401
3402
3403class TestNativeAnnouncementRouting:
3404 """Announcement routing respects the player's own support and its active output."""
3405
3406 def _make_player_with_linked_child(
3407 self,
3408 mock_mass: MagicMock,
3409 playback_state: PlaybackState,
3410 *,
3411 parent_supports_announce: bool = False,
3412 active_protocol: str | None = None,
3413 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
3414 """Create a controller, a player, its linked protocol child and the two path mocks."""
3415 controller = PlayerController(mock_mass)
3416 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3417 player = MockPlayer(provider, "player_1", "Player 1")
3418 player._attr_playback_state = playback_state
3419 if parent_supports_announce:
3420 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3421 proto_provider = MockProvider("airplay", mass=mock_mass)
3422 proto = MockPlayer(
3423 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
3424 )
3425 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3426 controller._players = {"player_1": player, "proto_1": proto}
3427 mock_mass.players = controller
3428 player.set_linked_output_protocols(
3429 [
3430 OutputProtocol(
3431 output_protocol_id="proto_1",
3432 name="AirPlay",
3433 protocol_domain="airplay",
3434 priority=40,
3435 )
3436 ]
3437 )
3438 if active_protocol is not None:
3439 player.set_active_output_protocol(active_protocol)
3440 render = MagicMock()
3441 render.wait_ready = AsyncMock(return_value=True)
3442 renderer = mock_mass.streams.announcement_renderer
3443 renderer.register = MagicMock(return_value=render)
3444 renderer.unregister = AsyncMock()
3445 mock_mass.streams.get_announcement_url = MagicMock(
3446 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
3447 )
3448 proto.update_state(signal_event=False)
3449 player.update_state(signal_event=False)
3450 native_path = AsyncMock()
3451 generic_path = AsyncMock()
3452 controller._play_native_announcement = native_path # type: ignore[method-assign]
3453 controller._play_announcement = generic_path # type: ignore[method-assign]
3454 return controller, player, proto, native_path, generic_path
3455
3456 async def test_playing_player_does_not_route_to_an_idle_linked_child(
3457 self, mock_mass: MagicMock
3458 ) -> None:
3459 """
3460 A player rendering through one output must not announce through another.
3461
3462 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
3463 the announcement to the child would seize the device from the native
3464 output, with nothing restoring that playback afterwards.
3465 """
3466 controller, _player, _proto, native_path, generic_path = (
3467 self._make_player_with_linked_child(
3468 mock_mass, PlaybackState.PLAYING, active_protocol="native"
3469 )
3470 )
3471
3472 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3473
3474 native_path.assert_not_awaited()
3475 generic_path.assert_awaited_once()
3476
3477 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
3478 """An idle player announces natively through any capable linked protocol."""
3479 controller, _player, proto, native_path, _generic_path = (
3480 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
3481 )
3482
3483 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3484
3485 native_path.assert_awaited_once()
3486 assert native_path.call_args.args[1] is proto
3487
3488 async def test_active_protocol_child_beats_own_native_support(
3489 self, mock_mass: MagicMock
3490 ) -> None:
3491 """
3492 The output that is actively rendering wins over the player's own support.
3493
3494 E.g. a Sonos playing through its AirPlay child: the announcement rides
3495 the same audio path as the music (mixed into the live stream, in sync
3496 with the rest of a group) instead of a second mechanism firing beside
3497 the playback.
3498 """
3499 controller, _player, proto, native_path, _generic_path = (
3500 self._make_player_with_linked_child(
3501 mock_mass,
3502 PlaybackState.PLAYING,
3503 parent_supports_announce=True,
3504 active_protocol="proto_1",
3505 )
3506 )
3507
3508 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3509
3510 native_path.assert_awaited_once()
3511 assert native_path.call_args.args[1] is proto
3512
3513 async def test_own_native_support_wins_when_playing_natively(
3514 self, mock_mass: MagicMock
3515 ) -> None:
3516 """A player rendering through its own native output announces natively."""
3517 controller, player, _proto, native_path, _generic_path = (
3518 self._make_player_with_linked_child(
3519 mock_mass,
3520 PlaybackState.PLAYING,
3521 parent_supports_announce=True,
3522 active_protocol="native",
3523 )
3524 )
3525
3526 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3527
3528 native_path.assert_awaited_once()
3529 assert native_path.call_args.args[1] is player
3530
3531 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
3532 self, mock_mass: MagicMock
3533 ) -> None:
3534 """Without active playback the player's own announcement support wins."""
3535 controller, player, _proto, native_path, _generic_path = (
3536 self._make_player_with_linked_child(
3537 mock_mass,
3538 PlaybackState.IDLE,
3539 parent_supports_announce=True,
3540 )
3541 )
3542
3543 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3544
3545 native_path.assert_awaited_once()
3546 assert native_path.call_args.args[1] is player
3547
3548
3549@pytest.mark.usefixtures("running_background_tasks")
3550class TestPlayAnnouncementRestore:
3551 """Test the state restore of the default (fallback) announcement implementation."""
3552
3553 def _make_player(
3554 self, mock_mass: MagicMock, prev_media: PlayerMedia
3555 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
3556 """Create a controller and a playing player, returning the patched resume handler."""
3557 controller = PlayerController(mock_mass)
3558 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3559 player = MockPlayer(provider, "player_1", "Player 1")
3560 player._attr_playback_state = PlaybackState.PLAYING
3561 player._attr_current_media = prev_media
3562 player._cache.clear()
3563 controller._players = {"player_1": player}
3564 mock_mass.players = controller
3565 mock_mass.player_queues.get = MagicMock(return_value=None)
3566 resume_mock = AsyncMock()
3567 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
3568 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
3569 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
3570 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
3571 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
3572 player.set_initialized()
3573 player.update_state(signal_event=False)
3574 return controller, player, resume_mock
3575
3576 @staticmethod
3577 def _add_group(
3578 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
3579 ) -> MockPlayer:
3580 """Register a powered group player that holds the given player as its member."""
3581 group = MockPlayer(
3582 cast("MockProvider", player.provider),
3583 "group_1",
3584 "Group 1",
3585 player_type=PlayerType.GROUP,
3586 )
3587 group._attr_powered = True
3588 group._attr_group_members = [player.player_id]
3589 if supports_set_members:
3590 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3591 controller._players[group.player_id] = group
3592 group.set_initialized()
3593 group._cache.clear()
3594 group.update_state(signal_event=False)
3595 # the member has no own input change to trigger a recalculation of its
3596 # (group derived) state, so force it here - just like register() does
3597 player._cache.clear()
3598 player.update_state(force_update=True, signal_event=False)
3599 assert player.state.active_group == group.player_id
3600 return group
3601
3602 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
3603 """Content that was playing before the announcement is resumed afterwards."""
3604 controller, player, resume_mock = self._make_player(
3605 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3606 )
3607
3608 await controller._play_announcement(player, _announcement())
3609
3610 resume_mock.assert_awaited_once()
3611
3612 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
3613 """A player still busy with an earlier announcement has no playback to restore."""
3614 controller, player, resume_mock = self._make_player(
3615 mock_mass,
3616 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
3617 )
3618
3619 await controller._play_announcement(player, _announcement())
3620
3621 resume_mock.assert_not_awaited()
3622
3623 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
3624 """A failing announcement never leaves the player at the raised volume."""
3625 controller, player, _ = self._make_player(
3626 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3627 )
3628 player._attr_volume_level = 20
3629 player._cache.clear()
3630 player.update_state(force_update=True, signal_event=False)
3631 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
3632 volume_mock = AsyncMock()
3633 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
3634 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3635 side_effect=PlayerCommandFailed("player went away")
3636 )
3637
3638 with pytest.raises(PlayerCommandFailed):
3639 await controller._play_announcement(player, _announcement())
3640
3641 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
3642
3643 async def test_zero_announcement_volume_is_applied_and_restored(
3644 self, mock_mass: MagicMock
3645 ) -> None:
3646 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
3647 controller, player, _ = self._make_player(
3648 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3649 )
3650 player._attr_volume_level = 20
3651 player._cache.clear()
3652 player.update_state(force_update=True, signal_event=False)
3653 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
3654 volume_mock = AsyncMock()
3655 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
3656
3657 await controller._play_announcement(player, _announcement())
3658
3659 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
3660
3661 async def test_playback_is_restored_when_duration_is_unknown(
3662 self, mock_mass: MagicMock
3663 ) -> None:
3664 """An announcement of unknown length still hands the player back to its content."""
3665 controller, player, resume_mock = self._make_player(
3666 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3667 )
3668 announcement = _announcement()
3669 announcement.duration = None
3670
3671 # an unknown length waits for the player to report it finished instead of failing
3672 await controller._play_announcement(player, announcement)
3673
3674 resume_mock.assert_awaited_once()
3675
3676 async def test_group_membership_is_restored_when_playback_fails(
3677 self, mock_mass: MagicMock
3678 ) -> None:
3679 """A failing announcement never leaves the player out of its group player."""
3680 controller, player, _ = self._make_player(
3681 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3682 )
3683 group = self._add_group(controller, player, supports_set_members=True)
3684 group.set_members = AsyncMock() # type: ignore[method-assign]
3685 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3686 side_effect=PlayerCommandFailed("player went away")
3687 )
3688
3689 with pytest.raises(PlayerCommandFailed):
3690 await controller._play_announcement(player, _announcement())
3691
3692 assert group.set_members.await_args_list == [
3693 call(player_ids_to_remove=["player_1"]),
3694 call(player_ids_to_add=["player_1"]),
3695 ]
3696
3697 async def test_restore_failure_does_not_mask_the_announcement_error(
3698 self, mock_mass: MagicMock
3699 ) -> None:
3700 """A provider blowing up during the restore must not hide why the announcement failed."""
3701 controller, player, _ = self._make_player(
3702 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3703 )
3704 group = self._add_group(controller, player, supports_set_members=True)
3705 # set_members is a raw provider call: whatever its client library raises comes
3706 # through unwrapped, so the ungroup succeeds and the regroup times out
3707 group.set_members = AsyncMock( # type: ignore[method-assign]
3708 side_effect=[None, TimeoutError("provider timeout")]
3709 )
3710 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3711 side_effect=PlayerCommandFailed("player went away")
3712 )
3713
3714 with pytest.raises(PlayerCommandFailed, match="player went away"):
3715 await controller._play_announcement(player, _announcement())
3716
3717 async def test_group_without_set_members_is_the_one_powered_off(
3718 self, mock_mass: MagicMock
3719 ) -> None:
3720 """A group that can not release members is powered off, not the announcement target."""
3721 controller, player, _ = self._make_player(
3722 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3723 )
3724 self._add_group(controller, player, supports_set_members=False)
3725 power_mock = AsyncMock()
3726 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
3727 play_mock = AsyncMock()
3728 controller.cmd_play = play_mock # type: ignore[method-assign]
3729
3730 await controller._play_announcement(player, _announcement())
3731
3732 # the group is switched off for the announcement and restarted afterwards
3733 power_mock.assert_awaited_once_with("group_1", False)
3734 play_mock.assert_awaited_once_with("group_1")
3735
3736 async def test_idle_player_without_power_control_is_regrouped(
3737 self, mock_mass: MagicMock
3738 ) -> None:
3739 """An idle player that has no power state to restore is still put back in its group."""
3740 controller, player, _ = self._make_player(
3741 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3742 )
3743 player._attr_playback_state = PlaybackState.IDLE
3744 player._attr_powered = None
3745 group = self._add_group(controller, player, supports_set_members=True)
3746 assert player.state.power_control == PLAYER_CONTROL_NONE
3747 group.set_members = AsyncMock() # type: ignore[method-assign]
3748
3749 await controller._play_announcement(player, _announcement())
3750
3751 assert group.set_members.await_args_list == [
3752 call(player_ids_to_remove=["player_1"]),
3753 call(player_ids_to_add=["player_1"]),
3754 ]
3755
3756 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
3757 """A muted player hears the announcement and is muted again afterwards."""
3758 controller, player, _ = self._make_player(
3759 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3760 )
3761 mute_mock = _mute_natively(player)
3762
3763 await controller._play_announcement(player, _announcement())
3764
3765 assert mute_mock.await_args_list == [call(False), call(True)]
3766
3767 async def test_player_without_volume_control_is_still_unmuted(
3768 self, mock_mass: MagicMock
3769 ) -> None:
3770 """A player that can only be muted is unmuted for the announcement all the same."""
3771 mock_mass.config.get_raw_player_config_value = MagicMock(
3772 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
3773 )
3774 controller, player, _ = self._make_player(
3775 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3776 )
3777 mute_mock = _mute_natively(player)
3778 assert player.state.volume_control == PLAYER_CONTROL_NONE
3779
3780 await controller._play_announcement(player, _announcement())
3781
3782 assert mute_mock.await_args_list == [call(False), call(True)]
3783
3784 async def test_mute_is_restored_before_the_player_is_regrouped(
3785 self, mock_mass: MagicMock
3786 ) -> None:
3787 """A player is handed back to its group already muted, holding on to its mute lock."""
3788 controller, player, _ = self._make_player(
3789 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3790 )
3791 group = self._add_group(controller, player, supports_set_members=True)
3792 real_set_members = group.set_members
3793
3794 async def _set_members(**kwargs: list[str]) -> None:
3795 # let the membership really change, so the player is ungrouped while the
3796 # announcement plays - just like it is in production. neither player picks
3797 # the new membership up on its own here, so publish it on both.
3798 await real_set_members(**kwargs)
3799 group.update_state(force_update=True, signal_event=False)
3800 player._cache.clear()
3801 player.update_state(force_update=True, signal_event=False)
3802
3803 set_members = AsyncMock(side_effect=_set_members)
3804 group.set_members = set_members # type: ignore[method-assign]
3805 player.extra_data[ATTR_MUTE_LOCK] = True
3806 recorder = MagicMock()
3807 recorder.attach_mock(_mute_natively(player), "mute")
3808 recorder.attach_mock(set_members, "set_members")
3809
3810 await controller._play_announcement(player, _announcement())
3811
3812 assert recorder.mock_calls == [
3813 call.set_members(player_ids_to_remove=["player_1"]),
3814 call.mute(False),
3815 call.mute(True),
3816 call.set_members(player_ids_to_add=["player_1"]),
3817 ]
3818 # the lock survives the announcement, so the regroup does not unmute the player
3819 assert player.extra_data[ATTR_MUTE_LOCK] is True
3820
3821 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
3822 """A player that was not muted is never sent a mute command."""
3823 controller, player, _ = self._make_player(
3824 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3825 )
3826 mute_mock = _mute_natively(player)
3827 player._attr_volume_muted = False
3828 player._cache.clear()
3829 player.update_state(force_update=True, signal_event=False)
3830 mute_mock.reset_mock()
3831
3832 await controller._play_announcement(player, _announcement())
3833
3834 mute_mock.assert_not_awaited()
3835
3836 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
3837 """A failing announcement never leaves the player unmuted."""
3838 controller, player, _ = self._make_player(
3839 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3840 )
3841 mute_mock = _mute_natively(player)
3842 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3843 side_effect=PlayerCommandFailed("player went away")
3844 )
3845
3846 with pytest.raises(PlayerCommandFailed):
3847 await controller._play_announcement(player, _announcement())
3848
3849 assert mute_mock.await_args_list == [call(False), call(True)]
3850
3851 async def test_muted_sync_group_members_all_hear_the_announcement(
3852 self, mock_mass: MagicMock
3853 ) -> None:
3854 """Every member of a muted sync group is unmuted, keeping its mute lock."""
3855 controller, leader, _ = self._make_player(
3856 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3857 )
3858 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
3859 controller._players["player_2"] = member
3860 member.set_initialized()
3861 leader._attr_group_members = ["player_1", "player_2"]
3862 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
3863 # both members were muted while grouped, so both hold a mute lock
3864 for player in (leader, member):
3865 player.extra_data[ATTR_MUTE_LOCK] = True
3866
3867 await controller._play_announcement(leader, _announcement())
3868
3869 for player_id, mute_mock in mute_mocks.items():
3870 assert mute_mock.await_args_list == [call(False), call(True)], player_id
3871 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
3872
3873 async def test_fake_muted_player_announces_at_its_real_volume(
3874 self, mock_mass: MagicMock
3875 ) -> None:
3876 """A fake muted player announces at its real volume, not at the zero it is parked on."""
3877 mock_mass.config.get_raw_player_config_value = MagicMock(
3878 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
3879 )
3880 controller, player, _ = self._make_player(
3881 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3882 )
3883
3884 def _apply_volume(volume: int) -> None:
3885 player._attr_volume_level = volume
3886 player.update_state(signal_event=False)
3887
3888 player._attr_volume_level = 40
3889 volume_set = AsyncMock(side_effect=_apply_volume)
3890 player.volume_set = volume_set # type: ignore[method-assign]
3891 player._cache.clear()
3892 player.update_state(force_update=True, signal_event=False)
3893 await controller.cmd_volume_mute("player_1", True)
3894 assert player.state.volume_muted is True
3895 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
3896
3897 await controller._play_announcement(player, _announcement())
3898
3899 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
3900 assert volume_set.await_args_list == [
3901 call(0),
3902 call(40),
3903 call(80),
3904 call(40),
3905 call(0),
3906 ]
3907 assert player.state.volume_muted is True
3908 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
3909
3910
3911@pytest.mark.usefixtures("running_background_tasks")
3912class TestPlayNativeAnnouncement:
3913 """Test the mute handling around an announcement that a player plays natively."""
3914
3915 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
3916 """Create a controller and a player with native announcement support."""
3917 controller = PlayerController(mock_mass)
3918 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3919 player = MockPlayer(provider, "player_1", "Player 1")
3920 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3921 player._cache.clear()
3922 controller._players = {"player_1": player}
3923 mock_mass.players = controller
3924 mock_mass.player_queues.get = MagicMock(return_value=None)
3925 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
3926 announce_mock = AsyncMock()
3927 player.play_announcement = announce_mock # type: ignore[method-assign]
3928 player.set_initialized()
3929 player.update_state(signal_event=False)
3930 return controller, player, announce_mock
3931
3932 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
3933 """A muted player hears the announcement and is muted again afterwards."""
3934 controller, player, announce_mock = self._make_player(mock_mass)
3935 recorder = MagicMock()
3936 recorder.attach_mock(_mute_natively(player), "mute")
3937 recorder.attach_mock(announce_mock, "announce")
3938
3939 await controller._play_native_announcement(player, player, _announcement(), None)
3940
3941 assert recorder.mock_calls == [
3942 call.mute(False),
3943 call.announce(ANY, None),
3944 call.mute(True),
3945 ]
3946
3947 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
3948 """A player that was not muted is never sent a mute command."""
3949 controller, player, _ = self._make_player(mock_mass)
3950 mute_mock = _mute_natively(player)
3951 player._attr_volume_muted = False
3952 player._cache.clear()
3953 player.update_state(force_update=True, signal_event=False)
3954 mute_mock.reset_mock()
3955
3956 await controller._play_native_announcement(player, player, _announcement(), None)
3957
3958 mute_mock.assert_not_awaited()
3959
3960 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
3961 """A failing announcement never leaves the player unmuted."""
3962 controller, player, announce_mock = self._make_player(mock_mass)
3963 mute_mock = _mute_natively(player)
3964 announce_mock.side_effect = PlayerCommandFailed("player went away")
3965
3966 with pytest.raises(PlayerCommandFailed):
3967 await controller._play_native_announcement(player, player, _announcement(), None)
3968
3969 assert mute_mock.await_args_list == [call(False), call(True)]
3970
3971 async def test_muted_sync_group_members_all_hear_the_announcement(
3972 self, mock_mass: MagicMock
3973 ) -> None:
3974 """Every member of a muted sync group is unmuted, keeping its mute lock."""
3975 controller, leader, _ = self._make_player(mock_mass)
3976 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
3977 controller._players["player_2"] = member
3978 member.set_initialized()
3979 leader._attr_group_members = ["player_1", "player_2"]
3980 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
3981 # both members were muted while grouped, so both hold a mute lock
3982 for player in (leader, member):
3983 player.extra_data[ATTR_MUTE_LOCK] = True
3984
3985 await controller._play_native_announcement(leader, leader, _announcement(), None)
3986
3987 for player_id, mute_mock in mute_mocks.items():
3988 assert mute_mock.await_args_list == [call(False), call(True)], player_id
3989 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
3990
3991 async def test_fake_muted_player_announces_at_its_real_volume(
3992 self, mock_mass: MagicMock
3993 ) -> None:
3994 """The announcement volume is resolved after the unmute, not from the parked zero."""
3995 mock_mass.config.get_raw_player_config_value = MagicMock(
3996 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
3997 )
3998 controller, player, announce_mock = self._make_player(mock_mass)
3999
4000 def _apply_volume(volume: int) -> None:
4001 player._attr_volume_level = volume
4002 player.update_state(signal_event=False)
4003
4004 player._attr_volume_level = 40
4005 volume_set = AsyncMock(side_effect=_apply_volume)
4006 player.volume_set = volume_set # type: ignore[method-assign]
4007 player._cache.clear()
4008 player.update_state(force_update=True, signal_event=False)
4009 await controller.cmd_volume_mute("player_1", True)
4010 assert player.state.volume_muted is True
4011 # stand in for the configured strategy, which reads the volume of the player
4012 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
4013 side_effect=lambda _player_id, _volume_level: player.state.volume_level
4014 )
4015
4016 await controller._play_native_announcement(player, player, _announcement(), None)
4017
4018 assert announce_mock.await_args == call(ANY, 40)
4019 # unmute to 40 for the announcement, park back on 0 for the fake mute
4020 assert volume_set.await_args_list == [call(0), call(40), call(0)]
4021 assert player.state.volume_muted is True
4022 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4023
4024
4025class TestScheduleActiveOutputProtocolClear:
4026 """Test the deferred clear of a player's active output protocol."""
4027
4028 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
4029 """Scheduling defers the clear to a single, per-player, cancellable task."""
4030 controller = PlayerController(mock_mass)
4031 player = MagicMock()
4032 player.player_id = "player_1"
4033
4034 controller.schedule_active_output_protocol_clear(player)
4035
4036 mock_mass.create_task.assert_called_once()
4037 # close the coroutine passed to the mocked create_task to avoid a
4038 # "coroutine was never awaited" warning
4039 mock_mass.create_task.call_args.args[0].close()
4040 # no abort_existing: a duplicate schedule must reuse the pending clear
4041 # (deduped by task_id) instead of replacing it with an untracked task
4042 assert mock_mass.create_task.call_args.kwargs == {
4043 "task_id": "clear_active_protocol_player_1",
4044 }
4045
4046 @pytest.mark.asyncio
4047 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
4048 """The protocol is cleared after waiting for the player to reach IDLE."""
4049 controller = PlayerController(mock_mass)
4050 player = MagicMock()
4051 player.player_id = "player_1"
4052
4053 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
4054 await controller._clear_active_output_protocol_when_idle(player)
4055
4056 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
4057 player.set_active_output_protocol.assert_called_once_with(None)
4058
4059
4060@contextlib.asynccontextmanager
4061async def _skip_player_update_wait(
4062 *_args: object,
4063 **_kwargs: object,
4064) -> AsyncIterator[None]:
4065 """Skip provider-driven state propagation in command-routing tests."""
4066 yield
4067
4068
4069class TestRemovePlayerControl:
4070 """Test removing a registered player control."""
4071
4072 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
4073 """Test that only the players configured to use the removed control are refreshed."""
4074 mock_mass.loop = MagicMock()
4075 controller = PlayerController(mock_mass)
4076 using_control = MagicMock()
4077 using_control.state.power_control = "switch.amp"
4078 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
4079 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
4080 unrelated = MagicMock()
4081 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
4082 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
4083 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
4084 controller._players = {"using_control": using_control, "unrelated": unrelated}
4085 controller._controls = {
4086 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4087 }
4088
4089 controller.remove_player_control("switch.amp")
4090
4091 assert controller.player_controls() == []
4092 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
4093
4094 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
4095 """Test that a control removed and registered again re-attaches to its player."""
4096 # run the scheduled refresh straight away so each step is observable
4097 mock_mass.loop = MagicMock()
4098 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
4099 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
4100 {CONF_POWER_CONTROL: "switch.amp"}
4101 )
4102 controller = PlayerController(mock_mass)
4103 mock_mass.players = controller
4104 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4105 mock_mass.get_provider.return_value = provider
4106 player = MockPlayer(provider, "player", "Player")
4107 controller._players = {"player": player}
4108 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4109
4110 await controller.register_or_update_player_control(control)
4111 assert player.state.power_control == "switch.amp"
4112
4113 # the Home Assistant plugin drops and re-registers its controls around a reload
4114 controller.remove_player_control(control.id)
4115 assert player.state.power_control == PLAYER_CONTROL_NONE
4116
4117 await controller.register_or_update_player_control(control)
4118 assert player.state.power_control == "switch.amp"
4119
4120 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
4121 """Test that removing a control that was never registered is a no-op."""
4122 mock_mass.loop = MagicMock()
4123 controller = PlayerController(mock_mass)
4124 player = MagicMock()
4125 player.state.power_control = PLAYER_CONTROL_NATIVE
4126 player.state.volume_control = PLAYER_CONTROL_NATIVE
4127 player.state.mute_control = PLAYER_CONTROL_NATIVE
4128 controller._players = {"player": player}
4129
4130 controller.remove_player_control("switch.gone")
4131
4132 mock_mass.loop.call_soon.assert_not_called()
4133
4134
4135class _FailingTeardownPlayer(MockPlayer):
4136 """Player whose provider fails to release it."""
4137
4138 unloaded = False
4139
4140 async def on_unload(self) -> None:
4141 """Handle logic when the player is unloaded from the Player controller."""
4142 self.unloaded = True
4143 msg = "device is gone"
4144 raise RuntimeError(msg)
4145
4146
4147class TestUnregisterTeardown:
4148 """Test that a failing player teardown stays contained."""
4149
4150 async def test_failing_on_unload_still_unregisters_the_player(
4151 self, mock_mass: MagicMock
4152 ) -> None:
4153 """Test that a provider raising while releasing its player does not break unregister."""
4154 controller = PlayerController(mock_mass)
4155 mock_mass.players = controller
4156 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4157 player = _FailingTeardownPlayer(provider, "boom", "Boom")
4158 controller._players = {"boom": player}
4159
4160 await controller.unregister("boom")
4161
4162 assert "boom" not in controller._players
4163 assert player.unloaded
4164
4165
4166if __name__ == "__main__":
4167 pytest.main([__file__, "-v"])
4168