/
/
/
1"""Tests for MSXHTTPServer routes."""
2
3from __future__ import annotations
4
5import re
6import shutil
7import subprocess
8from collections.abc import AsyncGenerator
9from typing import TYPE_CHECKING, Any
10from unittest.mock import AsyncMock, MagicMock, Mock, patch
11from urllib.parse import urlsplit
12
13import pytest
14from aiohttp.test_utils import TestClient as AiohttpTestClient
15from aiohttp.test_utils import TestServer
16from music_assistant_models.enums import PlaybackState
17from music_assistant_models.player import PlayerMedia
18
19from music_assistant.providers.msx_bridge.http_server import STATIC_DIR, MSXHTTPServer
20from music_assistant.providers.msx_bridge.mappers import map_track_to_msx
21from music_assistant.providers.msx_bridge.player import MSXPlayer
22from music_assistant.providers.msx_bridge.provider import MSXBridgeProvider
23
24if TYPE_CHECKING:
25 from aiohttp.test_utils import TestClient
26
27# --- Bootstrap and CORS ---
28
29
30async def test_health(http_client: TestClient[Any, Any]) -> None:
31 """GET /health should return 200 with status ok."""
32 resp = await http_client.get("/health")
33 assert resp.status == 200
34 data = await resp.json()
35 assert data["status"] == "ok"
36 assert data["provider"] == "msx_bridge"
37
38
39async def test_root_html(http_client: TestClient[Any, Any]) -> None:
40 """GET / should return 200 with text/html content."""
41 resp = await http_client.get("/")
42 assert resp.status == 200
43 assert "text/html" in resp.headers["Content-Type"]
44 body = await resp.text()
45 assert "MSX" in body
46
47
48async def test_root_html_escapes_host_header(http_client: TestClient[Any, Any]) -> None:
49 """A crafted Host header must not be reflected unescaped (XSS)."""
50 resp = await http_client.get("/", headers={"Host": 'evil"><script>alert(1)</script>'})
51 assert resp.status == 200
52 body = await resp.text()
53 assert "<script>alert(1)</script>" not in body
54 # the quote must be escaped so the host can't break out of href attributes
55 assert 'evil">' not in body
56
57
58async def test_root_html_sendspin_urls_escaped(http_client: TestClient[Any, Any]) -> None:
59 """Generated sendspin hrefs must be HTML-escaped as a whole, including & separators."""
60 resp = await http_client.get("/")
61 assert resp.status == 200
62 body = await resp.text()
63 assert "&sendspin_url=http%3A%2F%2F" in body
64 assert "&sendspin_url=http%3A%2F%2F" not in body
65
66
67async def test_start_json(http_client: TestClient[Any, Any]) -> None:
68 """GET /msx/start.json should return launcher menu config."""
69 resp = await http_client.get("/msx/start.json")
70 assert resp.status == 200
71 data = await resp.json()
72 assert data["name"] == "Music Assistant"
73 assert data["parameter"].startswith("content:")
74 assert "/msx/launcher.json" in data["parameter"]
75 assert "scripts" not in data
76
77
78async def test_plugin_html(http_client: TestClient[Any, Any]) -> None:
79 """GET /msx/plugin.html should return HTML with interaction plugin."""
80 resp = await http_client.get("/msx/plugin.html")
81 assert resp.status == 200
82 assert "text/html" in resp.headers["Content-Type"]
83 body = await resp.text()
84 assert "tvx.InteractionPlugin" in body
85 assert "handleRequest" in body
86 assert resp.headers.get("Cache-Control") == "no-cache, no-store, must-revalidate"
87
88
89async def test_tvx_lib(http_client: TestClient[Any, Any]) -> None:
90 """GET /msx/tvx-plugin-module.min.js should return JS library."""
91 resp = await http_client.get("/msx/tvx-plugin-module.min.js")
92 assert resp.status == 200
93 assert "javascript" in resp.headers["Content-Type"]
94
95
96async def test_cors_headers(http_client: TestClient[Any, Any]) -> None:
97 """Responses should include CORS Access-Control-Allow-Origin header."""
98 resp = await http_client.get("/health")
99 assert resp.headers.get("Access-Control-Allow-Origin") == "*"
100
101
102# --- Stream proxy ---
103
104
105async def test_stream_player_not_found(http_client: TestClient[Any, Any]) -> None:
106 """GET /stream/nonexistent should return 404."""
107 resp = await http_client.get("/stream/nonexistent")
108 assert resp.status == 404
109
110
111async def test_stream_no_media(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
112 """GET /stream/{id} should return 404 when player has no current media."""
113 mock_player = Mock(spec=MSXPlayer)
114 mock_player.current_media = None
115 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = mock_player
116
117 server = MSXHTTPServer(provider, 0)
118 client = AiohttpTestClient(TestServer(server.app))
119 await client.start_server()
120 try:
121 resp = await client.get("/stream/msx_test")
122 assert resp.status == 404
123 body = await resp.text()
124 assert "No active stream" in body
125 finally:
126 await client.close()
127
128
129async def test_stream_not_msx_player(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
130 """GET /stream/{id} should return 404 for a non-MSX player."""
131 # Return a plain Mock (not spec=MSXPlayer)
132 non_msx_player = Mock()
133 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = non_msx_player
134
135 server = MSXHTTPServer(provider, 0)
136 client = AiohttpTestClient(TestServer(server.app))
137 await client.start_server()
138 try:
139 resp = await client.get("/stream/other_player")
140 assert resp.status == 404
141 body = await resp.text()
142 assert "Player not found" in body
143 finally:
144 await client.close()
145
146
147@pytest.mark.skip(reason="stream test hangs with TestClient/streaming on some platforms")
148async def test_stream_success(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
149 """GET /stream/{id} should stream audio via internal API."""
150 mock_player = Mock(spec=MSXPlayer)
151 mock_media = Mock()
152 mock_media.duration = 180
153 mock_media.source_id = None
154 mock_media.queue_item_id = None
155 mock_player.current_media = mock_media
156 mock_player.output_format = "mp3"
157 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = mock_player
158
159 # Mock get_stream to return an async generator
160 mass_mock.streams = Mock()
161 mass_mock.streams.get_stream = Mock(return_value=_async_iter([b"pcm-data"]))
162
163 server = MSXHTTPServer(provider, 0)
164 client = AiohttpTestClient(TestServer(server.app))
165 await client.start_server()
166 try:
167 chunks = [b"encoded-chunk-1", b"encoded-chunk-2"]
168 with patch(
169 "music_assistant.providers.msx_bridge.http_server.get_ffmpeg_stream",
170 return_value=_async_iter(chunks),
171 ):
172 resp = await client.get("/stream/msx_test")
173 assert resp.status == 200
174 assert resp.headers["Content-Type"] == "audio/mpeg"
175 body = await resp.read()
176 assert b"encoded-chunk-1" in body
177 assert b"encoded-chunk-2" in body
178 finally:
179 await client.close()
180
181
182# --- Library API ---
183
184
185async def test_albums(http_client: TestClient[Any, Any]) -> None:
186 """GET /api/albums should return items list."""
187 resp = await http_client.get("/api/albums")
188 assert resp.status == 200
189 data = await resp.json()
190 assert "items" in data
191 assert "total" in data
192
193
194async def test_albums_with_data(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
195 """GET /api/albums should format album data correctly."""
196 album = Mock()
197 album.item_id = 1
198 album.name = "Test Album"
199 album.artist_str = "Test Artist"
200 album.uri = "library://album/1"
201 album.image = None
202 mock_result = Mock()
203 mock_result.__iter__ = Mock(return_value=iter([album]))
204 mock_result.total = 1
205 mass_mock.music.albums.library_items.return_value = mock_result
206
207 server = MSXHTTPServer(provider, 0)
208 client = AiohttpTestClient(TestServer(server.app))
209 await client.start_server()
210 try:
211 resp = await client.get("/api/albums")
212 assert resp.status == 200
213 data = await resp.json()
214 assert len(data["items"]) == 1
215 assert data["items"][0]["name"] == "Test Album"
216 assert data["items"][0]["artist"] == "Test Artist"
217 assert data["total"] == 1
218 finally:
219 await client.close()
220
221
222async def test_album_tracks(http_client: TestClient[Any, Any]) -> None:
223 """GET /api/albums/{id}/tracks should return items list."""
224 resp = await http_client.get("/api/albums/1/tracks")
225 assert resp.status == 200
226 data = await resp.json()
227 assert "items" in data
228
229
230async def test_artists(http_client: TestClient[Any, Any]) -> None:
231 """GET /api/artists should return items list."""
232 resp = await http_client.get("/api/artists")
233 assert resp.status == 200
234 data = await resp.json()
235 assert "items" in data
236 assert "total" in data
237
238
239async def test_playlists(http_client: TestClient[Any, Any]) -> None:
240 """GET /api/playlists should return items list."""
241 resp = await http_client.get("/api/playlists")
242 assert resp.status == 200
243 data = await resp.json()
244 assert "items" in data
245 assert "total" in data
246
247
248async def test_tracks(http_client: TestClient[Any, Any]) -> None:
249 """GET /api/tracks should return items list."""
250 resp = await http_client.get("/api/tracks")
251 assert resp.status == 200
252 data = await resp.json()
253 assert "items" in data
254 assert "total" in data
255
256
257async def test_search(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
258 """GET /api/search?q=test should return search results."""
259 server = MSXHTTPServer(provider, 0)
260 client = AiohttpTestClient(TestServer(server.app))
261 await client.start_server()
262 try:
263 resp = await client.get("/api/search?q=test")
264 assert resp.status == 200
265 data = await resp.json()
266 assert "artists" in data
267 assert "albums" in data
268 assert "tracks" in data
269 assert "playlists" in data
270 mass_mock.music.search.assert_awaited_once()
271 finally:
272 await client.close()
273
274
275async def test_search_missing_query(http_client: TestClient[Any, Any]) -> None:
276 """GET /api/search without q parameter should return 400."""
277 resp = await http_client.get("/api/search")
278 assert resp.status == 400
279 data = await resp.json()
280 assert "error" in data
281
282
283# --- Playback control ---
284
285
286def _register_msx_player(mass_mock: Mock, provider: MSXBridgeProvider, player_id: str) -> MSXPlayer:
287 """Create an MSXPlayer and register it with the mass_mock so _get_msx_player passes."""
288 player = MSXPlayer(provider=provider, player_id=player_id)
289 mass_mock.players.get_player = Mock(
290 side_effect=lambda pid, **_kwargs: player if pid == player_id else None
291 )
292 return player
293
294
295async def test_play_track(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
296 """POST /api/play should call player_queues.play_media."""
297 _register_msx_player(mass_mock, provider, "msx_test")
298 server = MSXHTTPServer(provider, 0)
299 client = AiohttpTestClient(TestServer(server.app))
300 await client.start_server()
301 try:
302 resp = await client.post(
303 "/api/play",
304 json={"track_uri": "library://track/1", "player_id": "msx_test"},
305 )
306 assert resp.status == 200
307 data = await resp.json()
308 assert data["status"] == "ok"
309 mass_mock.player_queues.play_media.assert_awaited_once_with("msx_test", "library://track/1")
310 finally:
311 await client.close()
312
313
314async def test_play_unknown_player(provider: MSXBridgeProvider) -> None:
315 """POST /api/play with unknown player_id should return 404."""
316 server = MSXHTTPServer(provider, 0)
317 client = AiohttpTestClient(TestServer(server.app))
318 await client.start_server()
319 try:
320 resp = await client.post(
321 "/api/play",
322 json={"track_uri": "library://track/1", "player_id": "msx_test"},
323 )
324 assert resp.status == 404
325 finally:
326 await client.close()
327
328
329async def test_play_invalid_body(http_client: TestClient[Any, Any]) -> None:
330 """POST /api/play with invalid JSON should return 400."""
331 resp = await http_client.post(
332 "/api/play",
333 data=b"not json",
334 headers={"Content-Type": "application/json"},
335 )
336 assert resp.status == 400
337
338
339async def test_pause(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
340 """POST /api/pause/{id} should call cmd_pause."""
341 _register_msx_player(mass_mock, provider, "msx_test")
342 server = MSXHTTPServer(provider, 0)
343 client = AiohttpTestClient(TestServer(server.app))
344 await client.start_server()
345 try:
346 resp = await client.post("/api/pause/msx_test")
347 assert resp.status == 200
348 mass_mock.players.cmd_pause.assert_awaited_once_with("msx_test")
349 finally:
350 await client.close()
351
352
353async def test_stop(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
354 """POST /api/stop/{id} should call cmd_stop."""
355 _register_msx_player(mass_mock, provider, "msx_test")
356 server = MSXHTTPServer(provider, 0)
357 client = AiohttpTestClient(TestServer(server.app))
358 await client.start_server()
359 try:
360 resp = await client.post("/api/stop/msx_test")
361 assert resp.status == 200
362 mass_mock.players.cmd_stop.assert_awaited_once_with("msx_test")
363 finally:
364 await client.close()
365
366
367async def test_quick_stop(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
368 """POST /api/quick-stop/{id} should call cmd_stop and notify_play_stopped."""
369 _register_msx_player(mass_mock, provider, "msx_test")
370 server = MSXHTTPServer(provider, 0)
371 client = AiohttpTestClient(TestServer(server.app))
372 await client.start_server()
373 try:
374 with patch.object(provider, "notify_play_stopped", Mock()) as mock_notify:
375 resp = await client.post("/api/quick-stop/msx_test")
376 assert resp.status == 200
377 mass_mock.players.cmd_stop.assert_awaited_once_with("msx_test")
378 mock_notify.assert_called_once_with("msx_test")
379 finally:
380 await client.close()
381
382
383async def test_control_unknown_player(provider: MSXBridgeProvider) -> None:
384 """Control endpoints with unknown player_id should return 404."""
385 server = MSXHTTPServer(provider, 0)
386 client = AiohttpTestClient(TestServer(server.app))
387 await client.start_server()
388 try:
389 for path in ("/api/pause/unknown", "/api/stop/unknown", "/api/quick-stop/unknown"):
390 resp = await client.post(path)
391 assert resp.status == 404, f"{path} should return 404 for unknown player"
392 finally:
393 await client.close()
394
395
396async def test_control_endpoints_reject_cross_site(http_client: TestClient[Any, Any]) -> None:
397 """
398 State-changing endpoints must reject browser cross-site requests (CSRF).
399
400 Any web page can fire a GET via an img/script tag; modern browsers stamp
401 such requests with Sec-Fetch-Site: cross-site. The rejection must happen
402 before the player lookup so probing is impossible too.
403 """
404 headers = {"Sec-Fetch-Site": "cross-site"}
405 for path in (
406 "/api/pause/msx_x",
407 "/api/stop/msx_x",
408 "/api/quick-stop/msx_x",
409 "/api/next/msx_x",
410 "/api/previous/msx_x",
411 ):
412 resp = await http_client.get(path, headers=headers)
413 assert resp.status == 403, f"{path} must reject cross-site GET"
414
415 resp = await http_client.post(
416 "/api/play",
417 json={"track_uri": "library://track/1", "player_id": "msx_x"},
418 headers=headers,
419 )
420 assert resp.status == 403, "/api/play must reject cross-site POST"
421
422
423async def test_control_endpoints_allow_same_origin(
424 provider: MSXBridgeProvider, mass_mock: Mock
425) -> None:
426 """Same-origin browser requests (web player, MSX plugin) must still work."""
427 _register_msx_player(mass_mock, provider, "msx_test")
428 server = MSXHTTPServer(provider, 0)
429 client = AiohttpTestClient(TestServer(server.app))
430 await client.start_server()
431 try:
432 resp = await client.get("/api/pause/msx_test", headers={"Sec-Fetch-Site": "same-origin"})
433 assert resp.status == 200
434 mass_mock.players.cmd_pause.assert_awaited_once_with("msx_test")
435 finally:
436 await client.close()
437
438
439async def test_control_endpoints_reject_unexpected_methods(
440 http_client: TestClient[Any, Any],
441) -> None:
442 """Control endpoints accept only GET and POST â no wildcard methods."""
443 resp = await http_client.delete("/api/pause/msx_x")
444 assert resp.status == 405
445
446
447# --- MSX content page actions ---
448
449
450def _make_album_mock(item_id: int = 1, name: str = "Test Album") -> Mock:
451 """Create a mock album object."""
452 album = Mock()
453 album.item_id = item_id
454 album.name = name
455 album.artist_str = "Test Artist"
456 album.uri = f"library://album/{item_id}"
457 album.image = None
458 return album
459
460
461def _make_track_mock(item_id: int = 1, name: str = "Test Track") -> Mock:
462 """Create a mock track object."""
463 track = Mock()
464 track.item_id = item_id
465 track.name = name
466 track.artist_str = "Test Artist"
467 track.uri = f"library://track/{item_id}"
468 track.image = None
469 track.album = Mock(name="Test Album")
470 track.duration = 180
471 return track
472
473
474def _make_artist_mock(item_id: int = 1, name: str = "Test Artist") -> Mock:
475 """Create a mock artist object."""
476 artist = Mock()
477 artist.item_id = item_id
478 artist.name = name
479 artist.uri = f"library://artist/{item_id}"
480 artist.image = None
481 return artist
482
483
484def _make_playlist_mock(item_id: int = 1, name: str = "Test Playlist") -> Mock:
485 """Create a mock playlist object."""
486 playlist = Mock()
487 playlist.item_id = item_id
488 playlist.name = name
489 playlist.uri = f"library://playlist/{item_id}"
490 playlist.image = None
491 playlist.owner = "test_user"
492 playlist.provider = "library"
493 return playlist
494
495
496def _make_audio_player(mass_mock: Mock) -> tuple[MagicMock, PlayerMedia]:
497 """Wire a MagicMock MSXPlayer with queue-backed media into mass_mock for audio tests."""
498 player = MagicMock(spec=MSXPlayer)
499 player.player_id = "msx_test"
500 player.output_format = "mp3"
501 player._skip_ws_notify = False
502 media = PlayerMedia(
503 uri="library://track/1",
504 title=None,
505 artist=None,
506 album=None,
507 image_url=None,
508 duration=180,
509 )
510 player.current_media = media
511 player.wait_for_media = AsyncMock(return_value=media)
512 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = player
513 return player, media
514
515
516async def test_msx_albums_have_action(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
517 """GET /msx/albums.json items should have content: action for drill-down."""
518 album = _make_album_mock()
519 mock_result = Mock()
520 mock_result.__iter__ = Mock(return_value=iter([album]))
521 mass_mock.music.albums.library_items.return_value = mock_result
522
523 server = MSXHTTPServer(provider, 0)
524 client = AiohttpTestClient(TestServer(server.app))
525 await client.start_server()
526 try:
527 resp = await client.get("/msx/albums.json")
528 assert resp.status == 200
529 data = await resp.json()
530 item = data["items"][0]
531 assert "action" in item
532 assert item["action"].startswith("content:")
533 assert "/msx/albums/1/tracks.json" in item["action"]
534 finally:
535 await client.close()
536
537
538async def test_msx_artists_have_action(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
539 """GET /msx/artists.json items should have content: action for drill-down."""
540 artist = _make_artist_mock()
541 mock_result = Mock()
542 mock_result.__iter__ = Mock(return_value=iter([artist]))
543 mass_mock.music.artists.library_items.return_value = mock_result
544
545 server = MSXHTTPServer(provider, 0)
546 client = AiohttpTestClient(TestServer(server.app))
547 await client.start_server()
548 try:
549 resp = await client.get("/msx/artists.json")
550 assert resp.status == 200
551 data = await resp.json()
552 item = data["items"][0]
553 assert "action" in item
554 assert item["action"].startswith("content:")
555 assert "/msx/artists/1/albums.json" in item["action"]
556 finally:
557 await client.close()
558
559
560async def test_msx_playlists_have_action(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
561 """GET /msx/playlists.json items should have content: action for drill-down."""
562 playlist = _make_playlist_mock()
563 mock_result = Mock()
564 mock_result.__iter__ = Mock(return_value=iter([playlist]))
565 mass_mock.music.playlists.library_items.return_value = mock_result
566
567 server = MSXHTTPServer(provider, 0)
568 client = AiohttpTestClient(TestServer(server.app))
569 await client.start_server()
570 try:
571 resp = await client.get("/msx/playlists.json")
572 assert resp.status == 200
573 data = await resp.json()
574 item = data["items"][0]
575 assert "action" in item
576 assert item["action"].startswith("content:")
577 assert "/msx/playlists/1/tracks.json" in item["action"]
578 finally:
579 await client.close()
580
581
582async def test_msx_tracks_have_action(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
583 """GET /msx/tracks.json items should have playlist: action for playback."""
584 track = _make_track_mock()
585 mock_result = Mock()
586 mock_result.__iter__ = Mock(return_value=iter([track]))
587 mass_mock.music.tracks.library_items.return_value = mock_result
588
589 server = MSXHTTPServer(provider, 0)
590 client = AiohttpTestClient(TestServer(server.app))
591 await client.start_server()
592 try:
593 resp = await client.get("/msx/tracks.json")
594 assert resp.status == 200
595 data = await resp.json()
596 item = data["items"][0]
597 assert "action" in item
598 assert item["action"].startswith("playlist:")
599 assert "/msx/playlist/tracks.json" in item["action"]
600 assert item["titleHeader"] == "{txt:msx-white:Test Track}"
601 assert "playerLabel" in item
602 assert item["playerLabel"] == "Test Track"
603 finally:
604 await client.close()
605
606
607# --- MSX detail pages ---
608
609
610async def test_msx_album_tracks(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
611 """GET /msx/albums/{id}/tracks.json should return tracks with audio actions."""
612 track = _make_track_mock()
613 mass_mock.music.albums.tracks.return_value = [track]
614
615 server = MSXHTTPServer(provider, 0)
616 client = AiohttpTestClient(TestServer(server.app))
617 await client.start_server()
618 try:
619 resp = await client.get("/msx/albums/1/tracks.json")
620 assert resp.status == 200
621 data = await resp.json()
622 assert data["headline"] == "Album Tracks"
623 assert len(data["items"]) == 1
624 item = data["items"][0]
625 assert item["titleHeader"] == "{txt:msx-white:Test Track}"
626 assert item["action"].startswith("playlist:")
627 assert "/msx/playlist/album/" in item["action"]
628 finally:
629 await client.close()
630
631
632async def test_msx_artist_albums(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
633 """GET /msx/artists/{id}/albums.json should return albums with content actions."""
634 album = _make_album_mock()
635 mass_mock.music.artists.albums.return_value = [album]
636
637 server = MSXHTTPServer(provider, 0)
638 client = AiohttpTestClient(TestServer(server.app))
639 await client.start_server()
640 try:
641 resp = await client.get("/msx/artists/1/albums.json")
642 assert resp.status == 200
643 data = await resp.json()
644 assert data["headline"] == "Artist Albums"
645 assert len(data["items"]) == 1
646 item = data["items"][0]
647 assert item["title"] == "Test Album"
648 assert item["action"].startswith("content:")
649 assert "/msx/albums/1/tracks.json" in item["action"]
650 finally:
651 await client.close()
652
653
654async def test_msx_playlist_tracks(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
655 """GET /msx/playlists/{id}/tracks.json should return tracks with audio actions."""
656 track = _make_track_mock()
657
658 async def _mock_playlist_tracks(*_args: object, **_kwargs: object) -> AsyncGenerator[Any]:
659 yield track
660
661 mass_mock.music.playlists.tracks = Mock(side_effect=lambda *_a, **_k: _mock_playlist_tracks())
662
663 server = MSXHTTPServer(provider, 0)
664 client = AiohttpTestClient(TestServer(server.app))
665 await client.start_server()
666 try:
667 resp = await client.get("/msx/playlists/1/tracks.json")
668 assert resp.status == 200
669 data = await resp.json()
670 assert data["headline"] == "Playlist Tracks"
671 assert len(data["items"]) == 1
672 item = data["items"][0]
673 assert item["titleHeader"] == "{txt:msx-white:Test Track}"
674 assert item["action"].startswith("playlist:")
675 assert "/msx/playlist/playlist/" in item["action"]
676 finally:
677 await client.close()
678
679
680# --- MSX audio endpoint ---
681
682
683async def test_msx_audio_missing_uri(http_client: TestClient[Any, Any]) -> None:
684 """GET /msx/audio/msx_default without ?uri= should return 400."""
685 resp = await http_client.get("/msx/audio/msx_default")
686 assert resp.status == 400
687 body = await resp.text()
688 assert "uri" in body.lower() # "Missing uri" or "Invalid uri parameter"
689
690
691async def test_msx_audio_player_not_found(http_client: TestClient[Any, Any]) -> None:
692 """GET /msx/audio/nonexistent?uri=x should return 404."""
693 resp = await http_client.get("/msx/audio/nonexistent?uri=library://track/1")
694 assert resp.status == 404
695
696
697async def test_msx_audio_not_msx_player(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
698 """GET /msx/audio/{id}?uri=x should return 404 for non-MSX player."""
699 non_msx_player = Mock()
700 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = non_msx_player
701
702 server = MSXHTTPServer(provider, 0)
703 client = AiohttpTestClient(TestServer(server.app))
704 await client.start_server()
705 try:
706 resp = await client.get("/msx/audio/other?uri=library://track/1")
707 assert resp.status == 404
708 body = await resp.text()
709 assert "Player not found" in body
710 finally:
711 await client.close()
712
713
714async def test_msx_audio_per_track_mode(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
715 """GET /msx/audio should always use force_flow_mode=False (per-track)."""
716 server = MSXHTTPServer(provider, 0)
717 client = AiohttpTestClient(TestServer(server.app))
718 await client.start_server()
719 try:
720 _make_audio_player(mass_mock)
721
722 mass_mock.streams = Mock()
723 mass_mock.streams.get_stream = Mock(return_value=_async_iter([b"pcm"]))
724
725 chunks = [b"encoded-chunk-1"]
726 with patch(
727 "music_assistant.providers.msx_bridge.http_server.get_ffmpeg_stream",
728 return_value=_async_iter(chunks),
729 ):
730 resp = await client.get("/msx/audio/msx_test?uri=library://track/1")
731 assert resp.status == 200
732
733 mass_mock.streams.get_stream.assert_called_once()
734 _args, _pos, kwargs = mass_mock.streams.get_stream.mock_calls[0]
735 assert kwargs.get("force_flow_mode") is False
736
737 finally:
738 await client.close()
739
740
741async def test_msx_audio_from_playlist_skips_ws(
742 provider: MSXBridgeProvider, mass_mock: Mock
743) -> None:
744 """GET /msx/audio with from_playlist=1 should set _skip_ws_notify on the player."""
745 server = MSXHTTPServer(provider, 0)
746 client = AiohttpTestClient(TestServer(server.app))
747 await client.start_server()
748 try:
749 player, _media = _make_audio_player(mass_mock)
750
751 mass_mock.streams = Mock()
752 mass_mock.streams.get_stream = Mock(return_value=_async_iter([b"pcm"]))
753
754 # Track that _skip_ws_notify was set to True during play_media
755 notify_states: list[bool] = []
756
757 async def _capture_play_media(*_a: object, **_k: object) -> None:
758 notify_states.append(player._skip_ws_notify)
759
760 mass_mock.player_queues.play_media = _capture_play_media
761
762 chunks = [b"encoded-chunk-1"]
763 with patch(
764 "music_assistant.providers.msx_bridge.http_server.get_ffmpeg_stream",
765 return_value=_async_iter(chunks),
766 ):
767 resp = await client.get("/msx/audio/msx_test?uri=library://track/1&from_playlist=1")
768 assert resp.status == 200
769
770 # _skip_ws_notify should have been True during play_media call
771 assert notify_states == [True]
772 # And reset to False after
773 assert player._skip_ws_notify is False
774
775 finally:
776 await client.close()
777
778
779async def test_msx_audio_arms_wait_before_enqueue(
780 provider: MSXBridgeProvider, mass_mock: Mock
781) -> None:
782 """GET /msx/audio must arm expect_new_media() BEFORE enqueuing new playback."""
783 server = MSXHTTPServer(provider, 0)
784 client = AiohttpTestClient(TestServer(server.app))
785 await client.start_server()
786 try:
787 player, _media = _make_audio_player(mass_mock)
788
789 mass_mock.streams = Mock()
790 mass_mock.streams.get_stream = Mock(return_value=_async_iter([b"pcm"]))
791
792 call_order: list[str] = []
793 player.expect_new_media = Mock(side_effect=lambda: call_order.append("arm"))
794
795 async def _record_enqueue(*_a: object, **_k: object) -> None:
796 call_order.append("enqueue")
797
798 mass_mock.player_queues.play_media = _record_enqueue
799
800 with patch(
801 "music_assistant.providers.msx_bridge.http_server.get_ffmpeg_stream",
802 return_value=_async_iter([b"encoded-chunk-1"]),
803 ):
804 resp = await client.get("/msx/audio/msx_test?uri=library://track/1")
805 assert resp.status == 200
806
807 assert call_order == ["arm", "enqueue"]
808 finally:
809 await client.close()
810
811
812# --- Served audio length (Content-Length) ---
813
814
815def test_served_duration_uses_media_duration(provider: MSXBridgeProvider) -> None:
816 """Without a seek the served audio is the whole media item."""
817 server = MSXHTTPServer(provider, 0)
818 media = PlayerMedia(uri="library://track/1", duration=180)
819
820 assert server._resolve_served_duration(media) == 180
821
822
823def test_served_duration_prefers_stream_duration(provider: MSXBridgeProvider) -> None:
824 """Starting mid-track serves less audio than the media item is long."""
825 server = MSXHTTPServer(provider, 0)
826 media = PlayerMedia(uri="library://track/1", duration=180, stream_duration=60)
827
828 assert server._resolve_served_duration(media) == 60
829
830
831def test_served_duration_falls_back_to_queue_item(
832 provider: MSXBridgeProvider, mass_mock: Mock
833) -> None:
834 """A media item of unknown length is resolved through its queue item."""
835 server = MSXHTTPServer(provider, 0)
836 queue_item = MagicMock()
837 queue_item.media_item = None
838 queue_item.duration = 240
839 mass_mock.player_queues.get_item.return_value = queue_item
840 media = PlayerMedia(uri="library://track/1", source_id="q1", queue_item_id="item1")
841
842 assert server._resolve_served_duration(media) == 240
843
844
845# --- MSX playlist endpoints ---
846
847
848async def test_msx_album_playlist_endpoint(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
849 """GET /msx/playlist/album/{id}.json should return playlist JSON."""
850 track = _make_track_mock()
851 mass_mock.music.albums.tracks.return_value = [track]
852
853 server = MSXHTTPServer(provider, 0)
854 client = AiohttpTestClient(TestServer(server.app))
855 await client.start_server()
856 try:
857 resp = await client.get("/msx/playlist/album/42.json?start=0")
858 assert resp.status == 200
859 data = await resp.json()
860 assert data["type"] == "list"
861 assert data["action"] == "player:play"
862 assert len(data["items"]) == 1
863 item = data["items"][0]
864 assert item["action"].startswith("audio:")
865 assert "/msx/audio/" in item["action"]
866 assert "from_playlist=1" in item["action"]
867 finally:
868 await client.close()
869
870
871async def test_msx_playlist_playlist_endpoint(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
872 """GET /msx/playlist/playlist/{id}.json should return playlist JSON."""
873 track = _make_track_mock()
874
875 async def _mock_playlist_tracks(*_args: object, **_kwargs: object) -> AsyncGenerator[Any]:
876 yield track
877
878 mass_mock.music.playlists.tracks = Mock(side_effect=lambda *_a, **_k: _mock_playlist_tracks())
879
880 server = MSXHTTPServer(provider, 0)
881 client = AiohttpTestClient(TestServer(server.app))
882 await client.start_server()
883 try:
884 resp = await client.get("/msx/playlist/playlist/5.json?start=1")
885 assert resp.status == 200
886 data = await resp.json()
887 assert data["type"] == "list"
888 assert data["action"] == "player:play"
889 assert len(data["items"]) == 1
890 finally:
891 await client.close()
892
893
894async def test_msx_tracks_playlist_endpoint(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
895 """GET /msx/playlist/tracks.json should return playlist JSON."""
896 track = _make_track_mock()
897 mock_result = Mock()
898 mock_result.__iter__ = Mock(return_value=iter([track]))
899 mass_mock.music.tracks.library_items.return_value = mock_result
900
901 server = MSXHTTPServer(provider, 0)
902 client = AiohttpTestClient(TestServer(server.app))
903 await client.start_server()
904 try:
905 resp = await client.get("/msx/playlist/tracks.json?start=0")
906 assert resp.status == 200
907 data = await resp.json()
908 assert data["type"] == "list"
909 assert len(data["items"]) == 1
910 finally:
911 await client.close()
912
913
914# --- Duration in track formatting ---
915
916
917def test_format_msx_track_includes_duration(provider: MSXBridgeProvider) -> None:
918 """map_track_to_msx should include artist and duration in titleFooter."""
919 track = _make_track_mock() # duration=180
920 item = map_track_to_msx(track, "http://localhost", "msx_test", provider)
921 assert item.title_header == "{txt:msx-white:Test Track}"
922 assert item.title_footer == "Test Artist · 3:00"
923 assert item.background == item.image
924
925
926def test_format_msx_track_no_duration(provider: MSXBridgeProvider) -> None:
927 """map_track_to_msx should handle zero/missing duration gracefully."""
928 track = _make_track_mock()
929 track.duration = 0
930 item = map_track_to_msx(track, "http://localhost", "msx_test", provider)
931 assert item.title_header == "{txt:msx-white:Test Track}"
932 assert item.title_footer == "Test Artist"
933
934
935def test_format_msx_track_duration_only(provider: MSXBridgeProvider) -> None:
936 """map_track_to_msx should show only duration when no artist."""
937 track = _make_track_mock()
938 track.artist_str = ""
939 item = map_track_to_msx(track, "http://localhost", "msx_test", provider)
940 assert item.title_header == "{txt:msx-white:Test Track}"
941 assert item.title_footer == "3:00"
942
943
944# --- Async iteration helpers for stream mocking ---
945
946
947async def _async_iter(items: list[Any]) -> AsyncGenerator[Any]:
948 """Async generator helper for mocking iter_chunked."""
949 for item in items:
950 yield item
951
952
953# --- MSX queue-playlist endpoint ---
954
955
956async def test_msx_queue_playlist_endpoint(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
957 """GET /msx/queue-playlist/{player_id}.json should return MSX playlist from MA queue."""
958 qi1 = Mock()
959 qi1.name = "Track 1"
960 qi1.media_item = Mock()
961 qi1.media_item.name = "Track 1"
962 qi1.media_item.uri = "library://track/1"
963 qi1.media_item.duration = 180
964 qi1.media_item.artist_str = "Artist 1"
965 qi1.duration = 180
966 qi1.image = None
967
968 qi2 = Mock()
969 qi2.name = "Track 2"
970 qi2.media_item = Mock()
971 qi2.media_item.name = "Track 2"
972 qi2.media_item.uri = "library://track/2"
973 qi2.media_item.duration = 200
974 qi2.media_item.artist_str = "Artist 2"
975 qi2.duration = 200
976 qi2.image = None
977
978 mass_mock.player_queues.items = Mock(return_value=[qi1, qi2])
979
980 server = MSXHTTPServer(provider, 0)
981 client = AiohttpTestClient(TestServer(server.app))
982 await client.start_server()
983 try:
984 resp = await client.get("/msx/queue-playlist/msx_test.json?start=0")
985 assert resp.status == 200
986 data = await resp.json()
987 assert data["type"] == "list"
988 assert data["action"] == "player:play"
989 assert len(data["items"]) == 2
990 assert data["items"][0]["title"] == "Track 1"
991 assert data["items"][1]["title"] == "Track 2"
992 assert "from_playlist=1" in data["items"][0]["action"]
993 finally:
994 await client.close()
995
996
997async def test_msx_queue_playlist_with_start_index(
998 provider: MSXBridgeProvider, mass_mock: Mock
999) -> None:
1000 """GET /msx/queue-playlist with start=1 should use player:play action."""
1001 qi = Mock()
1002 qi.name = "Track 1"
1003 qi.media_item = Mock()
1004 qi.media_item.name = "Track 1"
1005 qi.media_item.uri = "library://track/1"
1006 qi.media_item.duration = 180
1007 qi.media_item.artist_str = "Artist 1"
1008 qi.duration = 180
1009 qi.image = None
1010
1011 mass_mock.player_queues.items = Mock(return_value=[qi])
1012
1013 server = MSXHTTPServer(provider, 0)
1014 client = AiohttpTestClient(TestServer(server.app))
1015 await client.start_server()
1016 try:
1017 resp = await client.get("/msx/queue-playlist/msx_test.json?start=1")
1018 assert resp.status == 200
1019 data = await resp.json()
1020 assert data["action"] == "player:play"
1021 finally:
1022 await client.close()
1023
1024
1025async def test_msx_queue_playlist_empty_queue(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
1026 """GET /msx/queue-playlist with empty queue should return empty playlist."""
1027 mass_mock.player_queues.items = Mock(return_value=[])
1028
1029 server = MSXHTTPServer(provider, 0)
1030 client = AiohttpTestClient(TestServer(server.app))
1031 await client.start_server()
1032 try:
1033 resp = await client.get("/msx/queue-playlist/msx_test.json?start=0")
1034 assert resp.status == 200
1035 data = await resp.json()
1036 assert data["type"] == "list"
1037 assert data["items"] == []
1038 finally:
1039 await client.close()
1040
1041
1042# --- WebSocket inbound message handling ---
1043
1044
1045async def test_ws_position_message(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
1046 """WS position message should update player's elapsed time."""
1047 player = MSXPlayer(provider, "msx_test", name="Test TV", output_format="mp3")
1048 player.update_state = Mock() # type: ignore[misc,method-assign]
1049 player._attr_playback_state = PlaybackState.PLAYING
1050 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = player
1051 provider.http_server = MSXHTTPServer(provider, 0)
1052
1053 server_obj = provider.http_server
1054 server_obj._handle_ws_message("msx_test", '{"type": "position", "position": 42.5}')
1055
1056 assert player._attr_elapsed_time == 42.5
1057 assert player._last_ws_position is not None
1058
1059
1060async def test_ws_position_message_unknown_player(
1061 provider: MSXBridgeProvider, mass_mock: Mock
1062) -> None:
1063 """WS position message for unknown player should not crash."""
1064 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = None
1065 provider.http_server = MSXHTTPServer(provider, 0)
1066
1067 # Should not raise
1068 provider.http_server._handle_ws_message("msx_unknown", '{"type": "position", "position": 10}')
1069
1070
1071async def test_ws_invalid_json(provider: MSXBridgeProvider) -> None:
1072 """WS invalid JSON should not crash."""
1073 provider.http_server = MSXHTTPServer(provider, 0)
1074 # Should not raise
1075 provider.http_server._handle_ws_message("msx_test", "not json")
1076
1077
1078async def test_ws_pause_message(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
1079 """WS pause message should update position and call cmd_pause."""
1080 player = MSXPlayer(provider, "msx_test", name="Test TV", output_format="mp3")
1081 player.update_state = Mock() # type: ignore[misc,method-assign]
1082 player._attr_playback_state = PlaybackState.PLAYING
1083 player._attr_elapsed_time = 10.0
1084 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = player
1085 provider.http_server = MSXHTTPServer(provider, 0)
1086
1087 provider.http_server._handle_ws_message("msx_test", '{"type": "pause", "position": 30.5}')
1088
1089 assert player._attr_elapsed_time == 30.5
1090 # Flag is now managed inside _cmd_pause_no_echo; verify the task was scheduled
1091 mass_mock.create_task.assert_called_once()
1092
1093
1094async def test_ws_resume_message(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
1095 """WS resume message should call cmd_play."""
1096 player = MSXPlayer(provider, "msx_test", name="Test TV", output_format="mp3")
1097 player.update_state = Mock() # type: ignore[misc,method-assign]
1098 player._attr_playback_state = PlaybackState.PAUSED
1099 mass_mock.players.get.return_value = mass_mock.players.get_player.return_value = player
1100 provider.http_server = MSXHTTPServer(provider, 0)
1101
1102 provider.http_server._handle_ws_message("msx_test", '{"type": "resume"}')
1103
1104 # Flag is now managed inside _cmd_play_no_echo; verify the task was scheduled
1105 mass_mock.create_task.assert_called_once()
1106
1107
1108async def test_ws_unknown_message_type(provider: MSXBridgeProvider) -> None:
1109 """WS unknown message type should not crash."""
1110 provider.http_server = MSXHTTPServer(provider, 0)
1111 # Should not raise
1112 provider.http_server._handle_ws_message("msx_test", '{"type": "unknown_cmd"}')
1113
1114
1115# --- Removed kiosk/sendspin routes ---
1116
1117
1118async def test_removed_kiosk_and_sendspin_routes_404(
1119 http_client: TestClient[Any, Any],
1120) -> None:
1121 """Removed kiosk and sendspin routes should return 404."""
1122 for path in [
1123 "/msx/kiosk-plugin.html",
1124 "/msx/kiosk.html",
1125 "/msx/kiosk-content.json",
1126 "/msx/kiosk-page.json",
1127 "/msx/kiosk-album.json",
1128 "/msx/sendspin-plugin.html",
1129 "/msx/sendspin-standalone.html",
1130 "/msx/sendspin-bundle.js",
1131 ]:
1132 resp = await http_client.get(path)
1133 assert resp.status == 404, f"Expected 404 for {path}, got {resp.status}"
1134
1135
1136# --- Server shutdown ---
1137
1138
1139async def test_server_stop_survives_ws_self_deregistration(
1140 provider: MSXBridgeProvider,
1141) -> None:
1142 """Closing a WS during stop() triggers its cleanup discard; stop() must survive it."""
1143 server = MSXHTTPServer(provider, 0)
1144
1145 class _SelfRemovingWS:
1146 closed = False
1147
1148 async def close(self) -> None:
1149 server._ws_clients["msx_test"].discard(self)
1150
1151 fake_clients: set[Any] = {_SelfRemovingWS(), _SelfRemovingWS()}
1152 server._ws_clients["msx_test"] = fake_clients
1153
1154 await server.stop()
1155
1156 assert server._ws_clients == {}
1157
1158
1159# --- Redirect stream mode (MA streamserver) ---
1160
1161
1162async def test_msx_audio_redirect_mode(provider: MSXBridgeProvider, mass_mock: Mock) -> None:
1163 """In redirect mode /msx/audio must 302-redirect to the MA streamserver stream."""
1164 provider.group_stream_mode = "redirect"
1165 server = MSXHTTPServer(provider, 0)
1166 client = AiohttpTestClient(TestServer(server.app))
1167 await client.start_server()
1168 try:
1169 _make_audio_player(mass_mock)
1170
1171 stream_url = "http://ma:8097/single/s1/q1/i1/msx_test.mp3"
1172 mass_mock.streams = Mock()
1173 mass_mock.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
1174
1175 resp = await client.get("/msx/audio/msx_test?uri=library://track/1", allow_redirects=False)
1176 assert resp.status == 302
1177 # the URL host is rewritten to the client-reachable one (see the
1178 # dedicated rewrite test); the streamserver path must be intact
1179 assert resp.headers["Location"].endswith(":8097/single/s1/q1/i1/msx_test.mp3")
1180 finally:
1181 await client.close()
1182
1183
1184async def test_msx_audio_redirect_rewrites_host_for_client(
1185 provider: MSXBridgeProvider, mass_mock: Mock
1186) -> None:
1187 """
1188 The redirect must target the host the TV already uses to reach the provider.
1189
1190 Behind Docker/NAT the MA streamserver advertises its container IP, which
1191 the TV cannot reach; only the host of the URL is rewritten â port, path
1192 and query of the streamserver URL must survive.
1193 """
1194 provider.group_stream_mode = "redirect"
1195 server = MSXHTTPServer(provider, 0)
1196 client = AiohttpTestClient(TestServer(server.app))
1197 await client.start_server()
1198 try:
1199 _make_audio_player(mass_mock)
1200
1201 stream_url = "http://172.18.0.2:8097/single/s1/q1/i1/msx_test.mp3?flow=1"
1202 mass_mock.streams = Mock()
1203 mass_mock.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
1204
1205 resp = await client.get("/msx/audio/msx_test?uri=library://track/1", allow_redirects=False)
1206 assert resp.status == 302
1207 location = urlsplit(resp.headers["Location"])
1208 assert location.hostname == "127.0.0.1" # host the TestClient connects to
1209 assert location.port == 8097
1210 assert location.path == "/single/s1/q1/i1/msx_test.mp3"
1211 assert location.query == "flow=1"
1212 finally:
1213 await client.close()
1214
1215
1216async def test_msx_audio_redirect_mode_falls_back_to_proxy(
1217 provider: MSXBridgeProvider, mass_mock: Mock
1218) -> None:
1219 """When URL resolution fails, redirect mode must fall back to the local proxy."""
1220 provider.group_stream_mode = "redirect"
1221 server = MSXHTTPServer(provider, 0)
1222 client = AiohttpTestClient(TestServer(server.app))
1223 await client.start_server()
1224 try:
1225 _make_audio_player(mass_mock)
1226
1227 mass_mock.streams = Mock()
1228 mass_mock.streams.resolve_stream_url = AsyncMock(side_effect=RuntimeError("boom"))
1229 mass_mock.streams.get_stream = Mock(return_value=_async_iter([b"pcm"]))
1230 filter_params = ["volume=0.5"]
1231 mass_mock.streams.audio.get_player_output_plan.return_value = Mock(
1232 filter_params=filter_params
1233 )
1234
1235 with patch(
1236 "music_assistant.providers.msx_bridge.http_server.get_ffmpeg_stream",
1237 return_value=_async_iter([b"encoded-chunk-1"]),
1238 ) as ffmpeg_stream:
1239 resp = await client.get(
1240 "/msx/audio/msx_test?uri=library://track/1", allow_redirects=False
1241 )
1242 assert resp.status == 200
1243 body = await resp.read()
1244 assert b"encoded-chunk-1" in body
1245 assert ffmpeg_stream.call_args.kwargs["filter_params"] == filter_params
1246 finally:
1247 await client.close()
1248
1249
1250# --- Vendored Sendspin JS client ---
1251
1252
1253async def test_vendored_sendspin_js_served(http_client: TestClient[Any, Any]) -> None:
1254 """The Sendspin JS client must be served locally â TVs on LAN-only setups have no CDN."""
1255 resp = await http_client.get("/web/sendspin-js/index.js")
1256 assert resp.status == 200
1257 body = await resp.text()
1258 assert "SendspinPlayer" in body
1259
1260
1261def test_web_player_has_no_cdn_dependency() -> None:
1262 """web.js must import the Sendspin SDK from the vendored copy, not from a CDN."""
1263 web_js = (STATIC_DIR / "web" / "web.js").read_text(encoding="utf-8")
1264 assert "unpkg.com" not in web_js
1265 assert "jsdelivr" not in web_js
1266 assert "sendspin-js/index.js" in web_js
1267
1268
1269async def test_root_has_kiosk_url_builder(http_client: TestClient[Any, Any]) -> None:
1270 """The status page offers a kiosk URL builder with the four display toggles."""
1271 resp = await http_client.get("/")
1272 assert resp.status == 200
1273 body = await resp.text()
1274 assert 'id="kiosk-builder"' in body
1275 for name in ("controls", "party", "viz", "lyrics"):
1276 assert f'data-kiosk-param="{name}"' in body
1277 assert 'id="kiosk-builder-link"' in body
1278 assert 'id="kiosk-builder-url"' in body
1279
1280
1281def test_web_player_reads_kiosk_display_params() -> None:
1282 """web.js must read the four kiosk display params (URL contract)."""
1283 web_js = (STATIC_DIR / "web" / "web.js").read_text(encoding="utf-8")
1284 for name in ("controls", "party", "viz", "lyrics"):
1285 assert f"kioskFlag('{name}')" in web_js, f"missing kiosk param: {name}"
1286
1287
1288def test_web_player_js_parses() -> None:
1289 """web.js must be syntactically valid (guards against edits breaking the kiosk)."""
1290 node = shutil.which("node")
1291 if node is None:
1292 pytest.skip("node not available")
1293 result = subprocess.run( # noqa: S603
1294 [node, "--check", str(STATIC_DIR / "web" / "web.js")],
1295 capture_output=True,
1296 text=True,
1297 check=False,
1298 )
1299 assert result.returncode == 0, result.stderr
1300
1301
1302def test_vendored_sendspin_js_imports_are_browser_loadable() -> None:
1303 """
1304 Every import specifier in the vendored SDK must be loadable by a browser.
1305
1306 Relative specifiers must resolve to a vendored file (the upstream dist is
1307 TypeScript output with extensionless specifiers that only work through CDN
1308 rewriting). Bare specifiers cannot resolve in browsers at all; the two
1309 opus-encdec ones are a known opus-decode fallback that stays unreachable
1310 because the web player stops advertising opus when WebCodecs is missing.
1311 """
1312 vendor_dir = STATIC_DIR / "web" / "sendspin-js"
1313 js_files = list(vendor_dir.rglob("*.js"))
1314 assert js_files, "vendored sendspin-js dist is missing"
1315 known_bare_specifiers = {
1316 "opus-encdec/dist/libopus-decoder.js",
1317 "opus-encdec/src/oggOpusDecoder.js",
1318 }
1319 # static imports/re-exports, side-effect imports, and dynamic import()
1320 pattern = re.compile(
1321 r"""(?:import\s*\(\s*|import[^'"()]*?from\s+|import\s+|export[^'"()]*?from\s+)"""
1322 r"""['"]([^'"]+)['"]"""
1323 )
1324 bad: list[str] = []
1325 for js_file in js_files:
1326 for specifier in pattern.findall(js_file.read_text(encoding="utf-8")):
1327 if specifier.startswith("."):
1328 target = (js_file.parent / specifier).resolve()
1329 if not (specifier.endswith(".js") and target.is_file()):
1330 bad.append(f"{js_file.name}: {specifier}")
1331 elif specifier not in known_bare_specifiers:
1332 bad.append(f"{js_file.name}: bare specifier {specifier}")
1333 assert not bad, f"browser-unloadable import specifiers: {bad}"
1334
1335
1336# --- Queue API ---
1337
1338
1339async def test_queue_unknown_player(http_client: TestClient[Any, Any]) -> None:
1340 """GET /api/queue/{player_id} for unknown player returns empty items."""
1341 resp = await http_client.get("/api/queue/unknown_player")
1342 assert resp.status == 200
1343 data = await resp.json()
1344 assert data["items"] == []
1345 assert data["current_index"] == -1
1346
1347
1348async def test_queue_with_items(
1349 http_client: TestClient[Any, Any],
1350 provider: MSXBridgeProvider,
1351 mass_mock: Mock,
1352) -> None:
1353 """GET /api/queue/{player_id} returns queue items with current_index."""
1354 # Create and register a player
1355 player = MSXPlayer(provider, "msx_queue_test", name="Queue TV", output_format="mp3")
1356 player.update_state = Mock() # type: ignore[misc,method-assign]
1357
1358 # Set current media
1359 media = MagicMock(spec=PlayerMedia)
1360 media.source_id = "msx_queue_test"
1361 media.queue_item_id = "qi_2"
1362 player._attr_current_media = media
1363
1364 # Mock get_player to return our player
1365 mass_mock.players.get_player = Mock(
1366 side_effect=lambda pid, **_kw: player if pid == "msx_queue_test" else None
1367 )
1368
1369 # Create mock queue items
1370 qi1 = Mock()
1371 qi1.name = "Track 1"
1372 qi1.duration = 180
1373 qi1.image = None
1374 mi1 = Mock()
1375 mi1.name = "Track 1"
1376 mi1.uri = "library://track/1"
1377 mi1.artist_str = "Artist A"
1378 mi1.duration = 180
1379 qi1.media_item = mi1
1380
1381 qi2 = Mock()
1382 qi2.name = "Track 2"
1383 qi2.duration = 240
1384 qi2.image = None
1385 mi2 = Mock()
1386 mi2.name = "Track 2"
1387 mi2.uri = "library://track/2"
1388 mi2.artist_str = "Artist B"
1389 mi2.duration = 240
1390 qi2.media_item = mi2
1391
1392 mass_mock.player_queues.items = Mock(return_value=[qi1, qi2])
1393
1394 # Mock get_item to resolve current track
1395 current_qi = Mock()
1396 current_qi.media_item = mi2
1397 mass_mock.player_queues.get_item = Mock(return_value=current_qi)
1398
1399 resp = await http_client.get("/api/queue/msx_queue_test")
1400 assert resp.status == 200
1401 data = await resp.json()
1402 assert len(data["items"]) == 2
1403 assert data["items"][0]["title"] == "Track 1"
1404 assert data["items"][0]["artist"] == "Artist A"
1405 assert data["items"][1]["title"] == "Track 2"
1406 assert data["current_index"] == 1 # Track 2 is current
1407
1408
1409class _AsyncCtx:
1410 """Async context manager helper for mocking session.get()."""
1411
1412 def __init__(self, obj: object) -> None:
1413 self._obj = obj
1414
1415 async def __aenter__(self) -> object:
1416 return self._obj
1417
1418 async def __aexit__(self, *args: object) -> None:
1419 pass
1420