/
/
/
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_is_applied_through_the_parent(self, mock_mass: MagicMock) -> None:
3426 """
3427 A native volume lives on the parent, so the parent applies and restores it.
3428
3429 The rendering output has no way to reach a native parent volume, and its own
3430 idea of the level can be stale, so routing through the parent keeps both the
3431 announcement level and the restore on the control that actually knows it.
3432 """
3433 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
3434 setup.parent._attr_volume_level = 20
3435 setup.parent._cache.clear()
3436 setup.parent.update_state(signal_event=False)
3437
3438 await self._announce(setup)
3439
3440 assert setup.volume_set.await_args_list == [
3441 call("parent", self.ANNOUNCE_VOLUME),
3442 call("parent", 20),
3443 ]
3444 setup.play_announcement.assert_awaited_once_with(ANY, None)
3445
3446 async def test_native_volume_on_its_own_output_is_kept_by_the_player(
3447 self, mock_mass: MagicMock
3448 ) -> None:
3449 """A player announcing on its own output can apply its native volume itself."""
3450 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
3451 play_announcement = AsyncMock()
3452 setup.parent.play_announcement = play_announcement # type: ignore[method-assign]
3453
3454 await setup.controller._play_native_announcement(
3455 setup.parent, setup.parent, _announcement(), self.ANNOUNCE_VOLUME
3456 )
3457
3458 setup.volume_set.assert_not_awaited()
3459 play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
3460
3461 async def test_without_volume_control_no_volume_is_applied(self, mock_mass: MagicMock) -> None:
3462 """Nothing in the signal path can set a volume, so the announcement plays as-is."""
3463 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NONE)
3464
3465 await self._announce(setup)
3466
3467 setup.volume_set.assert_not_awaited()
3468 setup.play_announcement.assert_awaited_once_with(ANY, None)
3469
3470
3471class TestPlayAnnouncementMessage:
3472 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
3473
3474 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
3475
3476 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
3477 """Create a TTS engine that renders every message to the given path."""
3478 engine = MagicMock()
3479 engine.uid = "tts_plugin/voice"
3480 engine.id = "voice"
3481 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
3482 return engine
3483
3484 def _make_player(
3485 self, mock_mass: MagicMock, announcements: dict[str, object]
3486 ) -> tuple[PlayerController, AsyncMock]:
3487 """Create a controller and a player with native announcement support."""
3488 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
3489 mock_mass, announcements
3490 )
3491 announce = AsyncMock()
3492 player.play_announcement = announce # type: ignore[method-assign]
3493 return controller, announce
3494
3495 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
3496 """A message is rendered by the default engine and announced as the rendered audio."""
3497 announcements: dict[str, object] = {}
3498 controller, announce = self._make_player(mock_mass, announcements)
3499 engine = self._make_engine()
3500
3501 with patch(
3502 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3503 ):
3504 await controller.play_announcement("player_1", message="dinner is ready")
3505
3506 # no language is sent, so the engine speaks in the language it is configured for
3507 engine.provider.get_tts_message.assert_awaited_once_with(
3508 "dinner is ready", language=None, engine_id="voice", options=None
3509 )
3510 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3511 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3512 announce.assert_awaited_once()
3513
3514 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
3515 """A message can name the language to speak it in."""
3516 announcements: dict[str, object] = {}
3517 controller, _announce = self._make_player(mock_mass, announcements)
3518 engine = self._make_engine()
3519
3520 with patch(
3521 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3522 ):
3523 await controller.play_announcement(
3524 "player_1", message="het eten is klaar", language="nl-NL"
3525 )
3526
3527 engine.provider.get_tts_message.assert_awaited_once_with(
3528 "het eten is klaar", language="nl-NL", engine_id="voice", options=None
3529 )
3530
3531 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
3532 """An engine that rejects the language speaks the message in its default voice."""
3533 announcements: dict[str, object] = {}
3534 controller, announce = self._make_player(mock_mass, announcements)
3535 engine = self._make_engine()
3536 engine.provider.get_tts_message = AsyncMock(
3537 side_effect=[
3538 RuntimeError("unsupported language"),
3539 SimpleNamespace(path="http://speech/spoken.mp3"),
3540 ]
3541 )
3542
3543 with patch(
3544 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3545 ):
3546 await controller.play_announcement(
3547 "player_1", message="dinner is ready", language="en-US"
3548 )
3549
3550 first_call, second_call = engine.provider.get_tts_message.await_args_list
3551 assert first_call.kwargs["language"] == "en-US"
3552 assert second_call.kwargs["language"] is None
3553 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3554 assert registered["announcement_url"] == "http://speech/spoken.mp3"
3555 announce.assert_awaited_once()
3556
3557 @pytest.mark.parametrize(
3558 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
3559 )
3560 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
3561 self, mock_mass: MagicMock, error: Exception
3562 ) -> None:
3563 """A timeout or a structured failure is no language rejection, so it is not retried."""
3564 announcements: dict[str, object] = {}
3565 controller, announce = self._make_player(mock_mass, announcements)
3566 engine = self._make_engine()
3567 engine.provider.get_tts_message = AsyncMock(side_effect=error)
3568
3569 with (
3570 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3571 pytest.raises(MusicAssistantError),
3572 ):
3573 await controller.play_announcement("player_1", message="dinner is ready")
3574
3575 engine.provider.get_tts_message.assert_awaited_once()
3576 announce.assert_not_awaited()
3577
3578 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
3579 """A message names the engine to speak it, overriding the configured default."""
3580 announcements: dict[str, object] = {}
3581 controller, _announce = self._make_player(mock_mass, announcements)
3582 engine = self._make_engine()
3583
3584 with (
3585 patch(
3586 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
3587 ) as resolve,
3588 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
3589 ):
3590 await controller.play_announcement(
3591 "player_1", message="hello", tts_engine="tts_plugin/voice"
3592 )
3593
3594 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
3595 select.assert_not_awaited()
3596
3597 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
3598 """A spoken message uses the player's pre-announce setting without sniffing the url."""
3599 announcements: dict[str, object] = {}
3600 controller, _announce = self._make_player(mock_mass, announcements)
3601 engine = self._make_engine()
3602 mock_mass.config.get_raw_player_config_value = MagicMock(
3603 side_effect=lambda _player_id, key, default=None: (
3604 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
3605 )
3606 )
3607
3608 with patch(
3609 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3610 ):
3611 await controller.play_announcement("player_1", message="dinner is ready")
3612
3613 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
3614 assert registered["pre_announce"] is True
3615
3616 async def test_the_engine_gets_the_shorter_announcement_timeout(
3617 self, mock_mass: MagicMock
3618 ) -> None:
3619 """The engine is capped well below the background default, it holds the player lock."""
3620 announcements: dict[str, object] = {}
3621 controller, _announce = self._make_player(mock_mass, announcements)
3622 engine = self._make_engine()
3623 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
3624
3625 with (
3626 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3627 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
3628 ):
3629 await controller.play_announcement("player_1", message="hello")
3630
3631 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
3632 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
3633
3634 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
3635 """Naming an engine for a url announcement is rejected instead of silently ignored."""
3636 announcements: dict[str, object] = {}
3637 controller, _announce = self._make_player(mock_mass, announcements)
3638
3639 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
3640 await controller.play_announcement(
3641 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
3642 )
3643
3644 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
3645 """Naming a language for a url announcement is rejected instead of silently ignored."""
3646 announcements: dict[str, object] = {}
3647 controller, _announce = self._make_player(mock_mass, announcements)
3648
3649 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
3650 await controller.play_announcement(
3651 "player_1", url="http://test/clip.mp3", language="nl-NL"
3652 )
3653
3654 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
3655 """An engine that fails to speak the message fails the announcement."""
3656 announcements: dict[str, object] = {}
3657 controller, announce = self._make_player(mock_mass, announcements)
3658 engine = self._make_engine()
3659 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
3660
3661 with (
3662 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3663 pytest.raises(PlayerCommandFailed),
3664 ):
3665 await controller.play_announcement("player_1", message="hello")
3666
3667 announce.assert_not_awaited()
3668 mock_mass.streams.announcement_renderer.register.assert_not_called()
3669
3670 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
3671 """An announcement with neither a url nor a message is rejected."""
3672 announcements: dict[str, object] = {}
3673 controller, _announce = self._make_player(mock_mass, announcements)
3674
3675 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
3676 await controller.play_announcement("player_1")
3677
3678 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
3679 """An announcement carrying both a url and a message is rejected."""
3680 announcements: dict[str, object] = {}
3681 controller, _announce = self._make_player(mock_mass, announcements)
3682
3683 with pytest.raises(PlayerCommandFailed, match="not both"):
3684 await controller.play_announcement(
3685 "player_1", url="http://test/clip.mp3", message="hello"
3686 )
3687
3688 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
3689 """A message naming an engine that does not exist fails instead of using another."""
3690 announcements: dict[str, object] = {}
3691 controller, _announce = self._make_player(mock_mass, announcements)
3692
3693 with (
3694 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
3695 pytest.raises(PlayerCommandFailed, match="is not available"),
3696 ):
3697 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
3698
3699 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
3700 """A message fails clearly when no TTS engine is set up at all."""
3701 announcements: dict[str, object] = {}
3702 controller, _announce = self._make_player(mock_mass, announcements)
3703
3704 with (
3705 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
3706 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
3707 ):
3708 await controller.play_announcement("player_1", message="hello")
3709
3710 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
3711 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
3712 announcements: dict[str, object] = {}
3713 controller, _announce = self._make_player(mock_mass, announcements)
3714 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
3715
3716 with (
3717 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
3718 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
3719 ):
3720 await controller.play_announcement("player_1", message="hello")
3721
3722 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
3723 """The message is spoken once and every group member announces the resulting audio."""
3724 announcements: dict[str, object] = {}
3725 use_real_create_task(mock_mass)
3726 controller, _announce = self._make_player(mock_mass, announcements)
3727 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3728 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
3729 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3730 group._attr_group_members = ["player_1"]
3731 group._cache.clear()
3732 controller._players["group_1"] = group
3733 group.update_state(signal_event=False)
3734 engine = self._make_engine()
3735
3736 with patch(
3737 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
3738 ):
3739 await controller.play_announcement("group_1", message="dinner is ready")
3740
3741 # rendered once for the group, then handed to the member as plain audio
3742 engine.provider.get_tts_message.assert_awaited_once()
3743 member_call = next(
3744 call_args
3745 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
3746 if call_args.args[0] == "player_1"
3747 )
3748 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
3749
3750
3751class TestNativeAnnouncementRouting:
3752 """Announcement routing respects the player's own support and its active output."""
3753
3754 def _make_player_with_linked_child(
3755 self,
3756 mock_mass: MagicMock,
3757 playback_state: PlaybackState,
3758 *,
3759 parent_supports_announce: bool = False,
3760 active_protocol: str | None = None,
3761 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
3762 """Create a controller, a player, its linked protocol child and the two path mocks."""
3763 controller = PlayerController(mock_mass)
3764 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3765 player = MockPlayer(provider, "player_1", "Player 1")
3766 player._attr_playback_state = playback_state
3767 if parent_supports_announce:
3768 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3769 proto_provider = MockProvider("airplay", mass=mock_mass)
3770 proto = MockPlayer(
3771 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
3772 )
3773 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3774 controller._players = {"player_1": player, "proto_1": proto}
3775 mock_mass.players = controller
3776 player.set_linked_output_protocols(
3777 [
3778 LinkedOutputProtocol(
3779 output_protocol_id="proto_1",
3780 protocol_domain="airplay",
3781 priority=40,
3782 )
3783 ]
3784 )
3785 if active_protocol is not None:
3786 player.set_active_output_protocol(active_protocol)
3787 render = MagicMock()
3788 render.wait_ready = AsyncMock(return_value=True)
3789 renderer = mock_mass.streams.announcement_renderer
3790 renderer.register = MagicMock(return_value=render)
3791 renderer.unregister = AsyncMock()
3792 mock_mass.streams.get_announcement_url = MagicMock(
3793 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
3794 )
3795 proto.update_state(signal_event=False)
3796 player.update_state(signal_event=False)
3797 native_path = AsyncMock()
3798 generic_path = AsyncMock()
3799 controller._play_native_announcement = native_path # type: ignore[method-assign]
3800 controller._play_announcement = generic_path # type: ignore[method-assign]
3801 return controller, player, proto, native_path, generic_path
3802
3803 async def test_playing_player_does_not_route_to_an_idle_linked_child(
3804 self, mock_mass: MagicMock
3805 ) -> None:
3806 """
3807 A player rendering through one output must not announce through another.
3808
3809 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
3810 the announcement to the child would seize the device from the native
3811 output, with nothing restoring that playback afterwards.
3812 """
3813 controller, _player, _proto, native_path, generic_path = (
3814 self._make_player_with_linked_child(
3815 mock_mass, PlaybackState.PLAYING, active_protocol="native"
3816 )
3817 )
3818
3819 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3820
3821 native_path.assert_not_awaited()
3822 generic_path.assert_awaited_once()
3823
3824 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
3825 """An idle player announces natively through any capable linked protocol."""
3826 controller, _player, proto, native_path, _generic_path = (
3827 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
3828 )
3829
3830 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3831
3832 native_path.assert_awaited_once()
3833 assert native_path.call_args.args[1] is proto
3834
3835 async def test_active_protocol_child_beats_own_native_support(
3836 self, mock_mass: MagicMock
3837 ) -> None:
3838 """
3839 The output that is actively rendering wins over the player's own support.
3840
3841 E.g. a Sonos playing through its AirPlay child: the announcement rides
3842 the same audio path as the music (mixed into the live stream, in sync
3843 with the rest of a group) instead of a second mechanism firing beside
3844 the playback.
3845 """
3846 controller, _player, proto, native_path, _generic_path = (
3847 self._make_player_with_linked_child(
3848 mock_mass,
3849 PlaybackState.PLAYING,
3850 parent_supports_announce=True,
3851 active_protocol="proto_1",
3852 )
3853 )
3854
3855 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3856
3857 native_path.assert_awaited_once()
3858 assert native_path.call_args.args[1] is proto
3859
3860 async def test_own_native_support_wins_when_playing_natively(
3861 self, mock_mass: MagicMock
3862 ) -> None:
3863 """A player rendering through its own native output announces natively."""
3864 controller, player, _proto, native_path, _generic_path = (
3865 self._make_player_with_linked_child(
3866 mock_mass,
3867 PlaybackState.PLAYING,
3868 parent_supports_announce=True,
3869 active_protocol="native",
3870 )
3871 )
3872
3873 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3874
3875 native_path.assert_awaited_once()
3876 assert native_path.call_args.args[1] is player
3877
3878 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
3879 self, mock_mass: MagicMock
3880 ) -> None:
3881 """Without active playback the player's own announcement support wins."""
3882 controller, player, _proto, native_path, _generic_path = (
3883 self._make_player_with_linked_child(
3884 mock_mass,
3885 PlaybackState.IDLE,
3886 parent_supports_announce=True,
3887 )
3888 )
3889
3890 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3891
3892 native_path.assert_awaited_once()
3893 assert native_path.call_args.args[1] is player
3894
3895
3896@pytest.mark.usefixtures("running_background_tasks")
3897class TestPlayAnnouncementRestore:
3898 """Test the state restore of the default (fallback) announcement implementation."""
3899
3900 def _make_player(
3901 self, mock_mass: MagicMock, prev_media: PlayerMedia
3902 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
3903 """Create a controller and a playing player, returning the patched resume handler."""
3904 controller = PlayerController(mock_mass)
3905 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3906 player = MockPlayer(provider, "player_1", "Player 1")
3907 player._attr_playback_state = PlaybackState.PLAYING
3908 player._attr_current_media = prev_media
3909 player._cache.clear()
3910 controller._players = {"player_1": player}
3911 mock_mass.players = controller
3912 mock_mass.player_queues.get = MagicMock(return_value=None)
3913 resume_mock = AsyncMock()
3914 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
3915 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
3916 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
3917 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
3918 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
3919 player.set_initialized()
3920 player.update_state(signal_event=False)
3921 return controller, player, resume_mock
3922
3923 @staticmethod
3924 def _add_group(
3925 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
3926 ) -> MockPlayer:
3927 """Register a powered group player that holds the given player as its member."""
3928 group = MockPlayer(
3929 cast("MockProvider", player.provider),
3930 "group_1",
3931 "Group 1",
3932 player_type=PlayerType.GROUP,
3933 )
3934 group._attr_powered = True
3935 group._attr_group_members = [player.player_id]
3936 if supports_set_members:
3937 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3938 controller._players[group.player_id] = group
3939 group.set_initialized()
3940 group._cache.clear()
3941 group.update_state(signal_event=False)
3942 # the member has no own input change to trigger a recalculation of its
3943 # (group derived) state, so force it here - just like register() does
3944 player._cache.clear()
3945 player.update_state(force_update=True, signal_event=False)
3946 assert player.state.active_group == group.player_id
3947 return group
3948
3949 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
3950 """Content that was playing before the announcement is resumed afterwards."""
3951 controller, player, resume_mock = self._make_player(
3952 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3953 )
3954
3955 await controller._play_announcement(player, _announcement())
3956
3957 resume_mock.assert_awaited_once()
3958
3959 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
3960 """A player still busy with an earlier announcement has no playback to restore."""
3961 controller, player, resume_mock = self._make_player(
3962 mock_mass,
3963 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
3964 )
3965
3966 await controller._play_announcement(player, _announcement())
3967
3968 resume_mock.assert_not_awaited()
3969
3970 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
3971 """A failing announcement never leaves the player at the raised volume."""
3972 controller, player, _ = self._make_player(
3973 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3974 )
3975 player._attr_volume_level = 20
3976 player._cache.clear()
3977 player.update_state(force_update=True, signal_event=False)
3978 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
3979 volume_mock = AsyncMock()
3980 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
3981 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3982 side_effect=PlayerCommandFailed("player went away")
3983 )
3984
3985 with pytest.raises(PlayerCommandFailed):
3986 await controller._play_announcement(player, _announcement())
3987
3988 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
3989
3990 async def test_zero_announcement_volume_is_applied_and_restored(
3991 self, mock_mass: MagicMock
3992 ) -> None:
3993 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
3994 controller, player, _ = self._make_player(
3995 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3996 )
3997 player._attr_volume_level = 20
3998 player._cache.clear()
3999 player.update_state(force_update=True, signal_event=False)
4000 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
4001 volume_mock = AsyncMock()
4002 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4003
4004 await controller._play_announcement(player, _announcement())
4005
4006 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
4007
4008 async def test_playback_is_restored_when_duration_is_unknown(
4009 self, mock_mass: MagicMock
4010 ) -> None:
4011 """An announcement of unknown length still hands the player back to its content."""
4012 controller, player, resume_mock = self._make_player(
4013 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4014 )
4015 announcement = _announcement()
4016 announcement.duration = None
4017
4018 # an unknown length waits for the player to report it finished instead of failing
4019 await controller._play_announcement(player, announcement)
4020
4021 resume_mock.assert_awaited_once()
4022
4023 async def test_group_membership_is_restored_when_playback_fails(
4024 self, mock_mass: MagicMock
4025 ) -> None:
4026 """A failing announcement never leaves the player out of its group player."""
4027 controller, player, _ = self._make_player(
4028 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4029 )
4030 group = self._add_group(controller, player, supports_set_members=True)
4031 group.set_members = AsyncMock() # type: ignore[method-assign]
4032 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4033 side_effect=PlayerCommandFailed("player went away")
4034 )
4035
4036 with pytest.raises(PlayerCommandFailed):
4037 await controller._play_announcement(player, _announcement())
4038
4039 assert group.set_members.await_args_list == [
4040 call(player_ids_to_remove=["player_1"]),
4041 call(player_ids_to_add=["player_1"]),
4042 ]
4043
4044 async def test_restore_failure_does_not_mask_the_announcement_error(
4045 self, mock_mass: MagicMock
4046 ) -> None:
4047 """A provider blowing up during the restore must not hide why the announcement failed."""
4048 controller, player, _ = self._make_player(
4049 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4050 )
4051 group = self._add_group(controller, player, supports_set_members=True)
4052 # set_members is a raw provider call: whatever its client library raises comes
4053 # through unwrapped, so the ungroup succeeds and the regroup times out
4054 group.set_members = AsyncMock( # type: ignore[method-assign]
4055 side_effect=[None, TimeoutError("provider timeout")]
4056 )
4057 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4058 side_effect=PlayerCommandFailed("player went away")
4059 )
4060
4061 with pytest.raises(PlayerCommandFailed, match="player went away"):
4062 await controller._play_announcement(player, _announcement())
4063
4064 async def test_group_without_set_members_is_the_one_powered_off(
4065 self, mock_mass: MagicMock
4066 ) -> None:
4067 """A group that can not release members is powered off, not the announcement target."""
4068 controller, player, _ = self._make_player(
4069 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4070 )
4071 self._add_group(controller, player, supports_set_members=False)
4072 power_mock = AsyncMock()
4073 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
4074 play_mock = AsyncMock()
4075 controller.cmd_play = play_mock # type: ignore[method-assign]
4076
4077 await controller._play_announcement(player, _announcement())
4078
4079 # the group is switched off for the announcement and restarted afterwards
4080 power_mock.assert_awaited_once_with("group_1", False)
4081 play_mock.assert_awaited_once_with("group_1")
4082
4083 async def test_idle_player_without_power_control_is_regrouped(
4084 self, mock_mass: MagicMock
4085 ) -> None:
4086 """An idle player that has no power state to restore is still put back in its group."""
4087 controller, player, _ = self._make_player(
4088 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4089 )
4090 player._attr_playback_state = PlaybackState.IDLE
4091 player._attr_powered = None
4092 group = self._add_group(controller, player, supports_set_members=True)
4093 assert player.state.power_control == PLAYER_CONTROL_NONE
4094 group.set_members = AsyncMock() # type: ignore[method-assign]
4095
4096 await controller._play_announcement(player, _announcement())
4097
4098 assert group.set_members.await_args_list == [
4099 call(player_ids_to_remove=["player_1"]),
4100 call(player_ids_to_add=["player_1"]),
4101 ]
4102
4103 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4104 """A muted player hears the announcement and is muted again afterwards."""
4105 controller, player, _ = self._make_player(
4106 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4107 )
4108 mute_mock = _mute_natively(player)
4109
4110 await controller._play_announcement(player, _announcement())
4111
4112 assert mute_mock.await_args_list == [call(False), call(True)]
4113
4114 async def test_player_without_volume_control_is_still_unmuted(
4115 self, mock_mass: MagicMock
4116 ) -> None:
4117 """A player that can only be muted is unmuted for the announcement all the same."""
4118 mock_mass.config.get_raw_player_config_value = MagicMock(
4119 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
4120 )
4121 controller, player, _ = self._make_player(
4122 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4123 )
4124 mute_mock = _mute_natively(player)
4125 assert player.state.volume_control == PLAYER_CONTROL_NONE
4126
4127 await controller._play_announcement(player, _announcement())
4128
4129 assert mute_mock.await_args_list == [call(False), call(True)]
4130
4131 async def test_mute_is_restored_before_the_player_is_regrouped(
4132 self, mock_mass: MagicMock
4133 ) -> None:
4134 """A player is handed back to its group already muted, holding on to its mute lock."""
4135 controller, player, _ = self._make_player(
4136 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4137 )
4138 group = self._add_group(controller, player, supports_set_members=True)
4139 real_set_members = group.set_members
4140
4141 async def _set_members(**kwargs: list[str]) -> None:
4142 # let the membership really change, so the player is ungrouped while the
4143 # announcement plays - just like it is in production. neither player picks
4144 # the new membership up on its own here, so publish it on both.
4145 await real_set_members(**kwargs)
4146 group.update_state(force_update=True, signal_event=False)
4147 player._cache.clear()
4148 player.update_state(force_update=True, signal_event=False)
4149
4150 set_members = AsyncMock(side_effect=_set_members)
4151 group.set_members = set_members # type: ignore[method-assign]
4152 player.extra_data[ATTR_MUTE_LOCK] = True
4153 recorder = MagicMock()
4154 recorder.attach_mock(_mute_natively(player), "mute")
4155 recorder.attach_mock(set_members, "set_members")
4156
4157 await controller._play_announcement(player, _announcement())
4158
4159 assert recorder.mock_calls == [
4160 call.set_members(player_ids_to_remove=["player_1"]),
4161 call.mute(False),
4162 call.mute(True),
4163 call.set_members(player_ids_to_add=["player_1"]),
4164 ]
4165 # the lock survives the announcement, so the regroup does not unmute the player
4166 assert player.extra_data[ATTR_MUTE_LOCK] is True
4167
4168 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4169 """A player that was not muted is never sent a mute command."""
4170 controller, player, _ = self._make_player(
4171 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4172 )
4173 mute_mock = _mute_natively(player)
4174 player._attr_volume_muted = False
4175 player._cache.clear()
4176 player.update_state(force_update=True, signal_event=False)
4177 mute_mock.reset_mock()
4178
4179 await controller._play_announcement(player, _announcement())
4180
4181 mute_mock.assert_not_awaited()
4182
4183 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4184 """A failing announcement never leaves the player unmuted."""
4185 controller, player, _ = self._make_player(
4186 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4187 )
4188 mute_mock = _mute_natively(player)
4189 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4190 side_effect=PlayerCommandFailed("player went away")
4191 )
4192
4193 with pytest.raises(PlayerCommandFailed):
4194 await controller._play_announcement(player, _announcement())
4195
4196 assert mute_mock.await_args_list == [call(False), call(True)]
4197
4198 async def test_muted_sync_group_members_all_hear_the_announcement(
4199 self, mock_mass: MagicMock
4200 ) -> None:
4201 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4202 controller, leader, _ = self._make_player(
4203 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4204 )
4205 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4206 controller._players["player_2"] = member
4207 member.set_initialized()
4208 leader._attr_group_members = ["player_1", "player_2"]
4209 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4210 # both members were muted while grouped, so both hold a mute lock
4211 for player in (leader, member):
4212 player.extra_data[ATTR_MUTE_LOCK] = True
4213
4214 await controller._play_announcement(leader, _announcement())
4215
4216 for player_id, mute_mock in mute_mocks.items():
4217 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4218 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4219
4220 async def test_fake_muted_player_announces_at_its_real_volume(
4221 self, mock_mass: MagicMock
4222 ) -> None:
4223 """A fake muted player announces at its real volume, not at the zero it is parked on."""
4224 mock_mass.config.get_raw_player_config_value = MagicMock(
4225 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4226 )
4227 controller, player, _ = self._make_player(
4228 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4229 )
4230
4231 def _apply_volume(volume: int) -> None:
4232 player._attr_volume_level = volume
4233 player.update_state(signal_event=False)
4234
4235 player._attr_volume_level = 40
4236 volume_set = AsyncMock(side_effect=_apply_volume)
4237 player.volume_set = volume_set # type: ignore[method-assign]
4238 player._cache.clear()
4239 player.update_state(force_update=True, signal_event=False)
4240 await controller.cmd_volume_mute("player_1", True)
4241 assert player.state.volume_muted is True
4242 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4243
4244 await controller._play_announcement(player, _announcement())
4245
4246 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
4247 assert volume_set.await_args_list == [
4248 call(0),
4249 call(40),
4250 call(80),
4251 call(40),
4252 call(0),
4253 ]
4254 assert player.state.volume_muted is True
4255 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4256
4257
4258@pytest.mark.usefixtures("running_background_tasks")
4259class TestPlayNativeAnnouncement:
4260 """Test the mute handling around an announcement that a player plays natively."""
4261
4262 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4263 """Create a controller and a player with native announcement support."""
4264 controller = PlayerController(mock_mass)
4265 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4266 player = MockPlayer(provider, "player_1", "Player 1")
4267 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4268 player._cache.clear()
4269 controller._players = {"player_1": player}
4270 mock_mass.players = controller
4271 mock_mass.player_queues.get = MagicMock(return_value=None)
4272 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4273 announce_mock = AsyncMock()
4274 player.play_announcement = announce_mock # type: ignore[method-assign]
4275 player.set_initialized()
4276 player.update_state(signal_event=False)
4277 return controller, player, announce_mock
4278
4279 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4280 """A muted player hears the announcement and is muted again afterwards."""
4281 controller, player, announce_mock = self._make_player(mock_mass)
4282 recorder = MagicMock()
4283 recorder.attach_mock(_mute_natively(player), "mute")
4284 recorder.attach_mock(announce_mock, "announce")
4285
4286 await controller._play_native_announcement(player, player, _announcement(), None)
4287
4288 assert recorder.mock_calls == [
4289 call.mute(False),
4290 call.announce(ANY, None),
4291 call.mute(True),
4292 ]
4293
4294 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4295 """A player that was not muted is never sent a mute command."""
4296 controller, player, _ = self._make_player(mock_mass)
4297 mute_mock = _mute_natively(player)
4298 player._attr_volume_muted = False
4299 player._cache.clear()
4300 player.update_state(force_update=True, signal_event=False)
4301 mute_mock.reset_mock()
4302
4303 await controller._play_native_announcement(player, player, _announcement(), None)
4304
4305 mute_mock.assert_not_awaited()
4306
4307 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
4308 """A failing announcement never leaves the player unmuted."""
4309 controller, player, announce_mock = self._make_player(mock_mass)
4310 mute_mock = _mute_natively(player)
4311 announce_mock.side_effect = PlayerCommandFailed("player went away")
4312
4313 with pytest.raises(PlayerCommandFailed):
4314 await controller._play_native_announcement(player, player, _announcement(), None)
4315
4316 assert mute_mock.await_args_list == [call(False), call(True)]
4317
4318 async def test_muted_sync_group_members_all_hear_the_announcement(
4319 self, mock_mass: MagicMock
4320 ) -> None:
4321 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4322 controller, leader, _ = self._make_player(mock_mass)
4323 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4324 controller._players["player_2"] = member
4325 member.set_initialized()
4326 leader._attr_group_members = ["player_1", "player_2"]
4327 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4328 # both members were muted while grouped, so both hold a mute lock
4329 for player in (leader, member):
4330 player.extra_data[ATTR_MUTE_LOCK] = True
4331
4332 await controller._play_native_announcement(leader, leader, _announcement(), None)
4333
4334 for player_id, mute_mock in mute_mocks.items():
4335 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4336 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4337
4338 async def test_fake_muted_player_announces_at_its_real_volume(
4339 self, mock_mass: MagicMock
4340 ) -> None:
4341 """The announcement volume is resolved after the unmute, not from the parked zero."""
4342 mock_mass.config.get_raw_player_config_value = MagicMock(
4343 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4344 )
4345 controller, player, announce_mock = self._make_player(mock_mass)
4346
4347 def _apply_volume(volume: int) -> None:
4348 player._attr_volume_level = volume
4349 player.update_state(signal_event=False)
4350
4351 player._attr_volume_level = 40
4352 volume_set = AsyncMock(side_effect=_apply_volume)
4353 player.volume_set = volume_set # type: ignore[method-assign]
4354 player._cache.clear()
4355 player.update_state(force_update=True, signal_event=False)
4356 await controller.cmd_volume_mute("player_1", True)
4357 assert player.state.volume_muted is True
4358 # stand in for the configured strategy, which reads the volume of the player
4359 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
4360 side_effect=lambda _player_id, _volume_level: player.state.volume_level
4361 )
4362
4363 await controller._play_native_announcement(player, player, _announcement(), None)
4364
4365 assert announce_mock.await_args == call(ANY, 40)
4366 # unmute to 40 for the announcement, park back on 0 for the fake mute
4367 assert volume_set.await_args_list == [call(0), call(40), call(0)]
4368 assert player.state.volume_muted is True
4369 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4370
4371
4372class TestScheduleActiveOutputProtocolClear:
4373 """Test the deferred clear of a player's active output protocol."""
4374
4375 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
4376 """Scheduling defers the clear to a single, per-player, cancellable task."""
4377 controller = PlayerController(mock_mass)
4378 player = MagicMock()
4379 player.player_id = "player_1"
4380
4381 controller.schedule_active_output_protocol_clear(player)
4382
4383 mock_mass.create_task.assert_called_once()
4384 # close the coroutine passed to the mocked create_task to avoid a
4385 # "coroutine was never awaited" warning
4386 mock_mass.create_task.call_args.args[0].close()
4387 # no abort_existing: a duplicate schedule must reuse the pending clear
4388 # (deduped by task_id) instead of replacing it with an untracked task
4389 assert mock_mass.create_task.call_args.kwargs == {
4390 "task_id": "clear_active_protocol_player_1",
4391 }
4392
4393 @pytest.mark.asyncio
4394 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
4395 """The protocol is cleared after waiting for the player to reach IDLE."""
4396 controller = PlayerController(mock_mass)
4397 player = MagicMock()
4398 player.player_id = "player_1"
4399
4400 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
4401 await controller._clear_active_output_protocol_when_idle(player)
4402
4403 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
4404 player.set_active_output_protocol.assert_called_once_with(None)
4405
4406
4407@contextlib.asynccontextmanager
4408async def _skip_player_update_wait(
4409 *_args: object,
4410 **_kwargs: object,
4411) -> AsyncIterator[None]:
4412 """Skip provider-driven state propagation in command-routing tests."""
4413 yield
4414
4415
4416class TestRemovePlayerControl:
4417 """Test removing a registered player control."""
4418
4419 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
4420 """Test that only the players configured to use the removed control are refreshed."""
4421 mock_mass.loop = MagicMock()
4422 controller = PlayerController(mock_mass)
4423 using_control = MagicMock()
4424 using_control.state.power_control = "switch.amp"
4425 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
4426 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
4427 unrelated = MagicMock()
4428 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
4429 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
4430 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
4431 controller._players = {"using_control": using_control, "unrelated": unrelated}
4432 controller._controls = {
4433 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4434 }
4435
4436 controller.remove_player_control("switch.amp")
4437
4438 assert controller.player_controls() == []
4439 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
4440
4441 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
4442 """Test that a control removed and registered again re-attaches to its player."""
4443 # run the scheduled refresh straight away so each step is observable
4444 mock_mass.loop = MagicMock()
4445 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
4446 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
4447 {CONF_POWER_CONTROL: "switch.amp"}
4448 )
4449 controller = PlayerController(mock_mass)
4450 mock_mass.players = controller
4451 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4452 mock_mass.get_provider.return_value = provider
4453 player = MockPlayer(provider, "player", "Player")
4454 controller._players = {"player": player}
4455 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
4456
4457 await controller.register_or_update_player_control(control)
4458 assert player.state.power_control == "switch.amp"
4459
4460 # the Home Assistant plugin drops and re-registers its controls around a reload
4461 controller.remove_player_control(control.id)
4462 assert player.state.power_control == PLAYER_CONTROL_NONE
4463
4464 await controller.register_or_update_player_control(control)
4465 assert player.state.power_control == "switch.amp"
4466
4467 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
4468 """Test that removing a control that was never registered is a no-op."""
4469 mock_mass.loop = MagicMock()
4470 controller = PlayerController(mock_mass)
4471 player = MagicMock()
4472 player.state.power_control = PLAYER_CONTROL_NATIVE
4473 player.state.volume_control = PLAYER_CONTROL_NATIVE
4474 player.state.mute_control = PLAYER_CONTROL_NATIVE
4475 controller._players = {"player": player}
4476
4477 controller.remove_player_control("switch.gone")
4478
4479 mock_mass.loop.call_soon.assert_not_called()
4480
4481
4482class _FailingTeardownPlayer(MockPlayer):
4483 """Player whose provider fails to release it."""
4484
4485 unloaded = False
4486
4487 async def on_unload(self) -> None:
4488 """Handle logic when the player is unloaded from the Player controller."""
4489 self.unloaded = True
4490 msg = "device is gone"
4491 raise RuntimeError(msg)
4492
4493
4494class TestUnregisterTeardown:
4495 """Test that a failing player teardown stays contained."""
4496
4497 async def test_failing_on_unload_still_unregisters_the_player(
4498 self, mock_mass: MagicMock
4499 ) -> None:
4500 """Test that a provider raising while releasing its player does not break unregister."""
4501 controller = PlayerController(mock_mass)
4502 mock_mass.players = controller
4503 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
4504 player = _FailingTeardownPlayer(provider, "boom", "Boom")
4505 controller._players = {"boom": player}
4506
4507 await controller.unregister("boom")
4508
4509 assert "boom" not in controller._players
4510 assert player.unloaded
4511
4512
4513class TestConfigChangeRestartsPlayback:
4514 """Test that a changed player setting which needs a reload restarts playback."""
4515
4516 @staticmethod
4517 def _config(*, requires_reload: bool) -> PlayerConfig:
4518 """Build a PlayerConfig holding a single output codec entry."""
4519 return PlayerConfig(
4520 provider="test_prov",
4521 player_id="player_1",
4522 values={
4523 CONF_OUTPUT_CODEC: ConfigEntry(
4524 key=CONF_OUTPUT_CODEC,
4525 type=ConfigEntryType.STRING,
4526 label="Output codec",
4527 value="flac",
4528 requires_reload=requires_reload,
4529 )
4530 },
4531 )
4532
4533 @staticmethod
4534 def _prepare(mock_mass: MagicMock, queue_state: PlaybackState) -> PlayerController:
4535 """Register a player whose active queue is in the given state."""
4536 controller = PlayerController(mock_mass)
4537 player = MagicMock()
4538 player.state.active_source = "player_1"
4539 player.on_config_updated = AsyncMock()
4540 controller._players = {"player_1": player}
4541 queue = MagicMock()
4542 queue.queue_id = "player_1"
4543 queue.state = queue_state
4544 mock_mass.player_queues.get = MagicMock(return_value=queue)
4545 mock_mass.player_queues.stop = AsyncMock()
4546 return controller
4547
4548 async def test_reload_setting_restarts_playback(self, mock_mass: MagicMock) -> None:
4549 """Test that changing a reload-requiring setting stops and resumes the queue."""
4550 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
4551
4552 await controller.on_player_config_change(
4553 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
4554 )
4555
4556 mock_mass.player_queues.stop.assert_awaited_once_with("player_1")
4557 mock_mass.call_later.assert_called_once_with(
4558 1, mock_mass.player_queues.resume, "player_1", False
4559 )
4560
4561 async def test_plain_setting_does_not_restart_playback(self, mock_mass: MagicMock) -> None:
4562 """Test that a setting which applies on the fly leaves playback alone."""
4563 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
4564
4565 await controller.on_player_config_change(
4566 self._config(requires_reload=False), {f"values/{CONF_OUTPUT_CODEC}"}
4567 )
4568
4569 mock_mass.player_queues.stop.assert_not_awaited()
4570 mock_mass.call_later.assert_not_called()
4571
4572 async def test_untouched_reload_setting_does_not_restart_playback(
4573 self, mock_mass: MagicMock
4574 ) -> None:
4575 """Test that only a changed reload-requiring setting restarts playback."""
4576 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
4577
4578 await controller.on_player_config_change(
4579 self._config(requires_reload=True), {f"values/{CONF_ICON}"}
4580 )
4581
4582 mock_mass.player_queues.stop.assert_not_awaited()
4583 mock_mass.call_later.assert_not_called()
4584
4585 async def test_idle_queue_is_left_alone(self, mock_mass: MagicMock) -> None:
4586 """Test that a reload-requiring change does not start playback on an idle queue."""
4587 controller = self._prepare(mock_mass, PlaybackState.IDLE)
4588
4589 await controller.on_player_config_change(
4590 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
4591 )
4592
4593 mock_mass.player_queues.stop.assert_not_awaited()
4594 mock_mass.call_later.assert_not_called()
4595
4596
4597if __name__ == "__main__":
4598 pytest.main([__file__, "-v"])
4599