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