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