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