/
/
/
1"""Unit tests for the AirPlay Receiver provider (ports + daemon reconciliation)."""
2
3from __future__ import annotations
4
5import asyncio
6from dataclasses import dataclass
7from typing import cast
8from unittest.mock import AsyncMock, MagicMock
9
10from music_assistant_models.enums import EventType
11from music_assistant_models.event import MassEvent
12
13from music_assistant.providers.airplay_receiver import (
14 AirPlayReceiverProvider,
15 airplay_receiver_ports,
16)
17
18# --- Port derivation -----------------------------------------------------------
19
20
21def test_airplay_receiver_ports_are_deterministic() -> None:
22 """The port derivation must be stable across processes/restarts (unlike ``hash()``)."""
23 # pinned expectations guard against accidental changes to the derivation:
24 # the AirPlay provider relies on reproducing these ports from config alone
25 assert airplay_receiver_ports("airplay_receiver", ["player-a", "player-b"]) == {
26 "player-a": 7765,
27 "player-b": 7158,
28 }
29
30
31def test_airplay_receiver_ports_stay_in_expected_range_and_unique() -> None:
32 """Derived ports stay within the 7000-7999 AirPlay range and never collide."""
33 ports = airplay_receiver_ports("airplay_receiver", [f"player{index}" for index in range(50)])
34 assert all(7000 <= port <= 7999 for port in ports.values())
35 assert len(set(ports.values())) == 50
36
37
38def test_airplay_receiver_ports_deduplicates_player_ids() -> None:
39 """A duplicated player id claims a single port instead of probing extra slots."""
40 ports = airplay_receiver_ports("airplay_receiver", ["player-a", "player-a"])
41 assert ports == {"player-a": 7765}
42
43
44def test_airplay_receiver_ports_collision_probing_is_order_independent() -> None:
45 """Colliding derivations probe deterministically regardless of the input order."""
46 # player22 and player23 both derive base port 7525 for this instance id
47 colliding = ["player22", "player23"]
48 ports = airplay_receiver_ports("airplay_receiver", colliding)
49 assert ports == {"player22": 7525, "player23": 7526}
50 assert airplay_receiver_ports("airplay_receiver", list(reversed(colliding))) == ports
51
52
53# --- Daemon reconciliation -----------------------------------------------------
54
55
56@dataclass
57class _ReconcileMocks:
58 """The mocked collaborators of a reconcile-test provider."""
59
60 mass: MagicMock
61 start_receiver: MagicMock
62 stop_receiver: AsyncMock
63
64
65def _reconcile_provider(
66 assigned: tuple[str, ...],
67 registered: dict[str, str],
68) -> tuple[AirPlayReceiverProvider, _ReconcileMocks]:
69 """
70 Build a bare provider with the real reconcile logic and mocked daemon control.
71
72 :param assigned: The connected player ids the provider was loaded with.
73 :param registered: Currently registered player ids mapped to their display name.
74 """
75 prov = AirPlayReceiverProvider.__new__(AirPlayReceiverProvider)
76 prov.logger = MagicMock()
77 prov.config = MagicMock()
78 prov.mass = mass = MagicMock()
79 prov._daemons = {}
80 prov._failed_player_ids = set()
81 prov._reconcile_lock = asyncio.Lock()
82 prov._unload_called = False
83 prov._unsubscribe = None
84 prov._assigned_player_ids = assigned
85 prov.get_config_value = MagicMock(return_value="player_mass") # type: ignore[method-assign]
86
87 def get_player(player_id: str) -> MagicMock | None:
88 if player_id not in registered:
89 return None
90 player = MagicMock()
91 player.player_id = player_id
92 player.display_name = registered[player_id]
93 return player
94
95 mass.players.get_player.side_effect = get_player
96
97 def start_receiver(player: MagicMock, airplay_name: str) -> None:
98 # stop_called / active_player_id are spelled out: a bare MagicMock attribute is
99 # truthy, which would trip the stopped-daemon guard and the deselect path
100 prov._daemons[player.player_id] = MagicMock(
101 player_id=player.player_id,
102 airplay_name=airplay_name,
103 stop_called=False,
104 active_player_id=None,
105 )
106
107 start_mock = MagicMock(side_effect=start_receiver)
108 stop_mock = AsyncMock()
109 prov._start_receiver = start_mock # type: ignore[method-assign]
110 prov._stop_receiver = stop_mock # type: ignore[method-assign]
111 return prov, _ReconcileMocks(mass=mass, start_receiver=start_mock, stop_receiver=stop_mock)
112
113
114async def test_reconcile_starts_daemon_when_assigned_player_registers() -> None:
115 """A daemon starts only once its connected player has actually registered."""
116 registered: dict[str, str] = {}
117 prov, mocks = _reconcile_provider(("p1",), registered)
118
119 # cold boot: the player has not registered yet, so nothing starts
120 await prov._reconcile()
121 mocks.start_receiver.assert_not_called()
122
123 registered["p1"] = "Kitchen"
124 await prov._reconcile()
125 mocks.start_receiver.assert_called_once()
126 assert mocks.start_receiver.call_args.args[1] == "Kitchen | Music Assistant"
127 assert "p1" in prov._daemons
128
129
130async def test_reconcile_restarts_daemon_on_advertised_name_drift() -> None:
131 """A renamed player gets its daemon restarted with the new advertised name."""
132 registered = {"p1": "Kitchen"}
133 prov, mocks = _reconcile_provider(("p1",), registered)
134 await prov._reconcile()
135 old_daemon = prov._daemons["p1"]
136
137 # a second pass without changes is a no-op
138 await prov._reconcile()
139 mocks.stop_receiver.assert_not_awaited()
140 assert mocks.start_receiver.call_count == 1
141
142 # a live session on the old daemon is released before the daemon is replaced
143 old_daemon.active_player_id = "consumer"
144 registered["p1"] = "Cellar"
145 await prov._reconcile()
146 mocks.stop_receiver.assert_awaited_once_with(old_daemon)
147 assert prov._daemons["p1"].airplay_name == "Cellar | Music Assistant"
148 mocks.mass.players.deselect_source.assert_called_once()
149 assert mocks.mass.players.deselect_source.call_args.args[0] == "consumer"
150
151
152async def test_reconcile_keeps_daemon_for_temporarily_unavailable_player() -> None:
153 """A temporarily unregistered player keeps its running daemon (stable identity)."""
154 registered = {"p1": "Kitchen"}
155 prov, mocks = _reconcile_provider(("p1",), registered)
156 await prov._reconcile()
157 daemon = prov._daemons["p1"]
158
159 registered.clear()
160 await prov._reconcile()
161 mocks.stop_receiver.assert_not_awaited()
162 assert prov._daemons["p1"] is daemon
163
164
165async def test_player_removed_event_stops_daemon() -> None:
166 """A permanently removed player gets its daemon stopped and dropped."""
167 registered = {"p1": "Kitchen"}
168 prov, mocks = _reconcile_provider(("p1",), registered)
169 await prov._reconcile()
170 daemon = prov._daemons["p1"]
171
172 await prov._on_player_event(MassEvent(event=EventType.PLAYER_REMOVED, object_id="p1"))
173 mocks.stop_receiver.assert_awaited_once_with(daemon)
174 assert not prov._daemons
175
176
177async def test_player_added_event_triggers_reconcile() -> None:
178 """A player registering (cold boot path) starts its daemon via the event handler."""
179 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
180
181 await prov._on_player_event(MassEvent(event=EventType.PLAYER_ADDED, object_id="p1"))
182 mocks.start_receiver.assert_called_once()
183
184
185async def test_loaded_in_mass_with_empty_connected_players_is_idle() -> None:
186 """An empty connected-players selection loads the provider fully idle."""
187 prov, mocks = _reconcile_provider((), {})
188
189 await prov.loaded_in_mass()
190 mocks.mass.subscribe.assert_not_called()
191 mocks.start_receiver.assert_not_called()
192 assert not prov._daemons
193
194
195async def test_loaded_in_mass_subscribes_to_assigned_players_only() -> None:
196 """Player events are only watched for the connected players."""
197 prov, mocks = _reconcile_provider(("p1", "p2"), {})
198
199 await prov.loaded_in_mass()
200 mocks.mass.subscribe.assert_called_once()
201 assert mocks.mass.subscribe.call_args.kwargs["id_filter"] == ("p1", "p2")
202
203
204async def test_get_player_audio_sources_scopes_to_the_daemon_player() -> None:
205 """Each receiver's source is bound to its own connected player only."""
206 prov, _mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
207 await prov._reconcile()
208 daemon = prov._daemons["p1"]
209
210 assert prov.get_player_audio_sources("p1") == [daemon.audio_source]
211 assert prov.get_player_audio_sources("p2") == []
212
213
214async def test_unload_stops_all_daemons() -> None:
215 """Unload stops every running daemon and stops watching player events."""
216 registered = {"p1": "Kitchen", "p2": "Garage"}
217 prov, mocks = _reconcile_provider(("p1", "p2"), registered)
218 await prov._reconcile()
219 unsubscribe = MagicMock()
220 prov._unsubscribe = unsubscribe
221
222 await prov.unload()
223 unsubscribe.assert_called_once()
224 assert mocks.stop_receiver.await_count == 2
225 assert not prov._daemons
226
227
228async def test_give_up_receiver_stops_only_the_failed_daemon() -> None:
229 """A permanently failed receiver is dropped while the other receivers keep running."""
230 prov, mocks = _reconcile_provider(("p1", "p2"), {"p1": "Kitchen", "p2": "Garage"})
231 await prov._reconcile()
232 daemon = prov._daemons["p1"]
233 daemon.active_player_id = "consumer"
234
235 await prov._give_up_receiver(daemon, "shairport-sync daemon failed to start multiple times.")
236
237 assert "p1" not in prov._daemons
238 assert "p2" in prov._daemons
239 mocks.stop_receiver.assert_awaited_once_with(daemon)
240 assert prov._failed_player_ids == {"p1"}
241 mocks.mass.players.trigger_player_update.assert_called_with("p1")
242 mocks.mass.players.deselect_source.assert_called_once()
243 cast("MagicMock", prov.logger).warning.assert_called_once()
244
245
246async def test_reconcile_skips_a_given_up_receiver() -> None:
247 """A receiver that gave up permanently is not relaunched by an ordinary reconcile."""
248 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
249 prov._failed_player_ids = {"p1"}
250
251 await prov._reconcile()
252
253 mocks.start_receiver.assert_not_called()
254 assert "p1" not in prov._daemons
255
256
257async def test_player_added_gives_a_failed_receiver_a_fresh_start() -> None:
258 """A player re-registering lifts the block and starts its receiver again."""
259 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
260 prov._failed_player_ids = {"p1"}
261
262 await prov._on_player_event(MassEvent(event=EventType.PLAYER_ADDED, object_id="p1"))
263
264 assert "p1" not in prov._failed_player_ids
265 mocks.start_receiver.assert_called_once()
266 assert "p1" in prov._daemons
267
268
269async def test_give_up_on_a_replaced_receiver_is_a_noop() -> None:
270 """A give-up landing after the receiver was replaced leaves the replacement running."""
271 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
272 await prov._reconcile()
273 old_daemon = prov._daemons["p1"]
274 replacement = MagicMock(player_id="p1", airplay_name="Kitchen | Music Assistant")
275 prov._daemons["p1"] = replacement
276
277 await prov._give_up_receiver(old_daemon, "boom")
278
279 mocks.stop_receiver.assert_not_awaited()
280 assert not prov._failed_player_ids
281 assert prov._daemons["p1"] is replacement
282
283
284async def test_runner_exit_retries_before_the_restart_budget_is_spent() -> None:
285 """A daemon exit within the restart budget schedules a delayed restart."""
286 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
287 await prov._reconcile()
288 daemon = prov._daemons["p1"]
289 daemon.started = asyncio.Event()
290 daemon.started.set()
291 daemon.runner_error_count = 0
292
293 prov._handle_runner_exit(daemon)
294
295 assert daemon.runner_error_count == 1
296 mocks.mass.call_later.assert_called_once_with(2, prov._setup_shairport_daemon, daemon)
297
298
299async def test_runner_exit_gives_up_once_the_restart_budget_is_spent() -> None:
300 """Exhausting the restart budget gives up this receiver instead of unloading."""
301 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
302 unload_with_error = MagicMock()
303 prov.unload_with_error = unload_with_error # type: ignore[method-assign]
304 await prov._reconcile()
305 daemon = prov._daemons["p1"]
306 daemon.started = asyncio.Event()
307 daemon.started.set()
308 daemon.runner_error_count = 5
309
310 prov._handle_runner_exit(daemon)
311 # the give-up runs as a deferred task so it never stops its own runner task
312 assert mocks.mass.create_task.call_args.kwargs == {"eager_start": False}
313 await mocks.mass.create_task.call_args.args[0]
314
315 assert "p1" not in prov._daemons
316 assert prov._failed_player_ids == {"p1"}
317 unload_with_error.assert_not_called()
318
319
320async def test_runner_exit_without_any_output_unloads_the_provider() -> None:
321 """A daemon that never produced any output is an environment-level provider failure."""
322 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
323 unload_with_error = MagicMock()
324 prov.unload_with_error = unload_with_error # type: ignore[method-assign]
325 await prov._reconcile()
326 daemon = prov._daemons["p1"]
327 daemon.started = asyncio.Event()
328
329 prov._handle_runner_exit(daemon)
330
331 unload_with_error.assert_called_once()
332 mocks.mass.create_task.assert_not_called()
333
334
335async def test_runner_exit_after_a_deliberate_stop_does_nothing() -> None:
336 """A deliberately stopped receiver never restarts, gives up or unloads."""
337 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
338 unload_with_error = MagicMock()
339 prov.unload_with_error = unload_with_error # type: ignore[method-assign]
340 await prov._reconcile()
341 daemon = prov._daemons["p1"]
342 daemon.started = asyncio.Event()
343 daemon.stop_called = True
344
345 prov._handle_runner_exit(daemon)
346
347 unload_with_error.assert_not_called()
348 mocks.mass.create_task.assert_not_called()
349 mocks.mass.call_later.assert_not_called()
350
351
352def test_a_fatal_log_line_marks_the_daemon_started() -> None:
353 """A fatal-error log line still proves the binary runs, routing exits to the restart path."""
354 prov, _mocks = _reconcile_provider(("p1",), {})
355 daemon = MagicMock(started=asyncio.Event())
356
357 prov._process_shairport_log_line(daemon, "fatal error: Could not bind any listening ports")
358
359 assert daemon.started.is_set()
360