/
/
/
1"""Replay-based DACP compliance tests for the AirPlay handler."""
2
3from __future__ import annotations
4
5from unittest.mock import MagicMock
6
7import pytest
8
9from music_assistant.providers.airplay.provider import AirPlayProvider
10
11from .conftest import make_player
12from .helpers import build_dacp_request, replay
13
14pytestmark = pytest.mark.asyncio
15
16
17async def test_unknown_active_remote_is_ignored(
18 airplay_provider: AirPlayProvider, mock_mass: MagicMock
19) -> None:
20 """A request whose Active-Remote matches no player must be a no-op (and not crash)."""
21 make_player(mock_mass, active_remote="123")
22 raw = build_dacp_request("/ctrl-int/1/play", active_remote="999")
23
24 await replay(airplay_provider, raw)
25
26 mock_mass.players.cmd_play.assert_not_called()
27
28
29@pytest.mark.parametrize(
30 ("path", "cmd"),
31 [
32 ("/ctrl-int/1/playpause", "cmd_play_pause"),
33 ("/ctrl-int/1/stop", "cmd_stop"),
34 ("/ctrl-int/1/nextitem", "cmd_next_track"),
35 ("/ctrl-int/1/previtem", "cmd_previous_track"),
36 ("/ctrl-int/1/volumeup", "cmd_volume_up"),
37 ("/ctrl-int/1/volumedown", "cmd_volume_down"),
38 ],
39)
40async def test_transport_commands_dispatch(
41 airplay_provider: AirPlayProvider, mock_mass: MagicMock, path: str, cmd: str
42) -> None:
43 """Simple transport paths dispatch the matching player command with the player id."""
44 player = make_player(mock_mass)
45 await replay(airplay_provider, build_dacp_request(path))
46 getattr(mock_mass.players, cmd).assert_called_once_with(player.player_id)
47
48
49async def test_play_when_not_playing_dispatches_play(
50 airplay_provider: AirPlayProvider, mock_mass: MagicMock
51) -> None:
52 """/play starts playback when the player is not already playing."""
53 player = make_player(mock_mass, playing=False)
54 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/play"))
55 mock_mass.players.cmd_play.assert_called_once_with(player.player_id)
56
57
58async def test_play_when_already_playing_is_ignored(
59 airplay_provider: AirPlayProvider, mock_mass: MagicMock
60) -> None:
61 """/play is a confirmation echo and must be ignored when already playing."""
62 make_player(mock_mass, playing=True)
63 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/play"))
64 mock_mass.players.cmd_play.assert_not_called()
65
66
67async def test_pause_when_playing_dispatches_pause(
68 airplay_provider: AirPlayProvider, mock_mass: MagicMock
69) -> None:
70 """/pause pauses when the player is playing."""
71 player = make_player(mock_mass, playing=True)
72 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/pause"))
73 mock_mass.players.cmd_pause.assert_called_once_with(player.player_id)
74
75
76async def test_pause_when_not_playing_is_ignored(
77 airplay_provider: AirPlayProvider, mock_mass: MagicMock
78) -> None:
79 """/pause does nothing when the player is not playing."""
80 make_player(mock_mass, playing=False)
81 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/pause"))
82 mock_mass.players.cmd_pause.assert_not_called()
83
84
85async def test_shuffle_songs_toggles_queue_shuffle(
86 airplay_provider: AirPlayProvider, mock_mass: MagicMock
87) -> None:
88 """/shuffle_songs flips the active queue's shuffle state."""
89 make_player(mock_mass)
90 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/shuffle_songs"))
91 mock_mass.player_queues.set_shuffle.assert_called_once_with("q1", True)
92
93
94async def test_discrete_pause_schedules_debounced_pause(
95 airplay_provider: AirPlayProvider, mock_mass: MagicMock
96) -> None:
97 """/discrete-pause while playing schedules a debounced cmd_pause (not an immediate one)."""
98 player = make_player(mock_mass, playing=True)
99 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/discrete-pause"))
100
101 mock_mass.players.cmd_pause.assert_not_called()
102 mock_mass.call_later.assert_called_once_with(
103 1.0,
104 mock_mass.players.cmd_pause,
105 player.player_id,
106 task_id=f"debounced_pause_{player.player_id}",
107 )
108
109
110async def test_discrete_pause_when_not_playing_does_nothing(
111 airplay_provider: AirPlayProvider,
112 mock_mass: MagicMock,
113) -> None:
114 """/discrete-pause is ignored when the player is not playing."""
115 make_player(mock_mass, playing=False)
116 await replay(airplay_provider, build_dacp_request("/ctrl-int/1/discrete-pause"))
117 mock_mass.call_later.assert_not_called()
118
119
120async def test_prevent_playback_cancels_pending_discrete_pause(
121 airplay_provider: AirPlayProvider,
122 mock_mass: MagicMock,
123) -> None:
124 """device-prevent-playback=1 cancels the debounced pause timer for the player."""
125 player = make_player(mock_mass, playing=True)
126 await replay(
127 airplay_provider,
128 build_dacp_request("/ctrl-int/1/setproperty?device-prevent-playback=1"),
129 )
130 mock_mass.cancel_timer.assert_any_call(f"debounced_pause_{player.player_id}")
131
132
133async def test_device_volume_updates_volume(
134 airplay_provider: AirPlayProvider, mock_mass: MagicMock
135) -> None:
136 """dmcp.device-volume in dB updates the player volume via the converted 0-100 value."""
137 player = make_player(mock_mass)
138 # -15 dB maps to 50 on the 0-100 scale
139 raw = build_dacp_request("/ctrl-int/1/setproperty?dmcp.device-volume=-15.000000")
140 await replay(airplay_provider, raw)
141 player.update_volume_from_device.assert_called_once_with(50)
142
143
144async def test_device_volume_mute_threshold_mutes(
145 airplay_provider: AirPlayProvider, mock_mass: MagicMock
146) -> None:
147 """dmcp.device-volume at/below the mute threshold mutes the player and sends VOLUME=0."""
148 player = make_player(mock_mass)
149 raw = build_dacp_request("/ctrl-int/1/setproperty?dmcp.device-volume=-144.000000")
150 await replay(airplay_provider, raw)
151 assert player._attr_volume_muted is True
152 player.stream.send_cli_command.assert_called_once_with("VOLUME=0")
153
154
155async def test_device_volume_ignored_for_apple_device(
156 airplay_provider: AirPlayProvider, mock_mass: MagicMock
157) -> None:
158 """Apple devices echo our own volume; dmcp.device-volume must be ignored for them."""
159 player = make_player(mock_mass, manufacturer="Apple")
160 raw = build_dacp_request("/ctrl-int/1/setproperty?dmcp.device-volume=-15.000000")
161 await replay(airplay_provider, raw)
162 player.update_volume_from_device.assert_not_called()
163
164
165async def test_dmcp_volume_button_updates_volume(
166 airplay_provider: AirPlayProvider, mock_mass: MagicMock
167) -> None:
168 """dmcp.volume (hardware buttons) updates the player volume directly."""
169 player = make_player(mock_mass)
170 raw = build_dacp_request("/ctrl-int/1/setproperty?dmcp.volume=45")
171 await replay(airplay_provider, raw)
172 player.update_volume_from_device.assert_called_once_with(45)
173
174
175_PREVENT_1 = "/ctrl-int/1/setproperty?device-prevent-playback=1"
176_PREVENT_0 = "/ctrl-int/1/setproperty?device-prevent-playback=0"
177
178
179async def test_prevent_playback_stops_unsynced_stream(
180 airplay_provider: AirPlayProvider, mock_mass: MagicMock
181) -> None:
182 """prevent-playback=1 on an established, unsynced stream stops that stream."""
183 player = make_player(mock_mass, synced_to=None)
184 await replay(airplay_provider, build_dacp_request(_PREVENT_1))
185 assert player.stream.prevent_playback is True
186 player.stream.stop.assert_called_once()
187 mock_mass.players.cmd_ungroup.assert_not_called()
188
189
190async def test_prevent_playback_ungroups_synced_player(
191 airplay_provider: AirPlayProvider, mock_mass: MagicMock
192) -> None:
193 """prevent-playback=1 on a synced player ungroups instead of stopping the stream."""
194 player = make_player(mock_mass, synced_to="parent")
195 await replay(airplay_provider, build_dacp_request(_PREVENT_1))
196 mock_mass.players.cmd_ungroup.assert_called_once_with(player.player_id)
197 player.stream.stop.assert_not_called()
198
199
200async def test_prevent_playback_ignored_during_transition(
201 airplay_provider: AirPlayProvider,
202 mock_mass: MagicMock,
203) -> None:
204 """prevent-playback=1 during a stream transition is a stale message and ignored."""
205 player = make_player(mock_mass, transitioning=True)
206 await replay(airplay_provider, build_dacp_request(_PREVENT_1))
207 player.stream.stop.assert_not_called()
208 mock_mass.players.cmd_ungroup.assert_not_called()
209
210
211async def test_prevent_playback_ignored_when_stream_not_connected(
212 airplay_provider: AirPlayProvider,
213 mock_mass: MagicMock,
214) -> None:
215 """prevent-playback=1 before the stream is established (Denon transient) is ignored."""
216 player = make_player(mock_mass, stream_connected=False)
217 await replay(airplay_provider, build_dacp_request(_PREVENT_1))
218 player.stream.stop.assert_not_called()
219
220
221async def test_prevent_playback_ignored_when_already_set(
222 airplay_provider: AirPlayProvider, mock_mass: MagicMock
223) -> None:
224 """A duplicate prevent-playback=1 while already preventing is ignored."""
225 player = make_player(mock_mass, prevent_playback=True)
226 await replay(airplay_provider, build_dacp_request(_PREVENT_1))
227 player.stream.stop.assert_not_called()
228
229
230async def test_prevent_playback_zero_schedules_reset(
231 airplay_provider: AirPlayProvider, mock_mass: MagicMock
232) -> None:
233 """prevent-playback=0 schedules a debounced reset of the prevent_playback flag."""
234 player = make_player(mock_mass, prevent_playback=True)
235 await replay(airplay_provider, build_dacp_request(_PREVENT_0))
236 mock_mass.call_later.assert_called_once_with(
237 5,
238 setattr,
239 player.stream,
240 "prevent_playback",
241 False,
242 task_id=f"reset_prevent_playback_{player.player_id}",
243 )
244
245
246async def test_denon_transient_prevent_playback_pair_keeps_stream(
247 airplay_provider: AirPlayProvider, mock_mass: MagicMock
248) -> None:
249 """
250 A transient prevent-playback=1/=0 burst during RAOP setup must not tear down the stream.
251
252 Regression for a Denon AVR-X2700H that emits this pair while the stream is still being
253 established (https://github.com/music-assistant/support/issues/5033).
254 """
255 player = make_player(mock_mass, synced_to=None, stream_connected=False)
256
257 await replay(airplay_provider, build_dacp_request(_PREVENT_1))
258 await replay(airplay_provider, build_dacp_request(_PREVENT_0))
259
260 player.stream.stop.assert_not_called()
261 mock_mass.players.cmd_ungroup.assert_not_called()
262 assert player.stream.prevent_playback is False
263
264
265async def test_empty_request_returns_early(
266 airplay_provider: AirPlayProvider, mock_mass: MagicMock
267) -> None:
268 """An empty request (e.g. Phorus PS10 ack) is handled without dispatching anything."""
269 make_player(mock_mass)
270 await replay(airplay_provider, b"")
271 mock_mass.players.cmd_play.assert_not_called()
272
273
274async def test_headers_only_request_does_not_crash(
275 airplay_provider: AirPlayProvider, mock_mass: MagicMock
276) -> None:
277 """A request without a body terminator must parse and dispatch normally."""
278 player = make_player(mock_mass, playing=False)
279 raw = b"POST /ctrl-int/1/play HTTP/1.1\r\nActive-Remote: 123"
280 await replay(airplay_provider, raw)
281 mock_mass.players.cmd_play.assert_called_once_with(player.player_id)
282
283
284async def test_malformed_header_line_is_skipped(
285 airplay_provider: AirPlayProvider, mock_mass: MagicMock
286) -> None:
287 """A header line without a colon is skipped rather than crashing the handler."""
288 player = make_player(mock_mass, playing=False)
289 raw = (
290 b"POST /ctrl-int/1/play HTTP/1.1\r\n"
291 b"garbage-line-without-colon\r\n"
292 b"Active-Remote: 123\r\n\r\n"
293 )
294 await replay(airplay_provider, raw)
295 mock_mass.players.cmd_play.assert_called_once_with(player.player_id)
296
297
298async def test_unknown_path_returns_204_without_command(
299 airplay_provider: AirPlayProvider, mock_mass: MagicMock
300) -> None:
301 """An unrecognized path dispatches nothing but still returns HTTP 204."""
302 make_player(mock_mass)
303 response = await replay(airplay_provider, build_dacp_request("/ctrl-int/1/bogus"))
304 mock_mass.players.cmd_play.assert_not_called()
305 assert response.startswith(b"HTTP/1.0 204 No Content")
306
307
308async def test_handled_request_returns_204(
309 airplay_provider: AirPlayProvider, mock_mass: MagicMock
310) -> None:
311 """A handled request returns the spec-shaped HTTP 204 response."""
312 make_player(mock_mass)
313 response = await replay(airplay_provider, build_dacp_request("/ctrl-int/1/stop"))
314 assert response.startswith(b"HTTP/1.0 204 No Content")
315
316
317# These commands are documented in the community DACP spec
318# (https://openairplay.github.io/airplay-spec/audio/remote_control.html) but MA does
319# not implement them yet. The test locks in the current graceful no-op so the suite
320# covers the full spec without failing. When one is implemented, move it out of this
321# parametrize list into its own behavior test. See the spec doc "Known spec gaps".
322_UNHANDLED_SPEC_COMMANDS = [
323 "/ctrl-int/1/beginff",
324 "/ctrl-int/1/beginrew",
325 "/ctrl-int/1/mutetoggle",
326 "/ctrl-int/1/playresume",
327]
328
329
330@pytest.mark.parametrize("path", _UNHANDLED_SPEC_COMMANDS)
331async def test_documented_unhandled_commands_noop(
332 airplay_provider: AirPlayProvider, mock_mass: MagicMock, path: str
333) -> None:
334 """Spec commands MA does not implement dispatch nothing but still return HTTP 204."""
335 make_player(mock_mass)
336 response = await replay(airplay_provider, build_dacp_request(path))
337
338 players = mock_mass.players
339 for cmd in (
340 "cmd_play",
341 "cmd_pause",
342 "cmd_play_pause",
343 "cmd_stop",
344 "cmd_next_track",
345 "cmd_previous_track",
346 "cmd_volume_up",
347 "cmd_volume_down",
348 ):
349 getattr(players, cmd).assert_not_called()
350 assert response.startswith(b"HTTP/1.0 204 No Content")
351