/
/
/
1"""
2Tests for the active output protocol lifecycle in play/stop command handling.
3
4Covers two regressions from support#5771:
5- a leftover ``active_output_protocol`` from a previous session must not be
6 reused on a fresh playback start (it bypassed protocol selection entirely,
7 e.g. a Sonos kept playing via AirPlay after leaving a sync group);
8- a stop command arriving when the player already reports IDLE must still
9 forward the stop to the pinned protocol player and schedule the protocol
10 clear (previously it returned early, leaving a stale session on the device
11 and the protocol pinned forever).
12"""
13
14from __future__ import annotations
15
16from unittest.mock import AsyncMock, MagicMock
17
18import pytest
19from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
20from music_assistant_models.player import PlayerMedia
21
22from music_assistant.controllers.players import PlayerController
23from music_assistant.models.player import LinkedOutputProtocol
24from tests.common import MockPlayer, MockProvider
25
26
27class PlayableMockPlayer(MockPlayer):
28 """MockPlayer that records play_media calls."""
29
30 def __init__(
31 self,
32 provider: MockProvider,
33 player_id: str,
34 name: str,
35 player_type: PlayerType = PlayerType.PLAYER,
36 ) -> None:
37 """Initialize the mock player with play_media call recording."""
38 super().__init__(provider, player_id, name, player_type=player_type)
39 self.play_media_calls: list[PlayerMedia] = []
40
41 async def play_media(self, media: PlayerMedia) -> None:
42 """Record the play_media call."""
43 self.play_media_calls.append(media)
44
45
46@pytest.fixture
47def mock_mass() -> MagicMock:
48 """Create a mock MusicAssistant instance."""
49 mass = MagicMock()
50 mass.closing = False
51 mass.config = MagicMock()
52 mass.config.get = MagicMock(return_value=[])
53
54 def _get_raw_player_config_value(
55 _player_id: str, key: str, default: str | int | None = None
56 ) -> str | int | None:
57 if key == "min_volume":
58 return 0
59 if key == "max_volume":
60 return 100
61 return default
62
63 mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
64 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
65 mass.config.set = MagicMock()
66 mass.signal_event = MagicMock()
67 mass.get_providers = MagicMock(return_value=[])
68 mass.player_queues = MagicMock()
69 mass.player_queues.get = MagicMock(return_value=None)
70 return mass
71
72
73@pytest.fixture
74def controller(mock_mass: MagicMock) -> PlayerController:
75 """Create a PlayerController instance."""
76 ctrl = PlayerController(mock_mass)
77 mock_mass.players = ctrl
78 return ctrl
79
80
81def _make_player_with_protocol(
82 mock_mass: MagicMock,
83 controller: PlayerController,
84 protocol_state: PlaybackState,
85 native_play_media: bool = True,
86) -> tuple[PlayableMockPlayer, PlayableMockPlayer]:
87 """Create a native player with one linked sendspin protocol player."""
88 native_provider = MockProvider("sonos", mass=mock_mass)
89 player = PlayableMockPlayer(native_provider, "player_1", "Test Player")
90 if native_play_media:
91 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
92
93 protocol_provider = MockProvider("sendspin", mass=mock_mass)
94 protocol_player = PlayableMockPlayer(
95 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
96 )
97 protocol_player._attr_playback_state = protocol_state
98
99 controller._players = {"player_1": player, "proto_1": protocol_player}
100 player.set_linked_output_protocols(
101 [
102 LinkedOutputProtocol(
103 output_protocol_id="proto_1",
104 protocol_domain="sendspin",
105 priority=40,
106 )
107 ]
108 )
109 player.set_active_output_protocol("proto_1")
110 protocol_player.update_state(signal_event=False)
111 player.refresh_state(signal_event=False)
112 return player, protocol_player
113
114
115class TestPlayMediaProtocolSelection:
116 """A fresh playback start must re-select instead of reusing a stale protocol."""
117
118 async def test_idle_player_reselects_instead_of_stale_protocol(
119 self, mock_mass: MagicMock, controller: PlayerController
120 ) -> None:
121 """
122 An idle player with a leftover active protocol re-runs selection.
123
124 Regression: a Sonos grouped via AirPlay kept the AirPlay protocol pinned
125 after leaving the group, so the next standalone playback used AirPlay
126 instead of native playback.
127 """
128 player, protocol_player = _make_player_with_protocol(
129 mock_mass, controller, PlaybackState.IDLE
130 )
131 media = PlayerMedia(uri="http://test/stream")
132
133 await controller._handle_play_media("player_1", media)
134
135 assert player.play_media_calls == [media]
136 assert protocol_player.play_media_calls == []
137 assert player.active_output_protocol == "native"
138
139 async def test_playing_player_keeps_active_protocol(
140 self, mock_mass: MagicMock, controller: PlayerController
141 ) -> None:
142 """While a session is active the already-set protocol stays in use."""
143 player, protocol_player = _make_player_with_protocol(
144 mock_mass, controller, PlaybackState.PLAYING
145 )
146 media = PlayerMedia(uri="http://test/stream")
147
148 await controller._handle_play_media("player_1", media)
149
150 assert protocol_player.play_media_calls == [media]
151 assert player.play_media_calls == []
152 assert player.active_output_protocol == "proto_1"
153
154 async def test_unlinked_active_protocol_falls_back_to_selection(
155 self, mock_mass: MagicMock, controller: PlayerController
156 ) -> None:
157 """An active protocol that is no longer linked falls back to selection."""
158 player, protocol_player = _make_player_with_protocol(
159 mock_mass, controller, PlaybackState.PLAYING
160 )
161 # simulate the protocol link being removed while the id stays pinned
162 player.set_linked_output_protocols([])
163 player.refresh_state(signal_event=False)
164 media = PlayerMedia(uri="http://test/stream")
165
166 await controller._handle_play_media("player_1", media)
167
168 assert player.play_media_calls == [media]
169 assert protocol_player.play_media_calls == []
170 assert player.active_output_protocol == "native"
171
172 async def test_idle_player_reselects_best_protocol_without_native(
173 self, mock_mass: MagicMock, controller: PlayerController
174 ) -> None:
175 """Without native playback, re-selection picks the best protocol again."""
176 player, protocol_player = _make_player_with_protocol(
177 mock_mass, controller, PlaybackState.IDLE, native_play_media=False
178 )
179 media = PlayerMedia(uri="http://test/stream")
180
181 await controller._handle_play_media("player_1", media)
182
183 assert protocol_player.play_media_calls == [media]
184 assert player.play_media_calls == []
185 assert player.active_output_protocol == "proto_1"
186
187
188class TestCmdStopWithPinnedProtocol:
189 """Stop on an already-idle player must still release the pinned protocol."""
190
191 async def test_idle_stop_forwards_to_pinned_protocol(
192 self, mock_mass: MagicMock, controller: PlayerController
193 ) -> None:
194 """
195 Stop on an idle player with a pinned protocol stops the protocol player.
196
197 Regression: when the source stream ended on its own before the stop
198 command arrived, the early idle-return skipped the protocol stop, so the
199 device kept a stale session (reported "playing" while silent) and the
200 protocol stayed pinned forever.
201 """
202 player, protocol_player = _make_player_with_protocol(
203 mock_mass, controller, PlaybackState.IDLE
204 )
205 protocol_player.stop = AsyncMock() # type: ignore[method-assign]
206 player.stop = AsyncMock() # type: ignore[method-assign]
207 controller.schedule_active_output_protocol_clear = MagicMock() # type: ignore[method-assign]
208
209 await controller._handle_cmd_stop("player_1")
210
211 protocol_player.stop.assert_awaited_once()
212 player.stop.assert_not_awaited()
213 controller.schedule_active_output_protocol_clear.assert_called_once_with(player)
214
215 async def test_idle_stop_without_pinned_protocol_is_noop(
216 self, mock_mass: MagicMock, controller: PlayerController
217 ) -> None:
218 """Stop on an idle player without an active protocol does nothing."""
219 player, protocol_player = _make_player_with_protocol(
220 mock_mass, controller, PlaybackState.IDLE
221 )
222 player.set_active_output_protocol(None)
223 player.refresh_state(signal_event=False)
224 protocol_player.stop = AsyncMock() # type: ignore[method-assign]
225 player.stop = AsyncMock() # type: ignore[method-assign]
226 controller.schedule_active_output_protocol_clear = MagicMock() # type: ignore[method-assign]
227
228 await controller._handle_cmd_stop("player_1")
229
230 protocol_player.stop.assert_not_awaited()
231 player.stop.assert_not_awaited()
232 controller.schedule_active_output_protocol_clear.assert_not_called()
233
234 async def test_idle_stop_grouped_protocol_keeps_pin(
235 self, mock_mass: MagicMock, controller: PlayerController
236 ) -> None:
237 """A protocol player that still has group members keeps the pin."""
238 _player, protocol_player = _make_player_with_protocol(
239 mock_mass, controller, PlaybackState.IDLE
240 )
241 protocol_player._attr_group_members = ["proto_1", "proto_2"]
242 protocol_player.stop = AsyncMock() # type: ignore[method-assign]
243 controller.schedule_active_output_protocol_clear = MagicMock() # type: ignore[method-assign]
244
245 await controller._handle_cmd_stop("player_1")
246
247 protocol_player.stop.assert_awaited_once()
248 controller.schedule_active_output_protocol_clear.assert_not_called()
249
250 async def test_playing_stop_delegates_and_schedules_clear(
251 self, mock_mass: MagicMock, controller: PlayerController
252 ) -> None:
253 """The regular stop path (player playing) is unchanged."""
254 player, protocol_player = _make_player_with_protocol(
255 mock_mass, controller, PlaybackState.PLAYING
256 )
257 protocol_player.stop = AsyncMock() # type: ignore[method-assign]
258 player.stop = AsyncMock() # type: ignore[method-assign]
259 controller.schedule_active_output_protocol_clear = MagicMock() # type: ignore[method-assign]
260
261 await controller._handle_cmd_stop("player_1")
262
263 protocol_player.stop.assert_awaited_once()
264 player.stop.assert_not_awaited()
265 controller.schedule_active_output_protocol_clear.assert_called_once_with(player)
266