/
/
/
1"""Tests for the AirPlay provider's dashboard registration and tvOS app launching."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, MagicMock, patch
6
7import pytest
8from music_assistant_models.enums import DashboardType
9from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError
10
11from music_assistant.controllers.dashboard.controller import DashboardController
12from music_assistant.providers.airplay.constants import TVOS_APP_BUNDLE_ID
13from music_assistant.providers.airplay.control_player import AirPlayControlPlayer
14from music_assistant.providers.airplay.dashboard import AirPlayDashboards
15from music_assistant.providers.airplay.player import GenericAirPlayPlayer
16
17PLAYER_ID = "ap1234567890ab"
18
19
20def _make_dashboards() -> AirPlayDashboards:
21 """Build an AirPlayDashboards instance with a fully mocked owning provider."""
22 provider = MagicMock()
23 provider.domain = "airplay"
24 provider.translation_owner = "provider.airplay"
25 provider.mass.players.get_player.return_value = None
26 provider.mass.dashboard.register_dashboard_handler.return_value = MagicMock()
27 return AirPlayDashboards(provider)
28
29
30def _make_player(
31 *,
32 player_id: str = PLAYER_ID,
33 manufacturer: str = "Apple",
34 model: str = "Apple TV 4K",
35 available: bool = True,
36 enabled: bool = True,
37 companion_connected: bool = True,
38 installed_apps: frozenset[str] | None = frozenset({TVOS_APP_BUNDLE_ID}),
39 display_name: str = "Living Room",
40) -> MagicMock:
41 """Build a control-player mock with configurable dashboard-eligibility state."""
42 player = MagicMock(spec=AirPlayControlPlayer)
43 player.player_id = player_id
44 player.display_name = display_name
45 player.available = available
46 player.enabled = enabled
47 player.companion_connected = companion_connected
48 player.device_info = MagicMock(manufacturer=manufacturer, model=model)
49 player.async_list_installed_app_ids = AsyncMock(return_value=installed_apps)
50 player.async_launch_app = AsyncMock()
51 player.wake = AsyncMock()
52 return player
53
54
55### Eligibility
56
57
58@pytest.mark.parametrize(
59 ("kwargs", "eligible"),
60 [
61 ({}, True),
62 ({"model": "HomePod Mini"}, False),
63 ({"companion_connected": False}, False),
64 ({"installed_apps": frozenset({"com.other.app"})}, False),
65 ({"installed_apps": None}, False),
66 ({"enabled": False}, False),
67 ({"available": False}, False),
68 ],
69 ids=[
70 "eligible-apple-tv",
71 "non-apple-tv-model",
72 "no-companion-connection",
73 "app-not-installed",
74 "app-list-unavailable",
75 "player-disabled",
76 "player-unavailable",
77 ],
78)
79async def test_eligibility_matrix(kwargs: dict[str, object], eligible: bool) -> None:
80 """Only an enabled, available, Companion-connected Apple TV with the app is exposed."""
81 dashboards = _make_dashboards()
82 player = _make_player(**kwargs) # type: ignore[arg-type]
83 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
84
85 await dashboards._async_reconcile(PLAYER_ID)
86
87 assert (PLAYER_ID in dashboards._unregister_callbacks) is eligible
88
89
90async def test_protocol_player_is_ineligible() -> None:
91 """A non-control (PROTOCOL) AirPlay player is never a dashboard endpoint."""
92 dashboards = _make_dashboards()
93 dashboards.mass.players.get_player.return_value = MagicMock( # type: ignore[attr-defined]
94 spec=GenericAirPlayPlayer
95 )
96
97 await dashboards._async_reconcile(PLAYER_ID)
98
99 assert PLAYER_ID not in dashboards._unregister_callbacks
100
101
102async def test_missing_player_is_ineligible() -> None:
103 """Reconciling a player that no longer exists drops any registration."""
104 dashboards = _make_dashboards()
105 dashboards.mass.players.get_player.return_value = None # type: ignore[attr-defined]
106
107 await dashboards._async_reconcile(PLAYER_ID)
108
109 assert PLAYER_ID not in dashboards._unregister_callbacks
110
111
112### Registration lifecycle
113
114
115async def test_eligible_player_registers_once() -> None:
116 """An eligible Apple TV registers exactly one dashboard endpoint."""
117 dashboards = _make_dashboards()
118 player = _make_player(display_name="Living Room")
119 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
120
121 await dashboards._async_reconcile(PLAYER_ID)
122
123 handler = dashboards.mass.dashboard.register_dashboard_handler
124 handler.assert_called_once() # type: ignore[attr-defined]
125 device = handler.call_args[0][0] # type: ignore[attr-defined]
126 assert device.dashboard_id == f"airplay_{PLAYER_ID}"
127 assert device.name == "Living Room"
128 assert device.supported_types == {
129 DashboardType.NOW_PLAYING,
130 DashboardType.PARTY,
131 DashboardType.MUSIC_QUIZ,
132 }
133 assert device.provider_domain_hint == "airplay"
134
135
136async def test_availability_loss_unregisters() -> None:
137 """Losing availability after registration unregisters the endpoint."""
138 dashboards = _make_dashboards()
139 player = _make_player()
140 unregister = MagicMock()
141 dashboards.mass.dashboard.register_dashboard_handler.return_value = unregister # type: ignore[attr-defined]
142 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
143 await dashboards._async_reconcile(PLAYER_ID)
144 assert PLAYER_ID in dashboards._unregister_callbacks
145
146 player.available = False
147 await dashboards._async_reconcile(PLAYER_ID)
148
149 unregister.assert_called_once()
150 assert PLAYER_ID not in dashboards._unregister_callbacks
151
152
153async def test_unregister_calls_callback_and_clears_state() -> None:
154 """Unregister drops the controller registration and all cached state."""
155 dashboards = _make_dashboards()
156 player = _make_player()
157 unregister = MagicMock()
158 dashboards.mass.dashboard.register_dashboard_handler.return_value = unregister # type: ignore[attr-defined]
159 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
160 await dashboards._async_reconcile(PLAYER_ID)
161
162 dashboards.unregister(PLAYER_ID)
163
164 unregister.assert_called_once()
165 assert PLAYER_ID not in dashboards._unregister_callbacks
166 assert PLAYER_ID not in dashboards._installed_apps
167 assert PLAYER_ID not in dashboards._companion_connected
168
169
170async def test_unload_unregisters_all_endpoints() -> None:
171 """Unload unregisters every endpoint and blocks further scheduling."""
172 dashboards = _make_dashboards()
173 player = _make_player()
174 unregister = MagicMock()
175 dashboards.mass.dashboard.register_dashboard_handler.return_value = unregister # type: ignore[attr-defined]
176 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
177 await dashboards._async_reconcile(PLAYER_ID)
178
179 await dashboards.unload()
180
181 unregister.assert_called_once()
182 assert dashboards._unloaded
183 assert not dashboards._unregister_callbacks
184 dashboards.reconcile(PLAYER_ID)
185 dashboards.mass.create_task.assert_not_called() # type: ignore[attr-defined]
186
187
188async def test_app_list_refetched_on_companion_reconnect() -> None:
189 """The installed-app cache is reused between reconciles but refreshed on a reconnect."""
190 dashboards = _make_dashboards()
191 player = _make_player()
192 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
193
194 await dashboards._async_reconcile(PLAYER_ID)
195 assert player.async_list_installed_app_ids.await_count == 1
196 assert PLAYER_ID in dashboards._unregister_callbacks
197
198 # a second reconcile with no connection change reuses the cache
199 await dashboards._async_reconcile(PLAYER_ID)
200 assert player.async_list_installed_app_ids.await_count == 1
201
202 # a Companion drop unregisters and invalidates the cache
203 player.companion_connected = False
204 await dashboards._async_reconcile(PLAYER_ID)
205 assert PLAYER_ID not in dashboards._unregister_callbacks
206
207 # the reconnect edge refetches the app list
208 player.companion_connected = True
209 await dashboards._async_reconcile(PLAYER_ID)
210 assert player.async_list_installed_app_ids.await_count == 2
211 assert PLAYER_ID in dashboards._unregister_callbacks
212
213
214### setup_player / reconcile scheduling
215
216
217def test_setup_player_wires_companion_callback() -> None:
218 """setup_player schedules a reconcile and wires the Companion state callback."""
219 dashboards = _make_dashboards()
220 player = _make_player()
221
222 with patch.object(dashboards, "reconcile") as mock_reconcile:
223 dashboards.setup_player(player)
224 mock_reconcile.assert_called_once_with(player.player_id)
225 # the wired callback triggers a further reconcile
226 assert player.on_companion_state_change is not None
227 player.on_companion_state_change()
228 assert mock_reconcile.call_count == 2
229
230
231def test_reconcile_schedules_background_task() -> None:
232 """Reconcile schedules a deduplicated background reconcile task."""
233 dashboards = _make_dashboards()
234
235 dashboards.reconcile(PLAYER_ID)
236
237 create_task = dashboards.mass.create_task
238 create_task.assert_called_once() # type: ignore[attr-defined]
239 args, kwargs = create_task.call_args # type: ignore[attr-defined]
240 assert args[0] == dashboards._async_reconcile
241 assert args[1] == PLAYER_ID
242 assert kwargs["abort_existing"] is True
243
244
245### on_show / on_hide
246
247
248async def test_on_show_builds_contract_launch_uri() -> None:
249 """on_show resolves the local url and launches the exact contract launch URI."""
250 dashboards = _make_dashboards()
251 player = _make_player(player_id=PLAYER_ID, display_name="Living Room")
252 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
253 target = (
254 "http://192.168.1.10:8095/?dashboard=ABC123&path=%2Fnow-playing%3Fplayer%3Dap1234567890ab"
255 )
256 dashboards.mass.dashboard.resolve_dashboard_url = AsyncMock(return_value=target) # type: ignore[method-assign]
257
258 await dashboards._on_show(PLAYER_ID, DashboardType.NOW_PLAYING, PLAYER_ID)
259
260 dashboards.mass.dashboard.resolve_dashboard_url.assert_awaited_once_with(
261 DashboardType.NOW_PLAYING, PLAYER_ID, prefer_local=True
262 )
263 expected = (
264 "musicassistant://dashboard/show?v=1&type=now_playing"
265 "&target=http%3A%2F%2F192.168.1.10%3A8095%2F%3Fdashboard%3DABC123"
266 "%26path%3D%252Fnow-playing%253Fplayer%253Dap1234567890ab"
267 "&dashboard_id=airplay_ap1234567890ab"
268 )
269 player.async_launch_app.assert_awaited_once_with(expected)
270
271
272async def test_on_show_party_launch_uri_needs_no_player() -> None:
273 """A dashboard that is not player-scoped launches through the real controller route."""
274 controller = DashboardController.__new__(DashboardController)
275 controller.mass = MagicMock()
276 controller.mass.webserver.base_url = "http://192.168.1.10:8095"
277 controller.logger = MagicMock()
278 controller._dashboards = {}
279 controller._sessions = {}
280
281 dashboards = _make_dashboards()
282 dashboards.mass.dashboard = controller
283 player = _make_player()
284 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
285 dashboards._register(player)
286 dashboard_id = f"airplay_{PLAYER_ID}"
287
288 with patch.object(DashboardController, "_get_dashboard_code", AsyncMock(return_value="ABC123")):
289 await controller.show_dashboard(dashboard_id, DashboardType.PARTY)
290
291 expected = (
292 "musicassistant://dashboard/show?v=1&type=party"
293 "&target=http%3A%2F%2F192.168.1.10%3A8095%3Fdashboard%3DABC123%26path%3D%252Fparty"
294 f"&dashboard_id={dashboard_id}"
295 )
296 player.async_launch_app.assert_awaited_once_with(expected)
297 assert controller._sessions[dashboard_id].player_id is None
298
299
300async def test_on_show_wakes_before_launching() -> None:
301 """on_show wakes the device before launching the app."""
302 dashboards = _make_dashboards()
303 player = _make_player()
304 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
305 dashboards.mass.dashboard.resolve_dashboard_url = AsyncMock( # type: ignore[method-assign]
306 return_value="http://192.168.1.10:8095/?dashboard=ABC123&path=%2Fnow-playing%3Fplayer%3Dap"
307 )
308 order = MagicMock()
309 order.attach_mock(player.wake, "wake")
310 order.attach_mock(player.async_launch_app, "launch")
311
312 await dashboards._on_show(PLAYER_ID, DashboardType.NOW_PLAYING, PLAYER_ID)
313
314 assert [call[0] for call in order.mock_calls] == ["wake", "launch"]
315
316
317async def test_on_show_launch_failure_raises_player_unavailable() -> None:
318 """A failed launch RPC surfaces as a translated PlayerUnavailableError."""
319 dashboards = _make_dashboards()
320 player = _make_player(display_name="Living Room")
321 player.async_launch_app = AsyncMock(side_effect=PlayerCommandFailed("boom"))
322 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
323 dashboards.mass.dashboard.resolve_dashboard_url = AsyncMock( # type: ignore[method-assign]
324 return_value="http://192.168.1.10:8095/?dashboard=ABC123&path=%2Fnow-playing%3Fplayer%3Dap"
325 )
326
327 with pytest.raises(PlayerUnavailableError) as exc_info:
328 await dashboards._on_show(PLAYER_ID, DashboardType.NOW_PLAYING, PLAYER_ID)
329
330 assert exc_info.value.translation_key == "show_dashboard_failed"
331 assert exc_info.value.translation_owner == "provider.airplay"
332 assert exc_info.value.translation_args == ["Living Room"]
333
334
335async def test_on_show_missing_player_raises() -> None:
336 """on_show for a player that vanished raises PlayerUnavailableError."""
337 dashboards = _make_dashboards()
338 dashboards.mass.players.get_player.return_value = None # type: ignore[attr-defined]
339
340 with pytest.raises(PlayerUnavailableError):
341 await dashboards._on_show(PLAYER_ID, DashboardType.NOW_PLAYING, PLAYER_ID)
342
343
344async def test_on_hide_performs_no_device_action() -> None:
345 """Hide is session-driven: the adapter performs no Companion action."""
346 dashboards = _make_dashboards()
347 player = _make_player()
348 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
349
350 await dashboards._on_hide(PLAYER_ID)
351
352 dashboards.mass.players.get_player.assert_not_called() # type: ignore[attr-defined]
353 player.wake.assert_not_awaited()
354 player.async_launch_app.assert_not_awaited()
355
356
357async def test_launch_failure_leaves_no_controller_session() -> None:
358 """A launch failure propagates through the real controller without leaving a session."""
359 controller = DashboardController.__new__(DashboardController)
360 controller.mass = MagicMock()
361 controller.logger = MagicMock()
362 controller._dashboards = {}
363 controller._sessions = {}
364 controller.resolve_dashboard_url = AsyncMock( # type: ignore[method-assign]
365 return_value="http://192.168.1.10:8095/?dashboard=ABC123&path=%2Fnow-playing%3Fplayer%3Dap"
366 )
367
368 dashboards = _make_dashboards()
369 dashboards.mass.dashboard = controller
370 player = _make_player()
371 player.async_launch_app = AsyncMock(side_effect=PlayerCommandFailed("boom"))
372 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
373 dashboards._register(player)
374 dashboard_id = f"airplay_{PLAYER_ID}"
375 assert dashboard_id in controller._dashboards
376
377 with pytest.raises(PlayerUnavailableError):
378 await controller.show_dashboard(
379 dashboard_id, DashboardType.NOW_PLAYING, player_id=PLAYER_ID
380 )
381
382 assert dashboard_id not in controller._sessions
383
384
385async def test_reconcile_after_unload_does_not_register() -> None:
386 """A provider unload during the eligibility await must not resurrect an endpoint."""
387 dashboards = _make_dashboards()
388 player = _make_player()
389 dashboards.mass.players.get_player.return_value = player # type: ignore[attr-defined]
390
391 async def _eligible_then_unload(_player: AirPlayControlPlayer) -> bool:
392 dashboards._unloaded = True
393 return True
394
395 with patch.object(dashboards, "_is_eligible", side_effect=_eligible_then_unload):
396 await dashboards._async_reconcile(PLAYER_ID)
397
398 assert PLAYER_ID not in dashboards._unregister_callbacks
399