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