/
/
1"""Embedded HTTP server for the MSX Bridge Provider."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import functools
8import hashlib
9import io
10import json
11import logging
12import time
13from html import escape as html_escape
14from pathlib import Path
15from types import SimpleNamespace
16from typing import TYPE_CHECKING, Any, NamedTuple, cast
17from urllib.parse import quote, urlsplit, urlunsplit
18
19import aiohttp
20from aiohttp import WSMsgType, web
21from music_assistant_models.enums import ContentType
22from music_assistant_models.media_items import AudioFormat, Track
23
24from music_assistant.constants import SENDSPIN_SERVER_PORT
25from music_assistant.controllers.streams.audio_processing import get_media_session_id
26from music_assistant.controllers.webserver.helpers.auth_middleware import ImpersonatedUser
27from music_assistant.helpers.ffmpeg import get_ffmpeg_stream
28from music_assistant.helpers.util import join_task
29
30from .constants import (
31 CONF_SHOW_STOP_NOTIFICATION,
32 DEFAULT_SHOW_STOP_NOTIFICATION,
33 MSX_PLAYER_ID_PREFIX,
34 PLAYER_ID_SANITIZE_RE,
35 PRE_BUFFER_BYTES,
36)
37from .mappers import (
38 append_device_param,
39 get_image_url,
40 map_album_to_msx,
41 map_artist_to_msx,
42 map_playlist_to_msx,
43 map_track_to_msx,
44 map_tracks_to_msx_playlist,
45)
46from .models import MsxContent, MsxItem, MsxTemplate
47from .player import MSXPlayer
48
49if TYPE_CHECKING:
50 from collections.abc import Sequence
51
52 from multidict import MultiMapping
53 from music_assistant_models.player import PlayerMedia
54
55 from music_assistant.helpers.dsp import ComplexFilter
56
57 from .provider import MSXBridgeProvider
58
59logger = logging.getLogger(__name__)
60
61STATIC_DIR = Path(__file__).parent / "static"
62
63_KNOWN_EXTENSIONS = (".mp3", ".json", ".flac", ".aac")
64
65PARTY_CACHE_TTL = 10.0
66PARTY_CALL_TIMEOUT = 5.0
67
68
69class PartyInfo(NamedTuple):
70 """Active-party details resolved from the MA Party plugin."""
71
72 join_url: str
73 name: str | None
74 qr_text: str | None
75 qr_version: str
76
77
78def _int_param(query: MultiMapping[str], name: str, default: int, max_val: int = 10000) -> int:
79 """Parse an integer query parameter safely, clamping to [0, max_val]."""
80 try:
81 return max(0, min(int(query.get(name, str(default))), max_val))
82 except ValueError, TypeError:
83 return default
84
85
86def _strip_known_extension(value: str) -> str:
87 """Strip only known audio/data extensions from a value."""
88 for ext in _KNOWN_EXTENSIONS:
89 if value.endswith(ext):
90 return value[: -len(ext)]
91 return value
92
93
94@functools.lru_cache(maxsize=4)
95def _render_qr(join_url: str, kind: str) -> bytes:
96 """
97 Render the join URL as a QR image (blocking on a miss; run in a worker thread).
98
99 Results are memoized â the output only changes when the join code rotates.
100 """
101 import segno # noqa: PLC0415 # only needed when the Party plugin is used
102
103 buf = io.BytesIO()
104 segno.make(join_url, error="m").save(buf, kind=kind, scale=8)
105 return buf.getvalue()
106
107
108def _render_qr_cover(join_url: str, cover_bytes: bytes) -> bytes:
109 """Render the QR and composite it onto the cover (blocking; run in a worker thread)."""
110 return _stamp_qr_on_cover(cover_bytes, _render_qr(join_url, "png"))
111
112
113def _stamp_qr_on_cover(cover_bytes: bytes, qr_bytes: bytes) -> bytes:
114 """Composite the QR into the cover's bottom-right corner; returns PNG bytes."""
115 from PIL import Image # noqa: PLC0415 # only needed when the Party plugin is used
116
117 cover = Image.open(io.BytesIO(cover_bytes)).convert("RGB")
118 qr = Image.open(io.BytesIO(qr_bytes)).convert("RGB")
119 # ~28% of the smaller cover side keeps the QR scannable without hiding the art;
120 # NEAREST preserves the hard module edges QR readers need.
121 side = max(48, min(cover.width, cover.height) * 28 // 100)
122 qr = qr.resize((side, side), Image.Resampling.NEAREST)
123 margin = side // 8
124 cover.paste(qr, (cover.width - side - margin, cover.height - side - margin))
125 out = io.BytesIO()
126 cover.save(out, format="PNG")
127 return out.getvalue()
128
129
130def _sort_album_tracks(tracks: list[Any]) -> list[Any]:
131 """
132 Sort album tracks deterministically.
133
134 MA sorts by (disc_number, track_number) but tracks with identical values
135 get non-deterministic ordering between calls. Adding name as a tiebreaker
136 ensures the display page and playlist endpoint always agree on track order.
137 """
138 return sorted(
139 tracks,
140 key=lambda t: (
141 getattr(t, "disc_number", 0) or 0,
142 getattr(t, "track_number", 0) or 0,
143 getattr(t, "name", "") or "",
144 ),
145 )
146
147
148class MSXHTTPServer:
149 """HTTP server that serves MSX bootstrap, library API, and stream proxy."""
150
151 def __init__(self, provider: MSXBridgeProvider, port: int) -> None:
152 """Initialize the HTTP server."""
153 self.provider = provider
154 self.port = port
155 self.app = web.Application(middlewares=[self._cors_middleware])
156 self._runner: web.AppRunner | None = None
157 self._ws_clients: dict[str, set[web.WebSocketResponse]] = {}
158 self._active_stream_tasks: dict[str, set[asyncio.Task[None]]] = {}
159 self._active_stream_transports: dict[str, set[Any]] = {}
160 self._party_cache: tuple[float, PartyInfo | None] | None = None
161 self._qr_cover_cache: dict[tuple[str, str], bytes] = {}
162 self._qr_cover_inflight: dict[tuple[str, str], asyncio.Task[bytes]] = {}
163 self._client_prefixes: dict[str, str] = {}
164 self._setup_routes()
165
166 async def start(self) -> None:
167 """Start the HTTP server."""
168 self._runner = web.AppRunner(self.app)
169 await self._runner.setup()
170 # reuse_address + reuse_port allow fast restart after reload.
171 # 0.0.0.0 is required: MSX TVs on LAN must reach this server by host IP;
172 # binding to 127.0.0.1 would prevent TV connections.
173 site = web.TCPSite(
174 self._runner,
175 "0.0.0.0",
176 self.port,
177 reuse_address=True,
178 reuse_port=True,
179 )
180 await site.start()
181 logger.info("MSX Bridge HTTP server started on port %s", self.port)
182
183 async def stop(self) -> None:
184 """Stop the HTTP server."""
185 # iterate over copies: closing a WS wakes its handler, whose cleanup
186 # discards the WS from these collections mid-iteration
187 for clients in list(self._ws_clients.values()):
188 for ws in list(clients):
189 if not ws.closed:
190 await ws.close()
191 self._ws_clients.clear()
192 for player_id in list(self._active_stream_tasks):
193 self.cancel_streams_for_player(player_id)
194 if self._runner:
195 await self._runner.cleanup()
196 self._runner = None
197 logger.info("MSX Bridge HTTP server stopped")
198
199 def broadcast_play(
200 self,
201 player_id: str,
202 *,
203 title: str | None = None,
204 artist: str | None = None,
205 image_url: str | None = None,
206 duration: int | None = None,
207 next_action: str | None = None,
208 prev_action: str | None = None,
209 ) -> None:
210 """Notify subscribed WebSocket clients to start playback with metadata."""
211 clients = self._ws_clients.get(player_id, set())
212 if not clients:
213 logger.warning(
214 "broadcast_play: no WebSocket clients for player_id=%s (connected: %s)",
215 player_id,
216 list(self._ws_clients.keys()),
217 )
218 return
219 logger.info(
220 "broadcast_play: player_id=%s, sending to %d client(s)",
221 player_id,
222 len(clients),
223 )
224
225 # We always use direct stream for maximum compatibility.
226 play_path = f"/stream/{player_id}"
227
228 payload: dict[str, Any] = {
229 "type": "play",
230 "path": play_path,
231 "player_id": player_id,
232 }
233 if title:
234 payload["title"] = title
235 if artist:
236 payload["artist"] = artist
237 if image_url:
238 # During a party the play background carries the join QR (MSX has
239 # no overlays); the endpoint falls back to the original image when
240 # the party is over, so a stale cache entry here is harmless.
241 if self._cached_party() and (client_prefix := self._client_prefixes.get(player_id)):
242 image_url = (
243 f"{client_prefix}/api/party/qr-cover.png?image={quote(image_url, safe='')}"
244 )
245 payload["image_url"] = image_url
246 if duration is not None:
247 payload["duration"] = duration
248 if next_action:
249 payload["next_action"] = next_action
250 if prev_action:
251 payload["prev_action"] = prev_action
252 msg = json.dumps(payload)
253 for ws in list(clients):
254 if not ws.closed:
255 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
256
257 def broadcast_playlist(self, player_id: str, playlist_url: str) -> None:
258 """Notify subscribed WebSocket clients to load an MSX native playlist."""
259 clients = self._ws_clients.get(player_id, set())
260 if not clients:
261 logger.warning(
262 "broadcast_playlist: no WebSocket clients for player_id=%s (connected: %s)",
263 player_id,
264 list(self._ws_clients.keys()),
265 )
266 return
267 logger.info(
268 "broadcast_playlist: player_id=%s, url=%s, sending to %d client(s)",
269 player_id,
270 playlist_url,
271 len(clients),
272 )
273 payload: dict[str, Any] = {
274 "type": "playlist",
275 "url": playlist_url,
276 "player_id": player_id,
277 }
278 msg = json.dumps(payload)
279 for ws in list(clients):
280 if not ws.closed:
281 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
282
283 def broadcast_sendspin(self, player_id: str, url: str) -> None:
284 """Notify WebSocket clients to open the Sendspin kiosk (bridge stream start)."""
285 clients = self._ws_clients.get(player_id, set())
286 if not clients:
287 logger.warning(
288 "broadcast_sendspin: no WebSocket clients for player_id=%s (connected: %s)",
289 player_id,
290 list(self._ws_clients.keys()),
291 )
292 return
293 logger.info(
294 "broadcast_sendspin: player_id=%s, url=%s, sending to %d client(s)",
295 player_id,
296 url,
297 len(clients),
298 )
299 msg = json.dumps({"type": "sendspin", "url": url, "player_id": player_id})
300 for ws in list(clients):
301 if not ws.closed:
302 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
303
304 def broadcast_goto_index(self, player_id: str, index: int) -> None:
305 """Notify subscribed WebSocket clients to jump to a playlist index."""
306 clients = self._ws_clients.get(player_id, set())
307 if not clients:
308 return
309 logger.info(
310 "broadcast_goto_index: player_id=%s, index=%d, sending to %d client(s)",
311 player_id,
312 index,
313 len(clients),
314 )
315 payload: dict[str, Any] = {"type": "goto_index", "index": index}
316 msg = json.dumps(payload)
317 for ws in list(clients):
318 if not ws.closed:
319 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
320
321 def cancel_streams_for_player(self, player_id: str) -> None:
322 """Cancel stream tasks and abort connections for the given player."""
323 tasks = self._active_stream_tasks.pop(player_id, set())
324 transports = self._active_stream_transports.pop(player_id, set())
325 for task in tasks:
326 if not task.done():
327 task.cancel()
328 for transport in transports:
329 with contextlib.suppress(Exception):
330 if transport and hasattr(transport, "abort"):
331 transport.abort()
332 if tasks or transports:
333 logger.debug(
334 "Cancelled %d task(s), aborted %d transport(s) for player %s",
335 len(tasks),
336 len(transports),
337 player_id,
338 )
339
340 def broadcast_pause(self, player_id: str) -> None:
341 """Notify subscribed WebSocket clients to pause playback."""
342 clients = self._ws_clients.get(player_id, set())
343 if not clients:
344 return
345 logger.info(
346 "broadcast_pause: player_id=%s, sending to %d client(s)",
347 player_id,
348 len(clients),
349 )
350 msg = json.dumps({"type": "pause"})
351 for ws in list(clients):
352 if not ws.closed:
353 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
354
355 def broadcast_resume(self, player_id: str) -> None:
356 """Notify subscribed WebSocket clients to resume playback."""
357 clients = self._ws_clients.get(player_id, set())
358 if not clients:
359 return
360 logger.info(
361 "broadcast_resume: player_id=%s, sending to %d client(s)",
362 player_id,
363 len(clients),
364 )
365 msg = json.dumps({"type": "resume"})
366 for ws in list(clients):
367 if not ws.closed:
368 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
369
370 def broadcast_stop(self, player_id: str) -> None:
371 """Notify subscribed WebSocket clients to stop playback."""
372 clients = self._ws_clients.get(player_id, set())
373 if not clients:
374 logger.warning(
375 "broadcast_stop: no WebSocket clients for player_id=%s (connected: %s)",
376 player_id,
377 list(self._ws_clients.keys()),
378 )
379 return
380 logger.info(
381 "broadcast_stop: player_id=%s, sending to %d client(s)",
382 player_id,
383 len(clients),
384 )
385 show_notification = self.provider.config.get_value(
386 CONF_SHOW_STOP_NOTIFICATION, DEFAULT_SHOW_STOP_NOTIFICATION
387 )
388 payload: dict[str, Any] = {
389 "type": "stop",
390 "showNotification": bool(show_notification),
391 }
392 msg = json.dumps(payload)
393 for ws in list(clients):
394 if not ws.closed:
395 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
396
397 def broadcast_seek(self, player_id: str, position_seconds: int) -> None:
398 """Notify subscribed WebSocket clients to seek to a position."""
399 clients = self._ws_clients.get(player_id, set())
400 if not clients:
401 logger.debug("broadcast_seek: no WebSocket clients for player_id=%s", player_id)
402 return
403 msg = json.dumps({"type": "seek", "position": position_seconds})
404 for ws in list(clients):
405 if not ws.closed:
406 self.provider.mass.create_task(self._ws_send(ws, msg, player_id))
407
408 def _setup_routes(self) -> None:
409 """Register all HTTP routes."""
410 self._setup_msx_routes()
411 self._setup_api_routes()
412
413 def _setup_msx_routes(self) -> None:
414 """Register MSX bootstrap, content, and playback routes."""
415 # MSX bootstrap
416 self.app.router.add_get("/", self._handle_root)
417 self.app.router.add_get("/msx/start.json", self._handle_start_json)
418 self.app.router.add_get("/msx/launcher.json", self._handle_launcher_json)
419 self.app.router.add_get("/msx/plugin.html", self._handle_msx_plugin_html)
420 self.app.router.add_get(
421 "/msx/tvx-plugin-module.min.js",
422 self._serve_static("tvx-plugin-module.min.js"),
423 )
424 self.app.router.add_get("/msx/tvx-plugin.min.js", self._serve_static("tvx-plugin.min.js"))
425 self.app.router.add_get("/msx/input.html", self._handle_msx_input_html)
426 self.app.router.add_get("/msx/input.js", self._serve_static("input.js"))
427
428 # MSX content pages (native MSX JSON navigation)
429 self.app.router.add_get("/msx/menu.json", self._handle_msx_menu)
430 self.app.router.add_get("/msx/albums.json", self._handle_msx_albums)
431 self.app.router.add_get("/msx/artists.json", self._handle_msx_artists)
432 self.app.router.add_get("/msx/playlists.json", self._handle_msx_playlists)
433 self.app.router.add_get("/msx/tracks.json", self._handle_msx_tracks)
434 self.app.router.add_get("/msx/recently-played.json", self._handle_msx_recently_played)
435 self.app.router.add_get("/msx/search-page.json", self._handle_msx_search_page)
436 self.app.router.add_get("/msx/search-input.json", self._handle_msx_search_input)
437 self.app.router.add_get("/msx/search.json", self._handle_msx_search)
438 self.app.router.add_get("/msx/party.json", self._handle_msx_party)
439
440 # MSX detail pages
441 self.app.router.add_get("/msx/albums/{item_id}/tracks.json", self._handle_msx_album_tracks)
442 self.app.router.add_get(
443 "/msx/artists/{item_id}/albums.json", self._handle_msx_artist_albums
444 )
445 self.app.router.add_get(
446 "/msx/playlists/{item_id}/tracks.json", self._handle_msx_playlist_tracks
447 )
448
449 # MSX queue playlist (MA queue â MSX native playlist)
450 self.app.router.add_get("/msx/queue-playlist/{player_id}.json", self._handle_queue_playlist)
451
452 # MSX playlist endpoints (native MSX playlist JSON)
453 self.app.router.add_get(
454 "/msx/playlist/album/{item_id}.json", self._handle_msx_album_playlist
455 )
456 self.app.router.add_get(
457 "/msx/playlist/playlist/{item_id}.json", self._handle_msx_playlist_playlist
458 )
459 self.app.router.add_get("/msx/playlist/tracks.json", self._handle_msx_tracks_playlist)
460 self.app.router.add_get(
461 "/msx/playlist/recently-played.json",
462 self._handle_msx_recently_played_playlist,
463 )
464 self.app.router.add_get("/msx/playlist/search.json", self._handle_msx_search_playlist)
465
466 # MSX audio playback
467 self.app.router.add_get("/msx/audio/{player_id}", self._handle_msx_audio)
468 self.app.router.add_get("/msx/audio/{player_id}.mp3", self._handle_msx_audio)
469
470 # Kiosk web player (browser-based, no MSX app needed)
471 self.app.router.add_get("/web", self._handle_web_app)
472 self.app.router.add_static("/web/", STATIC_DIR / "web")
473
474 # Health
475 self.app.router.add_get("/health", self._handle_health)
476
477 # WebSocket for push playback (MA -> MSX)
478 self.app.router.add_get("/ws", self._handle_ws)
479
480 # Stream proxy
481 self.app.router.add_get("/stream/{player_id}", self._handle_stream)
482 self.app.router.add_get("/stream/{player_id}.mp3", self._handle_stream)
483
484 def _setup_api_routes(self) -> None:
485 """Register Library and Playback API routes."""
486 # Library API
487 self.app.router.add_get("/api/albums", self._handle_albums)
488 self.app.router.add_get("/api/albums/{item_id}/tracks", self._handle_album_tracks)
489 self.app.router.add_get("/api/artists", self._handle_artists)
490 self.app.router.add_get("/api/artists/{item_id}/albums", self._handle_artist_albums)
491 self.app.router.add_get("/api/playlists", self._handle_playlists)
492 self.app.router.add_get("/api/playlists/{item_id}/tracks", self._handle_playlist_tracks)
493 self.app.router.add_get("/api/tracks", self._handle_tracks)
494 self.app.router.add_get("/api/search", self._handle_search)
495 self.app.router.add_get("/api/recently-played", self._handle_recently_played)
496 self.app.router.add_get("/api/lyrics/{player_id}", self._handle_lyrics)
497 self.app.router.add_get("/api/queue/{player_id}", self._handle_queue)
498 self.app.router.add_get("/api/party", self._handle_party_status)
499 self.app.router.add_get("/api/party/qr.svg", self._handle_party_qr)
500 self.app.router.add_get("/api/party/qr.png", self._handle_party_qr)
501 self.app.router.add_get("/api/party/qr-cover.png", self._handle_party_qr_cover)
502
503 # Playback control â GET (MSX interaction plugin) + POST (web player,
504 # dashboard). Never wildcard: extra methods only widen the CSRF surface.
505 self.app.router.add_post("/api/play", self._handle_play)
506 for path, handler in (
507 ("/api/pause/{player_id}", self._handle_pause),
508 ("/api/stop/{player_id}", self._handle_stop),
509 ("/api/quick-stop/{player_id}", self._handle_quick_stop),
510 ("/api/next/{player_id}", self._handle_next),
511 ("/api/previous/{player_id}", self._handle_previous),
512 ):
513 self.app.router.add_get(path, handler)
514 self.app.router.add_post(path, handler)
515
516 # --- Server Lifecycle ---
517
518 @web.middleware
519 async def _cors_middleware(self, request: web.Request, handler: Any) -> web.StreamResponse:
520 """
521 Add CORS headers to all responses.
522
523 Wildcard CORS is intentional: this server runs on LAN (default port 8099).
524 The web player (/web) and MSX plugin (/msx/plugin.html) are served from the
525 same origin, so browser playback-control POSTs are always same-origin.
526 MSX TV app only makes GET requests. This matches MA's own webserver pattern.
527 """
528 if request.method == "OPTIONS":
529 return web.Response(
530 headers={
531 "Access-Control-Allow-Origin": "*",
532 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
533 "Access-Control-Allow-Headers": "*",
534 }
535 )
536 response: web.StreamResponse = await handler(request)
537 response.headers["Access-Control-Allow-Origin"] = "*"
538 return response
539
540 # --- MSX Bootstrap Routes ---
541
542 async def _handle_root(self, request: web.Request) -> web.Response:
543 """Serve status dashboard."""
544 players = self.provider.players
545 # base is derived from the Host header, so escape it before embedding in HTML
546 prefix = self._get_prefix(request)
547 base = html_escape(prefix)
548 player_rows = []
549 for p in players:
550 row = (
551 f'<li class="player-row"><span>'
552 f"{html_escape(p.display_name)} â {html_escape(p.playback_state.value)}"
553 f"</span>"
554 )
555 row += f'<form method="post" action="{base}/api/quick-stop/{html_escape(p.player_id)}" '
556 row += 'style="display:inline">'
557 row += '<button type="submit" class="btn">Quick stop</button></form></li>'
558 player_rows.append(row)
559 player_info = "".join(player_rows) if player_rows else ""
560
561 # Build URLs
562 safe_host: str = html_escape(request.host) # escape for HTML display
563 _raw_host: str = request.url.host or request.host.split(":")[0] # IPv6-safe, no port
564 hostname = f"[{_raw_host}]" if ":" in _raw_host else _raw_host
565 sendspin_url = f"http://{hostname}:{SENDSPIN_SERVER_PORT}"
566 kiosk_html5_url = f"{base}/web?kiosk=1"
567 # escape the composed URL as a whole: host-derived prefix plus & separators
568 sendspin_query = f"sendspin=1&sendspin_url={quote(sendspin_url, safe='')}"
569 sendspin_web_url = html_escape(f"{prefix}/web?{sendspin_query}")
570 sendspin_kiosk_url = html_escape(f"{prefix}/web?kiosk=1&{sendspin_query}")
571
572 html = f"""<!DOCTYPE html>
573<html>
574<head><title>MSX Bridge</title>
575<style>
576body {{ font-family: system-ui, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }}
577.info {{ background: #e3f2fd; padding: 15px; border-radius: 5px; margin: 10px 0; }}
578.info-sendspin {{ background: #e8f5e9; }}
579code {{ background: #f5f5f5; padding: 2px 6px; border-radius: 3px; word-break: break-all; }}
580.player-row {{ display: flex; align-items: center; gap: 12px; margin: 8px 0; list-style: none; }}
581.player-row form {{ margin: 0; }}
582.btn {{ padding: 6px 12px; border-radius: 4px; border: 1px solid #1976d2;
583 background: #1976d2; color: white; cursor: pointer; font-size: 14px; }}
584.btn:hover {{ background: #1565c0; }}
585.link-row {{ margin: 8px 0; }}
586.builder-row {{ margin: 6px 0; }}
587.builder-row label {{ margin-right: 16px; cursor: pointer; }}
588.link-row a {{ color: #1976d2; text-decoration: none; }}
589.link-row a:hover {{ text-decoration: underline; }}
590small {{ color: #666; display: block; margin-top: 4px; }}
591</style>
592</head>
593<body>
594<h1>MSX Music Assistant Bridge</h1>
595
596<div class="info">
597<h3>MSX Setup URL</h3>
598<code>http://{safe_host}/msx/start.json</code>
599</div>
600
601<div class="info">
602<h3>Web Player</h3>
603<div class="link-row">
604<a href="/web">http://{safe_host}/web</a>
605<small>Browser-based player with library navigation (HTTP streaming)</small>
606</div>
607<div class="link-row">
608<a href="{kiosk_html5_url}">Kiosk Mode (HTML5)</a>
609<small>Fullscreen player with WebSocket push - ideal for dedicated displays</small>
610</div>
611</div>
612
613<div class="info info-sendspin">
614<h3>Sendspin Player (Synchronized Audio)</h3>
615<div class="link-row">
616<a href="{sendspin_web_url}">Web Player + Sendspin</a>
617<small>Library navigation with clock-synchronized audio</small>
618</div>
619<div class="link-row">
620<a href="{sendspin_kiosk_url}">Kiosk Mode (Sendspin)</a>
621<small>Fullscreen player with clock-synchronized audio</small>
622</div>
623<div class="link-row" style="margin-top: 12px;">
624<strong>Custom Sendspin URL:</strong><br>
625<code>/web?kiosk=1&sendspin=1&sendspin_url=http://<ma-server>:{SENDSPIN_SERVER_PORT}</code>
626</div>
627</div>
628
629<div class="info">
630<h3>Kiosk URL Builder</h3>
631<div id="kiosk-builder">
632<div class="builder-row">
633<label><input type="radio" name="kiosk-mode" value="html5" checked> HTML5</label>
634<label><input type="radio" name="kiosk-mode" value="sendspin"> Sendspin</label>
635</div>
636<div class="builder-row">
637<label><input type="checkbox" data-kiosk-param="controls" checked> Controls</label>
638<label><input type="checkbox" data-kiosk-param="party" checked> Party QR</label>
639<label><input type="checkbox" data-kiosk-param="viz" checked> Visualizer</label>
640<label><input type="checkbox" data-kiosk-param="lyrics" checked> Lyrics</label>
641</div>
642<div class="link-row">
643<a id="kiosk-builder-link" href="/web?kiosk=1" target="_blank">Open kiosk</a>
644</div>
645<code id="kiosk-builder-url"></code>
646</div>
647<script>
648(function () {{
649 var builder = document.getElementById('kiosk-builder');
650 var link = document.getElementById('kiosk-builder-link');
651 var urlOut = document.getElementById('kiosk-builder-url');
652
653 function rebuild() {{
654 var params = ['kiosk=1'];
655 var mode = builder.querySelector('input[name="kiosk-mode"]:checked').value;
656 if (mode === 'sendspin') {{
657 params.push('sendspin=1');
658 }}
659 var boxes = builder.querySelectorAll('input[data-kiosk-param]');
660 for (var i = 0; i < boxes.length; i++) {{
661 // only non-default choices land in the URL
662 if (!boxes[i].checked) {{
663 params.push(boxes[i].getAttribute('data-kiosk-param') + '=0');
664 }}
665 }}
666 var url = location.origin + '/web?' + params.join('&');
667 link.href = url;
668 urlOut.textContent = url;
669 }}
670
671 builder.addEventListener('change', rebuild);
672 rebuild();
673}})();
674</script>
675</div>
676
677<div class="info">
678<h3>Players</h3>
679<ul>{player_info or "<li>No players registered</li>"}</ul>
680</div>
681</body>
682</html>"""
683 return web.Response(text=html, content_type="text/html")
684
685 async def _handle_start_json(self, request: web.Request) -> web.Response:
686 """Return MSX start configuration pointing to the launcher menu."""
687 prefix = self._get_prefix(request)
688 return web.json_response(
689 {
690 "name": "Music Assistant",
691 "version": "1.0.7",
692 "parameter": f"content:{prefix}/msx/launcher.json",
693 }
694 )
695
696 async def _handle_launcher_json(self, request: web.Request) -> web.Response:
697 """Return MSX launcher page with MSX Player and Web Kiosk options."""
698 prefix = self._get_prefix(request)
699 content = MsxContent(
700 headline="Music Assistant",
701 template=MsxTemplate(
702 type="separate",
703 layout="0,0,2,4",
704 icon="msx-white-soft:music-note",
705 action="content:{context:content}",
706 ),
707 items=[
708 MsxItem(
709 label="MSX Player",
710 icon="msx-white-soft:tv",
711 action=f"menu:request:interaction:init@{prefix}/msx/plugin.html?v=8",
712 ),
713 MsxItem(
714 label="Web Kiosk",
715 icon="msx-white-soft:open-in-browser",
716 action=f"link:{prefix}/web?kiosk=1",
717 ),
718 ],
719 )
720 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
721
722 def _serve_static(self, filename: str) -> Any:
723 """Create a handler that serves a static file from the static directory."""
724 path = STATIC_DIR / filename
725
726 async def handler(_request: web.Request) -> web.FileResponse:
727 return web.FileResponse(path)
728
729 return handler
730
731 async def _handle_msx_plugin_html(self, _request: web.Request) -> web.StreamResponse:
732 """Serve plugin.html with cache-busting headers."""
733 response = cast("web.StreamResponse", web.FileResponse(STATIC_DIR / "plugin.html"))
734 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
735 response.headers["Pragma"] = "no-cache"
736 response.headers["Expires"] = "0"
737 return response
738
739 async def _handle_msx_input_html(self, request: web.Request) -> web.FileResponse:
740 """Serve input.html and ensure player is registered when Search is opened."""
741 await self._ensure_player_for_request(request)
742 return web.FileResponse(STATIC_DIR / "input.html")
743
744 async def _handle_web_app(self, request: web.Request) -> web.Response:
745 """Serve the web player SPA (browser-based, no MSX app needed)."""
746 response = cast("web.Response", web.FileResponse(STATIC_DIR / "web" / "index.html"))
747 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
748 return response
749
750 # --- MSX Content Pages (native MSX JSON) ---
751
752 async def _handle_msx_menu(self, request: web.Request) -> web.Response:
753 """Return the main library menu as an MSX content page."""
754 _, device_param, _ = await self._ensure_player_for_request(request)
755 prefix = self._get_prefix(request)
756 items = [
757 (
758 "Recently played",
759 "msx-white-soft:history",
760 f"{prefix}/msx/recently-played.json",
761 ),
762 ("Albums", "msx-white-soft:album", f"{prefix}/msx/albums.json"),
763 ("Artists", "msx-white-soft:person", f"{prefix}/msx/artists.json"),
764 (
765 "Playlists",
766 "msx-white-soft:playlist-play",
767 f"{prefix}/msx/playlists.json",
768 ),
769 ("Tracks", "msx-white-soft:audiotrack", f"{prefix}/msx/tracks.json"),
770 ("Search", "search", f"{prefix}/msx/search-page.json"),
771 ]
772 if await self._get_active_party() is not None:
773 items.append(("Party", "msx-white-soft:qr-code", f"{prefix}/msx/party.json"))
774 content = MsxContent(
775 headline="Music Assistant",
776 template=MsxTemplate(
777 type="separate",
778 layout="0,0,2,4",
779 icon="msx-white-soft:music-note",
780 action="content:{context:content}",
781 ),
782 items=[
783 MsxItem(
784 label=label,
785 icon=icon,
786 content=append_device_param(url, device_param),
787 )
788 for label, icon, url in items
789 ],
790 )
791 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
792
793 async def _handle_msx_albums(self, request: web.Request) -> web.Response:
794 """Return albums as an MSX content page."""
795 _, device_param, _ = await self._ensure_player_for_request(request)
796 prefix = self._get_prefix(request)
797 limit = _int_param(request.query, "limit", 50)
798 offset = _int_param(request.query, "offset", 0)
799 try:
800 albums = await asyncio.wait_for(
801 self.provider.mass.music.albums.library_items(
802 limit=limit, offset=offset, summary=False
803 ),
804 timeout=10.0,
805 )
806 except Exception:
807 logger.exception("Failed to fetch albums")
808 albums = []
809
810 items = await asyncio.gather(
811 *(map_album_to_msx(a, prefix, self.provider, device_param) for a in albums)
812 )
813 content = MsxContent(
814 headline="Albums",
815 template=MsxTemplate(
816 type="separate",
817 layout="0,0,3,4",
818 color="msx-glass",
819 ),
820 items=items if items else [MsxItem(title="No albums found")],
821 )
822 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
823
824 async def _handle_msx_artists(self, request: web.Request) -> web.Response:
825 """Return artists as an MSX content page."""
826 _, device_param, _ = await self._ensure_player_for_request(request)
827 prefix = self._get_prefix(request)
828 limit = _int_param(request.query, "limit", 50)
829 offset = _int_param(request.query, "offset", 0)
830 try:
831 artists = await asyncio.wait_for(
832 self.provider.mass.music.artists.library_items(
833 limit=limit, offset=offset, summary=False
834 ),
835 timeout=10.0,
836 )
837 except Exception:
838 logger.exception("Failed to fetch artists")
839 artists = []
840
841 items = [map_artist_to_msx(a, prefix, self.provider, device_param) for a in artists]
842 content = MsxContent(
843 headline="Artists",
844 template=MsxTemplate(
845 type="separate",
846 layout="0,0,2,3",
847 color="msx-glass",
848 ),
849 items=items if items else [MsxItem(title="No artists found")],
850 )
851 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
852
853 async def _handle_msx_playlists(self, request: web.Request) -> web.Response:
854 """Return playlists as an MSX content page."""
855 _, device_param, _ = await self._ensure_player_for_request(request)
856 prefix = self._get_prefix(request)
857 limit = _int_param(request.query, "limit", 50)
858 offset = _int_param(request.query, "offset", 0)
859 try:
860 playlists = await asyncio.wait_for(
861 self.provider.mass.music.playlists.library_items(
862 limit=limit, offset=offset, summary=False
863 ),
864 timeout=10.0,
865 )
866 except Exception:
867 logger.exception("Failed to fetch playlists")
868 playlists = []
869
870 items = [map_playlist_to_msx(p, prefix, self.provider, device_param) for p in playlists]
871 content = MsxContent(
872 headline="Playlists",
873 template=MsxTemplate(
874 type="separate",
875 layout="0,0,3,4",
876 color="msx-glass",
877 ),
878 items=items if items else [MsxItem(title="No playlists found")],
879 )
880 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
881
882 async def _handle_msx_tracks(self, request: web.Request) -> web.Response:
883 """Return tracks as an MSX content page."""
884 player_id, device_param, _ = await self._ensure_player_for_request(request)
885 prefix = self._get_prefix(request)
886 limit = _int_param(request.query, "limit", 50)
887 offset = _int_param(request.query, "offset", 0)
888 try:
889 tracks = await asyncio.wait_for(
890 self.provider.mass.music.tracks.library_items(
891 limit=limit, offset=offset, summary=False
892 ),
893 timeout=10.0,
894 )
895 except Exception:
896 logger.exception("Failed to fetch tracks")
897 tracks = []
898
899 playlist_base = f"{prefix}/msx/playlist/tracks.json?limit={limit}&offset={offset}"
900 playlist_base = append_device_param(playlist_base, device_param)
901 items = [
902 map_track_to_msx(
903 t,
904 prefix,
905 player_id,
906 self.provider,
907 device_param,
908 playlist_url=f"{playlist_base}&start={idx}",
909 )
910 for idx, t in enumerate(tracks)
911 ]
912 content = MsxContent(
913 headline="Tracks",
914 template=MsxTemplate(
915 type="default",
916 layout="0,0,6,1",
917 image_width=0.83,
918 color="msx-glass",
919 ),
920 items=items if items else [MsxItem(title="No tracks found")],
921 )
922 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
923
924 async def _handle_msx_recently_played(self, request: web.Request) -> web.Response:
925 """Return recently played tracks as an MSX content page."""
926 player_id, device_param, _ = await self._ensure_player_for_request(request)
927 prefix = self._get_prefix(request)
928 try:
929 tracks = await asyncio.wait_for(
930 self.provider.mass.music.tracks.library_items(
931 limit=50, order_by="last_played", summary=False
932 ),
933 timeout=10.0,
934 )
935 except Exception:
936 logger.exception("Failed to fetch recently played tracks")
937 tracks = []
938 playlist_base = f"{prefix}/msx/playlist/recently-played.json"
939 playlist_base = append_device_param(playlist_base, device_param)
940 items = [
941 map_track_to_msx(
942 t,
943 prefix,
944 player_id,
945 self.provider,
946 device_param,
947 playlist_url=f"{playlist_base}{'&' if '?' in playlist_base else '?'}start={idx}",
948 )
949 for idx, t in enumerate(tracks)
950 ]
951 content = MsxContent(
952 headline="Recently played",
953 template=MsxTemplate(
954 type="default",
955 layout="0,0,6,1",
956 image_width=0.83,
957 color="msx-glass",
958 ),
959 items=items if items else [MsxItem(title="No recently played tracks")],
960 )
961 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
962
963 async def _handle_msx_search_page(self, request: web.Request) -> web.Response:
964 """Return a content page whose page-level action launches the Input Plugin keyboard."""
965 _, device_param, _ = await self._ensure_player_for_request(request)
966 prefix = self._get_prefix(request)
967 search_url = append_device_param(
968 f"{prefix}/msx/search-input.json?q={{INPUT}}", device_param
969 )
970 action = (
971 f"content:request:interaction:"
972 f"{search_url}"
973 f"|search:3|en|Search Music||||Search..."
974 f"@{prefix}/msx/input.html"
975 )
976 content = MsxContent(
977 headline="Search",
978 action=action,
979 template=MsxTemplate(
980 type="separate",
981 layout="0,0,2,4",
982 ),
983 items=[
984 MsxItem(
985 title="Search Music",
986 title_footer="Press OK to open keyboard",
987 icon="search",
988 action=action,
989 )
990 ],
991 )
992 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
993
994 async def _handle_msx_search_input(self, request: web.Request) -> web.Response:
995 """Return search results for the MSX Input Plugin (search keyboard)."""
996 player_id, device_param, _ = await self._ensure_player_for_request(request)
997 prefix = self._get_prefix(request)
998 query = request.query.get("q", "")
999 if not query:
1000 content = MsxContent(
1001 headline="{ico:search} Search",
1002 hint="Type to search...",
1003 template=MsxTemplate(
1004 type="separate",
1005 layout="0,0,2,4",
1006 image_filler="default",
1007 ),
1008 items=[MsxItem(title="Start typing to search")],
1009 )
1010 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1011
1012 limit = _int_param(request.query, "limit", 20)
1013 items = await self._build_search_items(
1014 query,
1015 limit,
1016 player_id,
1017 device_param,
1018 prefix,
1019 )
1020
1021 content = MsxContent(
1022 headline=f'{{ico:search}} "{query}"',
1023 hint=f"Found {len(items)} items",
1024 template=MsxTemplate(
1025 type="separate",
1026 layout="0,0,2,4",
1027 image_filler="default",
1028 ),
1029 items=items if items else [MsxItem(title="No results found")],
1030 )
1031 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1032
1033 async def _handle_msx_search(self, request: web.Request) -> web.Response:
1034 """Return search results as an MSX content page."""
1035 player_id, device_param, _ = await self._ensure_player_for_request(request)
1036 prefix = self._get_prefix(request)
1037 query = request.query.get("q", "")
1038 if not query:
1039 return web.json_response(
1040 MsxContent(
1041 headline="Search",
1042 items=[MsxItem(title="Please enter a search query")],
1043 ).model_dump(by_alias=True, exclude_none=True)
1044 )
1045
1046 limit = _int_param(request.query, "limit", 20)
1047 items = await self._build_search_items(
1048 query,
1049 limit,
1050 player_id,
1051 device_param,
1052 prefix,
1053 )
1054
1055 content = MsxContent(
1056 headline=f"Search: {query}",
1057 template=MsxTemplate(
1058 type="separate",
1059 layout="0,0,2,4",
1060 image_filler="default",
1061 ),
1062 items=items if items else [MsxItem(title="No results found")],
1063 )
1064 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1065
1066 async def _handle_msx_party(self, request: web.Request) -> web.Response:
1067 """Return MSX page with the party QR code, or a hint when no party is active."""
1068 await self._ensure_player_for_request(request)
1069 prefix = self._get_prefix(request)
1070 party = await self._get_active_party()
1071 if party is None:
1072 item = MsxItem(
1073 title="No active party",
1074 label="Enable guest access in the Music Assistant Party plugin",
1075 )
1076 else:
1077 # PNG, not SVG: MSX image slots on older TV engines cannot decode SVG
1078 item = MsxItem(
1079 image=f"{prefix}/api/party/qr.png",
1080 label=party.qr_text or "Scan to join the party",
1081 )
1082 content = MsxContent(
1083 headline=(party.name if party else None) or "Party",
1084 template=MsxTemplate(type="separate", layout="0,0,4,4"),
1085 items=[item],
1086 )
1087 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1088
1089 async def _build_search_items(
1090 self,
1091 query: str,
1092 limit: int,
1093 player_id: str,
1094 device_param: str,
1095 prefix: str,
1096 ) -> list[MsxItem]:
1097 """Build MSX items from search results (shared by search handlers)."""
1098 results = await self.provider.mass.music.search(query, limit=limit)
1099 items: list[MsxItem] = []
1100 for artist in results.artists:
1101 item = map_artist_to_msx(artist, prefix, self.provider, device_param)
1102 item.label = "Artist"
1103 item.icon = "msx-white-soft:person"
1104 items.append(item)
1105 for album in results.albums:
1106 item = await map_album_to_msx(album, prefix, self.provider, device_param)
1107 item.label = f"Album â {getattr(album, 'artist_str', '')}"
1108 item.icon = "msx-white-soft:album"
1109 items.append(item)
1110 playlist_base = f"{prefix}/msx/playlist/search.json?q={quote(query, safe='')}"
1111 playlist_base = append_device_param(playlist_base, device_param)
1112 for idx, track in enumerate(results.tracks):
1113 item = map_track_to_msx(
1114 track,
1115 prefix,
1116 player_id,
1117 self.provider,
1118 device_param,
1119 playlist_url=f"{playlist_base}&start={idx}",
1120 )
1121 item.label = f"Track â {getattr(track, 'artist_str', '')}"
1122 item.icon = "msx-white-soft:audiotrack"
1123 items.append(item)
1124 return items
1125
1126 # --- MSX Detail Pages ---
1127
1128 async def _handle_msx_album_tracks(self, request: web.Request) -> web.Response:
1129 """Return tracks for an album as an MSX content page."""
1130 player_id, device_param, _ = await self._ensure_player_for_request(request)
1131 prefix = self._get_prefix(request)
1132 item_id = request.match_info["item_id"]
1133 provider = request.query.get("provider", "library")
1134 try:
1135 tracks = _sort_album_tracks(
1136 await self.provider.mass.music.albums.tracks(item_id, provider)
1137 )
1138 except Exception:
1139 logger.exception("Failed to fetch tracks for album %s", item_id)
1140 tracks = []
1141 playlist_base = f"{prefix}/msx/playlist/album/{item_id}.json?provider={provider}"
1142 playlist_base = append_device_param(playlist_base, device_param)
1143 items = [
1144 map_track_to_msx(
1145 t,
1146 prefix,
1147 player_id,
1148 self.provider,
1149 device_param,
1150 playlist_url=f"{playlist_base}&start={idx}",
1151 )
1152 for idx, t in enumerate(tracks)
1153 ]
1154 content = MsxContent(
1155 headline="Album Tracks",
1156 template=MsxTemplate(
1157 type="default",
1158 layout="0,0,6,1",
1159 image_width=0.83,
1160 color="msx-glass",
1161 ),
1162 items=items if items else [MsxItem(title="No tracks found")],
1163 )
1164 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1165
1166 async def _handle_msx_artist_albums(self, request: web.Request) -> web.Response:
1167 """Return albums for an artist as an MSX content page."""
1168 _, device_param, _ = await self._ensure_player_for_request(request)
1169 prefix = self._get_prefix(request)
1170 item_id = request.match_info["item_id"]
1171 try:
1172 albums = await self.provider.mass.music.artists.albums(item_id, "library")
1173 except Exception:
1174 logger.exception("Failed to fetch albums for artist %s", item_id)
1175 albums = []
1176
1177 items = await asyncio.gather(
1178 *(map_album_to_msx(a, prefix, self.provider, device_param) for a in albums)
1179 )
1180 content = MsxContent(
1181 headline="Artist Albums",
1182 template=MsxTemplate(
1183 type="default",
1184 layout="0,0,6,2",
1185 image_width=1.5,
1186 color="msx-glass",
1187 ),
1188 items=items if items else [MsxItem(title="No albums found")],
1189 )
1190 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1191
1192 async def _handle_msx_playlist_tracks(self, request: web.Request) -> web.Response:
1193 """Return tracks for a playlist as an MSX content page."""
1194 player_id, device_param, _ = await self._ensure_player_for_request(request)
1195 prefix = self._get_prefix(request)
1196 item_id = request.match_info["item_id"]
1197 try:
1198 tracks = [
1199 t async for t in self.provider.mass.music.playlists.tracks(item_id, "library")
1200 ]
1201 except Exception:
1202 logger.exception("Failed to fetch tracks for playlist %s", item_id)
1203 tracks = []
1204 playlist_base = f"{prefix}/msx/playlist/playlist/{item_id}.json"
1205 playlist_base = append_device_param(playlist_base, device_param)
1206 items = [
1207 map_track_to_msx(
1208 t,
1209 prefix,
1210 player_id,
1211 self.provider,
1212 device_param,
1213 playlist_url=f"{playlist_base}{'&' if '?' in playlist_base else '?'}start={idx}",
1214 )
1215 for idx, t in enumerate(tracks)
1216 ]
1217 content = MsxContent(
1218 headline="Playlist Tracks",
1219 template=MsxTemplate(
1220 type="default",
1221 layout="0,0,6,1",
1222 image_width=0.83,
1223 color="msx-glass",
1224 ),
1225 items=items if items else [MsxItem(title="No tracks found")],
1226 )
1227 return web.json_response(content.model_dump(by_alias=True, exclude_none=True))
1228
1229 # --- MSX Playlist Endpoints ---
1230
1231 async def _handle_msx_album_playlist(self, request: web.Request) -> web.Response:
1232 """Return album tracks as an MSX playlist JSON."""
1233 player_id, device_param, _ = await self._ensure_player_for_request(request)
1234 prefix = self._get_prefix(request)
1235 item_id = request.match_info["item_id"]
1236 provider_name = request.query.get("provider", "library")
1237 start = _int_param(request.query, "start", 0)
1238 try:
1239 tracks = _sort_album_tracks(
1240 await self.provider.mass.music.albums.tracks(item_id, provider_name)
1241 )
1242 except Exception:
1243 logger.exception("Failed to fetch tracks for album playlist %s", item_id)
1244 tracks = []
1245 playlist = map_tracks_to_msx_playlist(
1246 tracks,
1247 start,
1248 prefix,
1249 player_id,
1250 self.provider,
1251 device_param,
1252 qr_cover_base=await self._qr_cover_base(prefix),
1253 )
1254 return web.json_response(playlist.model_dump(by_alias=True, exclude_none=True))
1255
1256 async def _handle_msx_playlist_playlist(self, request: web.Request) -> web.Response:
1257 """Return playlist tracks as an MSX playlist JSON."""
1258 player_id, device_param, _ = await self._ensure_player_for_request(request)
1259 prefix = self._get_prefix(request)
1260 item_id = request.match_info["item_id"]
1261 start = _int_param(request.query, "start", 0)
1262 try:
1263 tracks = [
1264 t async for t in self.provider.mass.music.playlists.tracks(item_id, "library")
1265 ]
1266 except Exception:
1267 logger.exception("Failed to fetch tracks for playlist playlist %s", item_id)
1268 tracks = []
1269 playlist = map_tracks_to_msx_playlist(
1270 tracks,
1271 start,
1272 prefix,
1273 player_id,
1274 self.provider,
1275 device_param,
1276 qr_cover_base=await self._qr_cover_base(prefix),
1277 )
1278 return web.json_response(playlist.model_dump(by_alias=True, exclude_none=True))
1279
1280 async def _handle_msx_tracks_playlist(self, request: web.Request) -> web.Response:
1281 """Return library tracks as an MSX playlist JSON."""
1282 player_id, device_param, _ = await self._ensure_player_for_request(request)
1283 prefix = self._get_prefix(request)
1284 limit = _int_param(request.query, "limit", 50)
1285 offset = _int_param(request.query, "offset", 0)
1286 start = _int_param(request.query, "start", 0)
1287 tracks = await self.provider.mass.music.tracks.library_items(
1288 limit=limit, offset=offset, summary=False
1289 )
1290 playlist = map_tracks_to_msx_playlist(
1291 list(tracks),
1292 start,
1293 prefix,
1294 player_id,
1295 self.provider,
1296 device_param,
1297 qr_cover_base=await self._qr_cover_base(prefix),
1298 )
1299 return web.json_response(playlist.model_dump(by_alias=True, exclude_none=True))
1300
1301 async def _handle_msx_recently_played_playlist(self, request: web.Request) -> web.Response:
1302 """Return recently played tracks as an MSX playlist JSON."""
1303 player_id, device_param, _ = await self._ensure_player_for_request(request)
1304 prefix = self._get_prefix(request)
1305 start = _int_param(request.query, "start", 0)
1306 tracks = await self.provider.mass.music.tracks.library_items(
1307 limit=50, order_by="last_played", summary=False
1308 )
1309 playlist = map_tracks_to_msx_playlist(
1310 list(tracks),
1311 start,
1312 prefix,
1313 player_id,
1314 self.provider,
1315 device_param,
1316 qr_cover_base=await self._qr_cover_base(prefix),
1317 )
1318 return web.json_response(playlist.model_dump(by_alias=True, exclude_none=True))
1319
1320 async def _handle_msx_search_playlist(self, request: web.Request) -> web.Response:
1321 """Return search track results as an MSX playlist JSON."""
1322 player_id, device_param, _ = await self._ensure_player_for_request(request)
1323 prefix = self._get_prefix(request)
1324 query = request.query.get("q", "")
1325 start = _int_param(request.query, "start", 0)
1326 if not query:
1327 return web.json_response(
1328 MsxContent(items=[]).model_dump(by_alias=True, exclude_none=True)
1329 )
1330 limit = _int_param(request.query, "limit", 20)
1331 results = await self.provider.mass.music.search(query, limit=limit)
1332 playlist = map_tracks_to_msx_playlist(
1333 list(results.tracks),
1334 start,
1335 prefix,
1336 player_id,
1337 self.provider,
1338 device_param,
1339 qr_cover_base=await self._qr_cover_base(prefix),
1340 )
1341 return web.json_response(playlist.model_dump(by_alias=True, exclude_none=True))
1342
1343 # --- MSX Queue Playlist ---
1344
1345 async def _handle_queue_playlist(self, request: web.Request) -> web.Response:
1346 """Return the current MA queue as an MSX native playlist."""
1347 _, device_param, _ = await self._ensure_player_for_request(request)
1348 prefix = self._get_prefix(request)
1349 player_id = request.match_info["player_id"]
1350 queue_id = request.query.get("queue_id", player_id)
1351 start = _int_param(request.query, "start", 0)
1352
1353 try:
1354 queue_items = self.provider.mass.player_queues.items(queue_id)
1355 except Exception:
1356 logger.exception("Failed to fetch queue items for %s", player_id)
1357 queue_items = []
1358
1359 # Convert QueueItems to track-like objects for map_tracks_to_msx_playlist
1360 tracks: list[Any] = []
1361 for qi in queue_items:
1362 mi = getattr(qi, "media_item", None)
1363 tracks.append(
1364 SimpleNamespace(
1365 name=getattr(mi, "name", None) or getattr(qi, "name", "") or "",
1366 uri=getattr(mi, "uri", None) or "",
1367 duration=getattr(mi, "duration", None) or getattr(qi, "duration", 0) or 0,
1368 artist_str=getattr(mi, "artist_str", "") if mi else "",
1369 image=getattr(qi, "image", None),
1370 )
1371 )
1372
1373 playlist = map_tracks_to_msx_playlist(
1374 tracks,
1375 start,
1376 prefix,
1377 player_id,
1378 self.provider,
1379 device_param,
1380 qr_cover_base=await self._qr_cover_base(prefix),
1381 )
1382 return web.json_response(playlist.model_dump(by_alias=True, exclude_none=True))
1383
1384 # --- MSX Audio Playback ---
1385
1386 async def _handle_msx_audio(self, request: web.Request) -> web.StreamResponse:
1387 """Trigger playback via MA queue and stream audio to MSX."""
1388 player_id = _strip_known_extension(request.match_info["player_id"])
1389
1390 uri = request.query.get("uri")
1391 if not uri or "://" not in uri:
1392 return web.Response(status=400, text="Invalid uri parameter")
1393
1394 from_playlist = request.query.get("from_playlist") == "1"
1395
1396 self.provider.on_player_activity(player_id)
1397 player = self.provider.mass.players.get_player(player_id)
1398 if not player or not isinstance(player, MSXPlayer):
1399 return web.Response(status=404, text="Player not found")
1400
1401 # When MA is driving the queue (next/prev from MA UI), current_media is
1402 # already set by player.play_media() before the WS goto_index reaches MSX.
1403 # Re-enqueuing would recreate the queue from the track URI, destroying it.
1404 # We verify by checking that current_media's queue item URI matches the
1405 # requested track URI â if not, MSX auto-advanced and we must re-enqueue.
1406 if (
1407 from_playlist
1408 and player._playing_from_queue
1409 and self._current_media_matches_uri(player, uri)
1410 ):
1411 logger.debug("Queue-driven: using current_media for %s", uri)
1412 media = player.current_media
1413 else:
1414 # Suppress WS broadcast when called from MSX playlist to avoid conflicts
1415 if from_playlist:
1416 player._skip_ws_notify = True
1417
1418 # Arm BEFORE enqueuing so wait_for_media() waits for the new track's
1419 # play_media() instead of returning the previous track's media.
1420 player.expect_new_media()
1421 try:
1422 async with ImpersonatedUser(
1423 self.provider.mass, await self.provider.get_owner_username()
1424 ):
1425 await self.provider.mass.player_queues.play_media(player_id, uri)
1426 finally:
1427 if from_playlist:
1428 player._skip_ws_notify = False
1429
1430 # Wait for play_media() to signal media is ready (replaces 10s polling loop)
1431 media = await player.wait_for_media(timeout=10.0)
1432
1433 if not media:
1434 return web.Response(status=504, text="Playback setup timeout")
1435
1436 return await self._serve_audio_stream(
1437 request,
1438 player,
1439 media,
1440 duration=self._resolve_served_duration(media),
1441 )
1442
1443 # --- Audio Streaming Infrastructure ---
1444
1445 def _resolve_served_duration(self, media: PlayerMedia) -> int:
1446 """
1447 Return the length in seconds of the audio served for the given media, or 0 if unknown.
1448
1449 This is what the Content-Length header is derived from, so it describes
1450 the audio we actually serve rather than the media item: starting
1451 playback at a seek position yields a shorter stream.
1452
1453 :param media: The media being served.
1454 """
1455 duration = media.stream_duration or media.duration or 0
1456 if not duration and media.source_id and media.queue_item_id:
1457 queue_item = self.provider.mass.player_queues.get_item(
1458 media.source_id, media.queue_item_id
1459 )
1460 if queue_item:
1461 if queue_item.media_item:
1462 duration = getattr(queue_item.media_item, "duration", None) or duration
1463 if not duration and queue_item.duration:
1464 duration = queue_item.duration
1465 return int(duration)
1466
1467 @staticmethod
1468 def _build_audio_params(
1469 output_format_str: str, duration: int
1470 ) -> tuple[AudioFormat, AudioFormat, dict[str, str]]:
1471 """Build PCM input format, encoded output format, and HTTP headers."""
1472 pcm_format = AudioFormat(
1473 content_type=ContentType.PCM_S16LE,
1474 sample_rate=44100,
1475 bit_depth=16,
1476 channels=2,
1477 )
1478 content_type_map: dict[str, tuple[ContentType, str]] = {
1479 "mp3": (ContentType.MP3, "audio/mpeg"),
1480 "aac": (ContentType.AAC, "audio/aac"),
1481 "flac": (ContentType.FLAC, "audio/flac"),
1482 }
1483 codec, mime_type = content_type_map.get(output_format_str, (ContentType.MP3, "audio/mpeg"))
1484 out_format = AudioFormat(
1485 content_type=codec,
1486 sample_rate=44100,
1487 bit_depth=16,
1488 channels=2,
1489 )
1490 bitrate_map = {"mp3": 40_000, "aac": 32_000}
1491 bytes_per_sec = bitrate_map.get(output_format_str, 0)
1492 headers: dict[str, str] = {
1493 "Content-Type": mime_type,
1494 "Cache-Control": "no-cache",
1495 "Connection": "keep-alive",
1496 "Accept-Ranges": "none",
1497 }
1498 if duration and bytes_per_sec:
1499 capped_duration = min(float(duration), 43200) # cap at 12h
1500 headers["Content-Length"] = str(int(capped_duration * bytes_per_sec))
1501 return pcm_format, out_format, headers
1502
1503 async def _serve_audio_stream(
1504 self,
1505 request: web.Request,
1506 player: MSXPlayer,
1507 media: Any,
1508 duration: int = 0,
1509 ) -> web.StreamResponse:
1510 """
1511 Unified method to stream audio from MA to MSX via ffmpeg.
1512
1513 Supports three modes based on provider configuration:
1514 1. Independent (default): Each player gets its own ffmpeg stream
1515 2. Shared Buffer: Group members share one ffmpeg process via SharedGroupStream
1516 3. MA Redirect: 302 redirect to MA Streamserver (requires MA 2.6+)
1517
1518 Pre-buffers audio data before sending HTTP headers so MSX receives
1519 the response and initial audio burst simultaneously, preventing
1520 stutter/restart from an empty initial buffer.
1521 """
1522 player_id = player.player_id
1523
1524 # --- Mode 1: MA Redirect ---
1525 if self.provider.is_redirect_stream_mode():
1526 redirect_url = await self.provider.get_ma_stream_url(player_id, media)
1527 if redirect_url:
1528 redirect_url = self._rewrite_stream_host(request, redirect_url)
1529 logger.info(
1530 "[StreamMode:redirect] Player %s -> MA Streamserver: %s",
1531 player_id,
1532 redirect_url,
1533 )
1534 raise web.HTTPFound(location=redirect_url)
1535 # Fallback to independent mode if redirect fails
1536 logger.warning(
1537 "[StreamMode:redirect] Failed to get MA URL for %s, "
1538 "falling back to independent mode",
1539 player_id,
1540 )
1541
1542 # Resolve effective output format: per-player config overrides provider default.
1543 # CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3 uses key "output_codec"; fall back to
1544 # player.output_format (set from provider-level config during registration).
1545 # Only the proxy paths below need this â in redirect mode the MA streamserver
1546 # applies the same per-player codec config itself.
1547 effective_format = cast(
1548 "str",
1549 player.config.get_value("output_codec", player.output_format),
1550 )
1551
1552 pcm_format, out_format, headers = self._build_audio_params(
1553 effective_format,
1554 duration,
1555 )
1556
1557 # --- Mode 2: Shared Buffer (for groups) ---
1558 group_id = self.provider.get_group_id_for_player(player)
1559 if group_id and self.provider.is_shared_stream_mode():
1560 logger.info(
1561 "[StreamMode:shared] Player %s in group %s, using shared stream",
1562 player_id,
1563 group_id,
1564 )
1565 return await self._serve_shared_stream(
1566 request, player, media, group_id, pcm_format, out_format, headers
1567 )
1568
1569 # --- Mode 3: Independent (default) ---
1570 logger.debug(
1571 "[StreamMode:independent] Serving audio %s: format=%s, duration=%s",
1572 player_id,
1573 effective_format,
1574 duration,
1575 )
1576
1577 audio_source = self.provider.mass.streams.get_stream(
1578 media,
1579 pcm_format,
1580 force_flow_mode=False,
1581 )
1582 output_plan = self.provider.mass.streams.audio.get_player_output_plan(
1583 player_id,
1584 pcm_format,
1585 out_format,
1586 queue_id=getattr(media, "source_id", None),
1587 session_id=get_media_session_id(media),
1588 queue_item_id=getattr(media, "queue_item_id", None),
1589 )
1590
1591 response = web.StreamResponse(status=200, headers=headers)
1592 stream_task: asyncio.Task[None] = asyncio.create_task(
1593 self._stream_with_prebuffer(
1594 request,
1595 response,
1596 player,
1597 headers,
1598 audio_source,
1599 pcm_format,
1600 out_format,
1601 output_plan.filter_params,
1602 )
1603 )
1604 transport = getattr(request, "transport", None)
1605 await self._run_stream_task(player_id, stream_task, transport)
1606
1607 return response
1608
1609 async def _serve_shared_stream(
1610 self,
1611 request: web.Request,
1612 player: MSXPlayer,
1613 media: Any,
1614 group_id: str,
1615 pcm_format: AudioFormat,
1616 out_format: AudioFormat,
1617 headers: dict[str, str],
1618 ) -> web.StreamResponse:
1619 """
1620 Serve audio from a shared group stream.
1621
1622 Multiple players in a group read from the same SharedGroupStream,
1623 which has a single ffmpeg producer.
1624 """
1625 player_id = player.player_id
1626 media_uri = getattr(media, "uri", "") or str(media)
1627
1628 # Check if we need to create a new shared stream (leader creates it)
1629 existing_stream = self.provider._shared_streams.get(group_id)
1630 is_leader = player_id == group_id
1631
1632 if existing_stream and not existing_stream.finished:
1633 # Reuse existing stream
1634 logger.debug(
1635 "[SharedStream] Player %s subscribing to existing stream for group %s",
1636 player_id,
1637 group_id,
1638 )
1639 shared_stream = existing_stream
1640 elif is_leader:
1641 # Leader creates the shared stream
1642 logger.info(
1643 "[SharedStream] Leader %s creating shared stream for group %s",
1644 player_id,
1645 group_id,
1646 )
1647 audio_source = self.provider.mass.streams.get_stream(
1648 media,
1649 pcm_format,
1650 force_flow_mode=False,
1651 )
1652 output_plan = self.provider.mass.streams.audio.get_player_output_plan(
1653 player_id,
1654 pcm_format,
1655 out_format,
1656 queue_id=getattr(media, "source_id", None),
1657 session_id=get_media_session_id(media),
1658 queue_item_id=getattr(media, "queue_item_id", None),
1659 )
1660 # Create ffmpeg chunk generator
1661 audio_chunks = get_ffmpeg_stream(
1662 audio_input=audio_source,
1663 input_format=pcm_format,
1664 output_format=out_format,
1665 filter_params=output_plan.filter_params,
1666 )
1667 shared_stream = await self.provider.get_or_create_shared_stream(
1668 group_id, media_uri, audio_chunks
1669 )
1670 shared_stream.output_plan = output_plan
1671 else:
1672 # Member but no existing stream - wait briefly for leader
1673 logger.info(
1674 "[SharedStream] Member %s waiting for leader to create stream for group %s",
1675 player_id,
1676 group_id,
1677 )
1678 for _ in range(30): # Wait up to 3 seconds
1679 await asyncio.sleep(0.1)
1680 existing_stream = self.provider._shared_streams.get(group_id)
1681 if existing_stream and not existing_stream.finished:
1682 shared_stream = existing_stream
1683 break
1684 else:
1685 # Timeout - fallback to independent stream
1686 logger.warning(
1687 "[SharedStream] Timeout waiting for leader stream, "
1688 "falling back to independent for %s",
1689 player_id,
1690 )
1691 return await self._serve_independent_stream(
1692 request, player, media, pcm_format, out_format, headers
1693 )
1694
1695 queue_id = getattr(media, "source_id", None)
1696 session_id = get_media_session_id(media)
1697 if (
1698 shared_stream.output_plan is not None
1699 and queue_id is not None
1700 and session_id is not None
1701 ):
1702 self.provider.mass.streams.audio_processing.update_output(
1703 player_id,
1704 shared_stream.output_plan,
1705 queue_id=queue_id,
1706 session_id=session_id,
1707 queue_item_id=getattr(media, "queue_item_id", None),
1708 )
1709
1710 # Subscribe to shared stream
1711 response = web.StreamResponse(status=200, headers=headers)
1712 await response.prepare(request)
1713
1714 total_bytes = 0
1715 try:
1716 async for chunk in shared_stream.subscribe(player_id):
1717 await response.write(chunk)
1718 total_bytes += len(chunk)
1719 except ConnectionResetError, BrokenPipeError, ConnectionAbortedError:
1720 logger.debug(
1721 "[SharedStream] Client %s disconnected after %d bytes",
1722 player_id,
1723 total_bytes,
1724 )
1725 except asyncio.CancelledError:
1726 logger.debug("[SharedStream] Stream cancelled for %s", player_id)
1727 raise
1728
1729 logger.info(
1730 "[SharedStream] Player %s finished, wrote %d bytes",
1731 player_id,
1732 total_bytes,
1733 )
1734 return response
1735
1736 async def _serve_independent_stream(
1737 self,
1738 request: web.Request,
1739 player: MSXPlayer,
1740 media: Any,
1741 pcm_format: AudioFormat,
1742 out_format: AudioFormat,
1743 headers: dict[str, str],
1744 ) -> web.StreamResponse:
1745 """Serve audio via independent ffmpeg stream (fallback)."""
1746 player_id = player.player_id
1747 logger.debug(
1748 "[StreamMode:independent] Fallback stream for %s",
1749 player_id,
1750 )
1751
1752 audio_source = self.provider.mass.streams.get_stream(
1753 media,
1754 pcm_format,
1755 force_flow_mode=False,
1756 )
1757 output_plan = self.provider.mass.streams.audio.get_player_output_plan(
1758 player_id,
1759 pcm_format,
1760 out_format,
1761 queue_id=getattr(media, "source_id", None),
1762 session_id=get_media_session_id(media),
1763 queue_item_id=getattr(media, "queue_item_id", None),
1764 )
1765
1766 response = web.StreamResponse(status=200, headers=headers)
1767 stream_task: asyncio.Task[None] = asyncio.create_task(
1768 self._stream_with_prebuffer(
1769 request,
1770 response,
1771 player,
1772 headers,
1773 audio_source,
1774 pcm_format,
1775 out_format,
1776 output_plan.filter_params,
1777 )
1778 )
1779 transport = getattr(request, "transport", None)
1780 await self._run_stream_task(player_id, stream_task, transport)
1781
1782 return response
1783
1784 async def _stream_with_prebuffer(
1785 self,
1786 request: web.Request,
1787 response: web.StreamResponse,
1788 player: MSXPlayer,
1789 headers: dict[str, str],
1790 audio_source: Any,
1791 pcm_format: AudioFormat,
1792 out_format: AudioFormat,
1793 filter_params: Sequence[str | ComplexFilter],
1794 ) -> None:
1795 """Pre-buffer audio chunks, then send HTTP headers and stream remaining data."""
1796 player_id = player.player_id
1797 chunk_queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=32)
1798
1799 async def producer() -> None:
1800 try:
1801 async for chunk in get_ffmpeg_stream(
1802 audio_input=audio_source,
1803 input_format=pcm_format,
1804 output_format=out_format,
1805 filter_params=filter_params,
1806 ):
1807 await chunk_queue.put(chunk)
1808 finally:
1809 with contextlib.suppress(asyncio.QueueFull):
1810 chunk_queue.put_nowait(None)
1811
1812 producer_task: asyncio.Task[None] | None = None
1813 total_bytes = 0
1814 try:
1815 producer_task = asyncio.create_task(producer())
1816
1817 # Phase 1: Pre-buffer â collect chunks until we have enough data
1818 pre_buffer: list[bytes] = []
1819 pre_buffer_size = 0
1820 while pre_buffer_size < PRE_BUFFER_BYTES:
1821 chunk = await chunk_queue.get()
1822 if chunk is None:
1823 break
1824 pre_buffer.append(chunk)
1825 pre_buffer_size += len(chunk)
1826
1827 # Re-check: stop may have been called while buffering
1828 if not player.current_media and not pre_buffer:
1829 return
1830
1831 # NOW send HTTP headers + pre-buffer burst
1832 await response.prepare(request)
1833 for buf_chunk in pre_buffer:
1834 await response.write(buf_chunk)
1835 total_bytes += len(buf_chunk)
1836
1837 # If pre-buffer ended with sentinel, we're done
1838 if chunk is None:
1839 return
1840
1841 # Phase 2: Stream remaining chunks normally
1842 while True:
1843 chunk = await chunk_queue.get()
1844 if chunk is None:
1845 break
1846 await response.write(chunk)
1847 total_bytes += len(chunk)
1848 except ConnectionResetError, BrokenPipeError, ConnectionAbortedError:
1849 logger.debug("Client disconnected from stream %s", player_id)
1850 except asyncio.CancelledError:
1851 logger.debug("Stream cancelled for player %s", player_id)
1852 raise
1853 finally:
1854 if producer_task and not producer_task.done():
1855 producer_task.cancel()
1856 with contextlib.suppress(asyncio.CancelledError):
1857 await producer_task
1858 content_length = headers.get("Content-Length")
1859 if content_length:
1860 logger.debug(
1861 "Stream %s: wrote %d bytes, Content-Length=%s, diff=%d",
1862 player_id,
1863 total_bytes,
1864 content_length,
1865 total_bytes - int(content_length),
1866 )
1867 else:
1868 logger.debug("Stream %s finished: wrote %d bytes", player_id, total_bytes)
1869
1870 async def _run_stream_task(
1871 self,
1872 player_id: str,
1873 stream_task: asyncio.Task[None],
1874 transport: Any,
1875 ) -> None:
1876 """Run a stream task with registration and error handling."""
1877 self._register_stream(player_id, stream_task, transport)
1878 try:
1879 await stream_task
1880 except asyncio.CancelledError:
1881 raise
1882 except Exception:
1883 logger.exception("Stream error for player %s", player_id)
1884 finally:
1885 self._unregister_stream(player_id, stream_task, transport)
1886
1887 # --- WebSocket, Broadcast & Health ---
1888
1889 async def _handle_health(self, request: web.Request) -> web.Response:
1890 """Health check endpoint."""
1891 return web.json_response(
1892 {
1893 "status": "ok",
1894 "provider": "msx_bridge",
1895 "players": len(self.provider.players),
1896 }
1897 )
1898
1899 async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
1900 """
1901 WebSocket for push playback â clients subscribe by player_id.
1902
1903 Uses the same player_id derivation (device_id or IP) as content and
1904 stream endpoints so broadcast_stop reaches the correct client.
1905 Registers the player in MA on connect so the player appears when MSX starts.
1906 """
1907 ws = web.WebSocketResponse(heartbeat=30)
1908 await ws.prepare(request)
1909
1910 player_id, _, player = await self._ensure_player_for_request(request)
1911 if player_id not in self._ws_clients:
1912 self._ws_clients[player_id] = set()
1913 self._ws_clients[player_id].add(ws)
1914 logger.info(
1915 "WebSocket connected: player_id=%s, clients_for_player=%d, all_players=%s",
1916 player_id,
1917 len(self._ws_clients[player_id]),
1918 list(self._ws_clients.keys()),
1919 )
1920 if player and isinstance(player, MSXPlayer):
1921 player.on_ws_connected()
1922
1923 try:
1924 async for msg in ws:
1925 if msg.type == WSMsgType.TEXT:
1926 self._handle_ws_message(player_id, msg.data)
1927 finally:
1928 self._ws_clients.get(player_id, set()).discard(ws)
1929 if not self._ws_clients.get(player_id):
1930 self._ws_clients.pop(player_id, None)
1931 # Notify the player that its last WS client disconnected
1932 offline_player = self.provider.mass.players.get_player(player_id)
1933 if offline_player and isinstance(offline_player, MSXPlayer):
1934 offline_player.on_ws_disconnected()
1935 logger.debug("WebSocket client disconnected for player %s", player_id)
1936
1937 return ws
1938
1939 def _register_stream(self, player_id: str, task: asyncio.Task[None], transport: Any) -> None:
1940 """Register active stream task and transport for cancel on stop."""
1941 if player_id not in self._active_stream_tasks:
1942 self._active_stream_tasks[player_id] = set()
1943 self._active_stream_transports[player_id] = set()
1944 if task:
1945 self._active_stream_tasks[player_id].add(task)
1946 if transport:
1947 self._active_stream_transports[player_id].add(transport)
1948
1949 def _unregister_stream(self, player_id: str, task: asyncio.Task[None], transport: Any) -> None:
1950 """Unregister stream when done (from finally block)."""
1951 if player_id not in self._active_stream_tasks:
1952 return
1953 if task:
1954 self._active_stream_tasks[player_id].discard(task)
1955 if transport:
1956 self._active_stream_transports[player_id].discard(transport)
1957 if not self._active_stream_tasks[player_id]:
1958 del self._active_stream_tasks[player_id]
1959 del self._active_stream_transports[player_id]
1960
1961 async def _ws_send(
1962 self, ws: web.WebSocketResponse, text: str, player_id: str | None = None
1963 ) -> None:
1964 """Send text to WebSocket; on failure warn and remove the stale client."""
1965 try:
1966 await ws.send_str(text)
1967 except Exception as exc:
1968 logger.warning("WebSocket send failed (player=%s): %s", player_id, exc)
1969 if player_id:
1970 self._ws_clients.get(player_id, set()).discard(ws)
1971
1972 async def _cmd_pause_no_echo(self, player_id: str) -> None:
1973 """Pause player without echoing back to MSX."""
1974 player = self.provider.mass.players.get_player(player_id)
1975 if not (player and isinstance(player, MSXPlayer)):
1976 return
1977 player._skip_ws_notify = True
1978 try:
1979 await self.provider.mass.players.cmd_pause(player_id)
1980 finally:
1981 player._skip_ws_notify = False
1982
1983 async def _cmd_play_no_echo(self, player_id: str) -> None:
1984 """Resume player without echoing back to MSX."""
1985 player = self.provider.mass.players.get_player(player_id)
1986 if not (player and isinstance(player, MSXPlayer)):
1987 return
1988 player._skip_ws_notify = True
1989 try:
1990 await self.provider.mass.players.cmd_play(player_id)
1991 finally:
1992 player._skip_ws_notify = False
1993
1994 def _handle_ws_message(self, player_id: str, data: str) -> None:
1995 """Process an inbound WebSocket message from MSX."""
1996 try:
1997 msg = json.loads(data)
1998 except json.JSONDecodeError, TypeError:
1999 logger.debug("Invalid WS message from %s: %s", player_id, data)
2000 return
2001
2002 msg_type = msg.get("type")
2003 if msg_type == "position":
2004 position = msg.get("position")
2005 if position is not None and isinstance(position, (int, float)):
2006 player = self.provider.mass.players.get_player(player_id)
2007 if player and isinstance(player, MSXPlayer):
2008 player.update_position(float(position))
2009 self.provider.on_player_activity(player_id)
2010 elif msg_type == "pause":
2011 player = self.provider.mass.players.get_player(player_id)
2012 if player and isinstance(player, MSXPlayer):
2013 position = msg.get("position")
2014 if position is not None and isinstance(position, (int, float)):
2015 player.update_position(float(position))
2016 self.provider.mass.create_task(self._cmd_pause_no_echo(player_id))
2017 self.provider.on_player_activity(player_id)
2018 elif msg_type == "resume":
2019 player = self.provider.mass.players.get_player(player_id)
2020 if player and isinstance(player, MSXPlayer):
2021 self.provider.mass.create_task(self._cmd_play_no_echo(player_id))
2022 self.provider.on_player_activity(player_id)
2023 else:
2024 logger.debug("Unknown WS message type from %s: %s", player_id, msg_type)
2025
2026 # --- Stream Proxy ---
2027
2028 async def _handle_stream(self, request: web.Request) -> web.StreamResponse:
2029 """Stream audio from MA to the TV using internal API."""
2030 player_id = _strip_known_extension(request.match_info["player_id"])
2031
2032 self.provider.on_player_activity(player_id)
2033 player = self.provider.mass.players.get_player(player_id)
2034 if not player or not isinstance(player, MSXPlayer):
2035 return web.Response(status=404, text="Player not found")
2036
2037 media = player.current_media
2038 if not media:
2039 return web.Response(status=404, text="No active stream")
2040
2041 return await self._serve_audio_stream(
2042 request,
2043 player,
2044 media,
2045 duration=self._resolve_served_duration(media),
2046 )
2047
2048 # --- Library API Routes ---
2049
2050 async def _handle_albums(self, request: web.Request) -> web.Response:
2051 """List albums."""
2052 limit = _int_param(request.query, "limit", 50)
2053 offset = _int_param(request.query, "offset", 0)
2054 albums = await self.provider.mass.music.albums.library_items(
2055 limit=limit, offset=offset, summary=False
2056 )
2057 return web.json_response(
2058 {
2059 "items": [
2060 {
2061 "item_id": str(album.item_id),
2062 "name": album.name,
2063 "artist": getattr(album, "artist_str", ""),
2064 "image": get_image_url(album, self.provider),
2065 "uri": album.uri,
2066 }
2067 for album in albums
2068 ],
2069 "total": albums.total if hasattr(albums, "total") else len(albums),
2070 }
2071 )
2072
2073 async def _handle_album_tracks(self, request: web.Request) -> web.Response:
2074 """List tracks for an album."""
2075 item_id = request.match_info["item_id"]
2076 tracks = await self.provider.mass.music.albums.tracks(item_id, "library")
2077 return web.json_response(
2078 {
2079 "items": [self._format_track(track) for track in tracks],
2080 }
2081 )
2082
2083 async def _handle_artists(self, request: web.Request) -> web.Response:
2084 """List artists."""
2085 limit = _int_param(request.query, "limit", 50)
2086 offset = _int_param(request.query, "offset", 0)
2087 artists = await self.provider.mass.music.artists.library_items(
2088 limit=limit, offset=offset, summary=False
2089 )
2090 return web.json_response(
2091 {
2092 "items": [
2093 {
2094 "item_id": str(artist.item_id),
2095 "name": artist.name,
2096 "image": get_image_url(artist, self.provider),
2097 "uri": artist.uri,
2098 }
2099 for artist in artists
2100 ],
2101 "total": artists.total if hasattr(artists, "total") else len(artists),
2102 }
2103 )
2104
2105 async def _handle_artist_albums(self, request: web.Request) -> web.Response:
2106 """List albums for an artist."""
2107 item_id = request.match_info["item_id"]
2108 albums = await self.provider.mass.music.artists.albums(item_id, "library")
2109 return web.json_response(
2110 {
2111 "items": [
2112 {
2113 "item_id": str(album.item_id),
2114 "name": album.name,
2115 "artist": getattr(album, "artist_str", ""),
2116 "image": get_image_url(album, self.provider),
2117 "uri": album.uri,
2118 }
2119 for album in albums
2120 ],
2121 }
2122 )
2123
2124 async def _handle_playlists(self, request: web.Request) -> web.Response:
2125 """List playlists."""
2126 limit = _int_param(request.query, "limit", 50)
2127 offset = _int_param(request.query, "offset", 0)
2128 playlists = await self.provider.mass.music.playlists.library_items(
2129 limit=limit, offset=offset, summary=False
2130 )
2131 return web.json_response(
2132 {
2133 "items": [
2134 {
2135 "item_id": str(playlist.item_id),
2136 "name": playlist.name,
2137 "image": get_image_url(playlist, self.provider),
2138 "uri": playlist.uri,
2139 }
2140 for playlist in playlists
2141 ],
2142 "total": playlists.total if hasattr(playlists, "total") else len(playlists),
2143 }
2144 )
2145
2146 async def _handle_playlist_tracks(self, request: web.Request) -> web.Response:
2147 """List tracks for a playlist."""
2148 item_id = request.match_info["item_id"]
2149 tracks = [t async for t in self.provider.mass.music.playlists.tracks(item_id, "library")]
2150 return web.json_response(
2151 {
2152 "items": [self._format_track(track) for track in tracks],
2153 }
2154 )
2155
2156 async def _handle_tracks(self, request: web.Request) -> web.Response:
2157 """List tracks."""
2158 limit = _int_param(request.query, "limit", 50)
2159 offset = _int_param(request.query, "offset", 0)
2160 tracks = await self.provider.mass.music.tracks.library_items(
2161 limit=limit, offset=offset, summary=False
2162 )
2163 return web.json_response(
2164 {
2165 "items": [self._format_track(track) for track in tracks],
2166 "total": tracks.total if hasattr(tracks, "total") else len(tracks),
2167 }
2168 )
2169
2170 async def _handle_search(self, request: web.Request) -> web.Response:
2171 """Search the music library."""
2172 query = request.query.get("q", "")
2173 if not query:
2174 return web.json_response({"error": "Missing query parameter 'q'"}, status=400)
2175 limit = _int_param(request.query, "limit", 20)
2176 results = await self.provider.mass.music.search(query, limit=limit)
2177 return web.json_response(
2178 {
2179 "artists": [
2180 {
2181 "item_id": str(a.item_id),
2182 "name": a.name,
2183 "image": get_image_url(a, self.provider),
2184 "uri": a.uri,
2185 }
2186 for a in results.artists
2187 ],
2188 "albums": [
2189 {
2190 "item_id": str(a.item_id),
2191 "name": a.name,
2192 "artist": getattr(a, "artist_str", ""),
2193 "image": get_image_url(a, self.provider),
2194 "uri": a.uri,
2195 }
2196 for a in results.albums
2197 ],
2198 "tracks": [self._format_track(t) for t in results.tracks],
2199 "playlists": [
2200 {
2201 "item_id": str(p.item_id),
2202 "name": p.name,
2203 "image": get_image_url(p, self.provider),
2204 "uri": p.uri,
2205 }
2206 for p in results.playlists
2207 ],
2208 }
2209 )
2210
2211 async def _handle_recently_played(self, request: web.Request) -> web.Response:
2212 """Return recently played items."""
2213 limit = _int_param(request.query, "limit", 20)
2214 tracks = await self.provider.mass.music.tracks.library_items(
2215 limit=limit, order_by="last_played", summary=False
2216 )
2217 return web.json_response(
2218 {
2219 "items": [self._format_track(track) for track in tracks],
2220 }
2221 )
2222
2223 async def _handle_lyrics(self, request: web.Request) -> web.Response:
2224 """Return lyrics for the currently playing track on a given player."""
2225 player_id = request.match_info["player_id"]
2226 empty = web.json_response({"lyrics": None, "lrc_lyrics": None})
2227
2228 player = self.provider.mass.players.get_player(player_id)
2229 if not player or not isinstance(player, MSXPlayer):
2230 return empty
2231
2232 media = player.current_media
2233 if not media or not media.source_id or not media.queue_item_id:
2234 return empty
2235
2236 queue_item = self.provider.mass.player_queues.get_item(media.source_id, media.queue_item_id)
2237 if not queue_item or not queue_item.media_item:
2238 return empty
2239
2240 track = queue_item.media_item
2241 if not isinstance(track, Track):
2242 return empty
2243 try:
2244 lyrics, lrc_lyrics = await self.provider.mass.metadata.get_track_lyrics(track)
2245 except Exception:
2246 lyrics, lrc_lyrics = None, None
2247
2248 return web.json_response(
2249 {
2250 "title": getattr(track, "name", ""),
2251 "artist": getattr(track, "artist_str", ""),
2252 "lyrics": lyrics,
2253 "lrc_lyrics": lrc_lyrics,
2254 }
2255 )
2256
2257 async def _handle_queue(self, request: web.Request) -> web.Response:
2258 """Return the current playback queue for a given player."""
2259 player_id = request.match_info["player_id"]
2260
2261 player = self.provider.mass.players.get_player(player_id)
2262 if not player or not isinstance(player, MSXPlayer):
2263 return web.json_response({"items": [], "current_index": -1})
2264
2265 queue_id = player_id
2266 try:
2267 queue_items = self.provider.mass.player_queues.items(queue_id)
2268 except Exception:
2269 logger.debug("Failed to fetch queue items for player %s", player_id, exc_info=True)
2270 queue_items = []
2271
2272 current_uri = None
2273 media = player.current_media
2274 if media and media.source_id and media.queue_item_id:
2275 qi = self.provider.mass.player_queues.get_item(media.source_id, media.queue_item_id)
2276 if qi and qi.media_item:
2277 current_uri = getattr(qi.media_item, "uri", None)
2278
2279 items: list[dict[str, Any]] = []
2280 current_index = -1
2281 for i, qi in enumerate(queue_items):
2282 mi = getattr(qi, "media_item", None)
2283 uri = getattr(mi, "uri", None) or ""
2284 img = None
2285 if hasattr(qi, "image") and qi.image:
2286 img = self.provider.mass.metadata.get_image_url(qi.image)
2287 items.append(
2288 {
2289 "title": getattr(mi, "name", None) or getattr(qi, "name", "") or "",
2290 "artist": getattr(mi, "artist_str", "") if mi else "",
2291 "duration": getattr(mi, "duration", None) or getattr(qi, "duration", 0) or 0,
2292 "image": img,
2293 "uri": uri,
2294 }
2295 )
2296 if current_uri and uri == current_uri and current_index < 0:
2297 current_index = i
2298
2299 return web.json_response({"items": items, "current_index": current_index})
2300
2301 # --- Party Mode ---
2302
2303 def _cached_party(self) -> PartyInfo | None:
2304 """Return the last cached party state without refreshing (sync contexts)."""
2305 return self._party_cache[1] if self._party_cache else None
2306
2307 async def _qr_cover_base(self, prefix: str) -> str | None:
2308 """Return the QR-cover endpoint base when a party is active, else None."""
2309 if await self._get_active_party() is None:
2310 return None
2311 return f"{prefix}/api/party/qr-cover.png"
2312
2313 async def _get_active_party(self) -> PartyInfo | None:
2314 """
2315 Return details of the active party, or None when no party is active.
2316
2317 Never raises: a broken or slow Party plugin degrades to "no party" so
2318 the core UI (menu, kiosk) keeps working. Results are cached briefly.
2319 """
2320 now = time.monotonic()
2321 if self._party_cache is not None and now - self._party_cache[0] < PARTY_CACHE_TTL:
2322 return self._party_cache[1]
2323 info: PartyInfo | None = None
2324 try:
2325 party = cast("Any", self.provider.mass.get_provider("party"))
2326 if party is not None:
2327 join_url = await asyncio.wait_for(party.get_party_url(), PARTY_CALL_TIMEOUT)
2328 if join_url:
2329 config = await asyncio.wait_for(party.get_party_config(), PARTY_CALL_TIMEOUT)
2330 info = PartyInfo(
2331 join_url=join_url,
2332 name=getattr(config, "party_name", None),
2333 qr_text=getattr(config, "qr_text", None),
2334 qr_version=hashlib.sha256(join_url.encode()).hexdigest()[:12],
2335 )
2336 except Exception:
2337 logger.warning("Party plugin status check failed", exc_info=True)
2338 self._party_cache = (now, info)
2339 return info
2340
2341 async def _handle_party_status(self, _request: web.Request) -> web.Response:
2342 """Return party status for the kiosk overlay."""
2343 party = await self._get_active_party()
2344 if party is None:
2345 return web.json_response({"active": False})
2346 # the join URL itself is deliberately not exposed â clients only get the QR
2347 # image URL (relative, so it works behind reverse proxies) plus an opaque
2348 # version so they refetch the image only when the join code rotates
2349 return web.json_response(
2350 {
2351 "active": True,
2352 "name": party.name,
2353 "qr_text": party.qr_text,
2354 "qr_url": "/api/party/qr.svg",
2355 "qr_version": party.qr_version,
2356 }
2357 )
2358
2359 async def _handle_party_qr(self, request: web.Request) -> web.Response:
2360 """Serve the guest join URL as a QR code image (SVG or PNG by route)."""
2361 party = await self._get_active_party()
2362 if party is None:
2363 return web.Response(status=404, text="No active party")
2364 kind = "png" if request.path.endswith(".png") else "svg"
2365 body = await asyncio.to_thread(_render_qr, party.join_url, kind)
2366 return web.Response(
2367 body=body,
2368 content_type="image/png" if kind == "png" else "image/svg+xml",
2369 headers={"Cache-Control": "no-store"},
2370 )
2371
2372 async def _handle_party_qr_cover(self, request: web.Request) -> web.Response:
2373 """
2374 Serve a cover image with the party QR stamped into its corner (PNG).
2375
2376 MSX cannot render overlays, so during a party the playback background
2377 is routed through this endpoint. Degrades to a redirect to the
2378 original image when the party ended, the source is not ours, or the
2379 fetch/composite fails â stale playlist JSON on TVs keeps working.
2380 """
2381 image_url = request.query.get("image", "")
2382 if not image_url:
2383 return web.Response(status=400, text="Missing image parameter")
2384 # Reject non-MA sources outright â redirecting would be an open
2385 # redirect and fetching would be an SSRF proxy.
2386 if not self._is_allowed_cover_source(request, image_url):
2387 return web.Response(status=400, text="Image source not permitted")
2388 party = await self._get_active_party()
2389 if party is None:
2390 raise web.HTTPFound(location=image_url)
2391 cache_key = (image_url, party.qr_version)
2392 if (cached := self._qr_cover_cache.get(cache_key)) is None:
2393 try:
2394 # join: a TV dropping its request must not cancel the shared
2395 # render â late joiners and the cache still get the result
2396 cached = await join_task(self._qr_cover_task(cache_key, image_url, party.join_url))
2397 except Exception as err:
2398 logger.debug("QR cover composite failed for %s: %s", image_url, err)
2399 raise web.HTTPFound(location=image_url) from None
2400 return web.Response(
2401 body=cached,
2402 content_type="image/png",
2403 headers={"Cache-Control": "no-store"},
2404 )
2405
2406 def _qr_cover_task(
2407 self, cache_key: tuple[str, str], image_url: str, join_url: str
2408 ) -> asyncio.Task[bytes]:
2409 """Return the in-flight render task for this cover, starting one if needed."""
2410 if (task := self._qr_cover_inflight.get(cache_key)) is None:
2411 task = asyncio.create_task(self._fetch_and_render_cover(cache_key, image_url, join_url))
2412 self._qr_cover_inflight[cache_key] = task
2413
2414 def _cleanup(finished: asyncio.Task[bytes]) -> None:
2415 self._qr_cover_inflight.pop(cache_key, None)
2416 # consume the exception so a task whose waiters were all
2417 # cancelled never logs "exception was never retrieved"
2418 if not finished.cancelled():
2419 finished.exception()
2420
2421 task.add_done_callback(_cleanup)
2422 return task
2423
2424 async def _fetch_and_render_cover(
2425 self, cache_key: tuple[str, str], image_url: str, join_url: str
2426 ) -> bytes:
2427 """Fetch the cover, composite the QR onto it, and cache the PNG."""
2428 async with self.provider.mass.http_session.get(
2429 image_url,
2430 timeout=aiohttp.ClientTimeout(total=10),
2431 allow_redirects=False,
2432 ) as resp:
2433 if resp.status != 200:
2434 raise ValueError(f"cover fetch returned HTTP {resp.status}")
2435 cover_bytes = await resp.read()
2436 # PIL decode/re-encode blocks; on this loop it would stall audio
2437 # streaming for every player, so hop to a worker thread
2438 rendered = await asyncio.to_thread(_render_qr_cover, join_url, cover_bytes)
2439 # QR rotation changes the cache key; keep the cache tiny and bounded
2440 if len(self._qr_cover_cache) >= 32:
2441 self._qr_cover_cache.clear()
2442 self._qr_cover_cache[cache_key] = rendered
2443 return rendered
2444
2445 @staticmethod
2446 def _rewrite_stream_host(request: web.Request, url: str) -> str:
2447 """
2448 Point a stream URL at the host the client already uses to reach us.
2449
2450 The MA streamserver advertises its own IP, which is unreachable for
2451 the TV when MA runs behind Docker/NAT. The host the TV used for this
2452 request is known-good, so only the URL's host is replaced â scheme,
2453 port, path and query are preserved.
2454 """
2455 client_host = request.url.host
2456 if not client_host:
2457 return url
2458 parts = urlsplit(url)
2459 if ":" in client_host: # IPv6 literals need brackets in a netloc
2460 client_host = f"[{client_host}]"
2461 netloc = f"{client_host}:{parts.port}" if parts.port else client_host
2462 return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
2463
2464 @staticmethod
2465 def _url_origin(url: str) -> tuple[str, str | None, int | None]:
2466 """Return (scheme, hostname, port); raises ValueError on malformed URLs."""
2467 parts = urlsplit(url)
2468 # .port is lazy and raises on garbage like "host:8095.evil.example"
2469 return (parts.scheme, parts.hostname, parts.port)
2470
2471 def _is_allowed_cover_source(self, request: web.Request, image_url: str) -> bool:
2472 """Only composite covers served by this provider or MA itself (no open proxy)."""
2473 try:
2474 target_origin = self._url_origin(image_url)
2475 except ValueError:
2476 return False
2477 if target_origin[0] not in ("http", "https") or not target_origin[1]:
2478 return False
2479 allowed_bases = [self._get_prefix(request)]
2480 for source in (
2481 getattr(self.provider.mass, "webserver", None),
2482 getattr(self.provider.mass, "streams", None),
2483 ):
2484 base_url = getattr(source, "base_url", None)
2485 if isinstance(base_url, str) and base_url.startswith("http"):
2486 allowed_bases.append(base_url)
2487 # Compare parsed origins, not string prefixes: "http://ma:8095.evil.com"
2488 # must not pass for the allowed base "http://ma:8095".
2489 for base in allowed_bases:
2490 try:
2491 if target_origin == self._url_origin(base):
2492 return True
2493 except ValueError:
2494 continue
2495 return False
2496
2497 # --- Playback Control ---
2498
2499 @staticmethod
2500 def _reject_cross_site(request: web.Request) -> web.Response | None:
2501 """
2502 Reject browser cross-site requests to state-changing endpoints (CSRF guard).
2503
2504 Any web page can fire an unauthenticated GET at this LAN server via an
2505 img/script tag; modern browsers mark such requests with
2506 Sec-Fetch-Site: cross-site. Legitimate callers are same-origin (web
2507 player, MSX interaction plugin, dashboard) or non-browser clients that
2508 omit the header entirely â both pass.
2509 """
2510 if request.headers.get("Sec-Fetch-Site", "").lower() == "cross-site":
2511 return web.json_response({"error": "Cross-site request rejected"}, status=403)
2512 return None
2513
2514 async def _handle_play(self, request: web.Request) -> web.Response:
2515 """Start playback of a track."""
2516 if rejected := self._reject_cross_site(request):
2517 return rejected
2518 try:
2519 body = await request.json()
2520 except Exception:
2521 return web.json_response({"error": "Invalid JSON body"}, status=400)
2522
2523 track_uri = body.get("track_uri")
2524 player_id = body.get("player_id")
2525 if not track_uri or not player_id:
2526 return web.json_response({"error": "Missing track_uri or player_id"}, status=400)
2527
2528 if self._get_msx_player(player_id) is None:
2529 return web.json_response({"error": "Unknown MSX player"}, status=404)
2530
2531 async with ImpersonatedUser(self.provider.mass, await self.provider.get_owner_username()):
2532 await self.provider.mass.player_queues.play_media(player_id, track_uri)
2533 return web.json_response({"status": "ok"})
2534
2535 async def _handle_pause(self, request: web.Request) -> web.Response:
2536 """Pause playback."""
2537 if rejected := self._reject_cross_site(request):
2538 return rejected
2539 player_id = _strip_known_extension(request.match_info["player_id"])
2540 if self._get_msx_player(player_id) is None:
2541 return web.json_response({"error": "Unknown MSX player"}, status=404)
2542 self.provider.on_player_activity(player_id)
2543 await self.provider.mass.players.cmd_pause(player_id)
2544 return web.json_response({"status": "ok"})
2545
2546 async def _handle_stop(self, request: web.Request) -> web.Response:
2547 """Stop playback."""
2548 if rejected := self._reject_cross_site(request):
2549 return rejected
2550 player_id = _strip_known_extension(request.match_info["player_id"])
2551 if self._get_msx_player(player_id) is None:
2552 return web.json_response({"error": "Unknown MSX player"}, status=404)
2553 self.provider.on_player_activity(player_id)
2554 await self.provider.mass.players.cmd_stop(player_id)
2555 return web.json_response({"status": "ok"})
2556
2557 async def _handle_quick_stop(self, request: web.Request) -> web.Response:
2558 """Stop playback on MSX immediately (same signal as Disable)."""
2559 if rejected := self._reject_cross_site(request):
2560 return rejected
2561 player_id = _strip_known_extension(request.match_info["player_id"])
2562 if self._get_msx_player(player_id) is None:
2563 return web.json_response({"error": "Unknown MSX player"}, status=404)
2564 self.provider.on_player_activity(player_id)
2565 await self.provider.mass.players.cmd_stop(player_id)
2566 self.provider.notify_play_stopped(player_id)
2567 accept = request.headers.get("Accept", "")
2568 if "text/html" in accept:
2569 return web.Response(status=303, headers={"Location": "/"})
2570 return web.json_response({"status": "ok"})
2571
2572 async def _handle_next(self, request: web.Request) -> web.Response:
2573 """Skip to next track."""
2574 if rejected := self._reject_cross_site(request):
2575 return rejected
2576 player_id = _strip_known_extension(request.match_info["player_id"])
2577 if self._get_msx_player(player_id) is None:
2578 return web.json_response({"error": "Unknown MSX player"}, status=404)
2579 self.provider.on_player_activity(player_id)
2580 await self.provider.mass.players.cmd_next_track(player_id)
2581 return web.json_response({"status": "ok"})
2582
2583 async def _handle_previous(self, request: web.Request) -> web.Response:
2584 """Skip to previous track."""
2585 if rejected := self._reject_cross_site(request):
2586 return rejected
2587 player_id = _strip_known_extension(request.match_info["player_id"])
2588 if self._get_msx_player(player_id) is None:
2589 return web.json_response({"error": "Unknown MSX player"}, status=404)
2590 self.provider.on_player_activity(player_id)
2591 await self.provider.mass.players.cmd_previous_track(player_id)
2592 return web.json_response({"status": "ok"})
2593
2594 # --- Helpers ---
2595
2596 def _get_msx_player(self, player_id: str) -> MSXPlayer | None:
2597 """Return the MSXPlayer for player_id if it belongs to this provider, else None."""
2598 player = self.provider.mass.players.get_player(player_id, raise_unavailable=False)
2599 if isinstance(player, MSXPlayer) and player.provider == self.provider:
2600 return player
2601 return None
2602
2603 def _get_prefix(self, request: web.Request) -> str:
2604 """
2605 Build URL prefix for JSON content, using our known port.
2606
2607 Uses aiohttp's parsed URL host (IPv6-safe, no port) and substitutes
2608 self.port. Note: host is still derived from the Host header; a crafted
2609 header can influence the returned host, but the server binds to 0.0.0.0
2610 so there is no single canonical IP to validate against.
2611 """
2612 host: str = request.url.host or request.host.split(":")[0] # IPv6-safe, no port
2613 host_addr = f"[{host}]" if ":" in host else host # bracket IPv6 literals for URLs
2614 return f"http://{host_addr}:{self.port}"
2615
2616 def _get_player_id_and_device_param(self, request: web.Request) -> tuple[str, str]:
2617 """
2618 Extract player_id and device_id query param from request.
2619
2620 Returns (player_id, device_param) where device_param is e.g. "device_id=xxx"
2621 or "" if using IP fallback.
2622 """
2623 device_id = request.query.get("device_id")
2624 remote_ip = request.remote or "unknown"
2625
2626 if device_id:
2627 device_id = device_id[:64] # clamp before sanitizing (UUIDs are 36 chars)
2628 sanitized = PLAYER_ID_SANITIZE_RE.sub("_", device_id).strip("_") or "device"
2629 player_id = f"{MSX_PLAYER_ID_PREFIX}{sanitized}"
2630 param = f"device_id={quote(device_id, safe='')}"
2631 logger.info(
2632 "[PlayerID] device_id=%s, remote_ip=%s -> player_id=%s",
2633 device_id,
2634 remote_ip,
2635 player_id,
2636 )
2637 else:
2638 ip = remote_ip if remote_ip != "unknown" else "0_0_0_0"
2639 sanitized = PLAYER_ID_SANITIZE_RE.sub("_", ip.replace(".", "_")).strip("_") or "ip"
2640 player_id = f"{MSX_PLAYER_ID_PREFIX}{sanitized}"
2641 param = ""
2642 logger.info(
2643 "[PlayerID] no device_id, remote_ip=%s -> player_id=%s",
2644 remote_ip,
2645 player_id,
2646 )
2647 return player_id, param
2648
2649 async def _ensure_player_for_request(
2650 self, request: web.Request
2651 ) -> tuple[str, str, MSXPlayer | None]:
2652 """
2653 Get or register player for this request.
2654
2655 Returns (player_id, device_param, player).
2656 Player may be None if registration failed.
2657 """
2658 player_id, device_param = self._get_player_id_and_device_param(request)
2659 # Remember how this client reaches us â WS pushes have no request context
2660 self._client_prefixes[player_id] = self._get_prefix(request)
2661 remote_ip = request.remote
2662 # Web player clients pass source=web to distinguish from MSX TV players
2663 prefix_label = "WEB TV" if request.query.get("source") == "web" else "MSX TV"
2664 display_name = self.provider._player_display_name_from_id(
2665 player_id, prefix_label=prefix_label, remote_ip=remote_ip
2666 )
2667 player = await self.provider.get_or_register_player(
2668 player_id, display_name=display_name, ip_address=remote_ip
2669 )
2670 return player_id, device_param, player
2671
2672 def _current_media_matches_uri(self, player: MSXPlayer, track_uri: str) -> bool:
2673 """Check if player's current_media corresponds to the requested track URI."""
2674 media = player.current_media
2675 if not media or not media.source_id or not media.queue_item_id:
2676 return False
2677 queue_item = self.provider.mass.player_queues.get_item(media.source_id, media.queue_item_id)
2678 if queue_item and queue_item.media_item:
2679 return getattr(queue_item.media_item, "uri", None) == track_uri
2680 return False
2681
2682 def _format_track(self, track: Any) -> dict[str, Any]:
2683 """Format a track object for the API response."""
2684 return {
2685 "item_id": str(track.item_id),
2686 "name": track.name,
2687 "artist": getattr(track, "artist_str", ""),
2688 "album": getattr(getattr(track, "album", None), "name", ""),
2689 "duration": getattr(track, "duration", 0),
2690 "image": self.provider.mass.metadata.get_image_url(track.image)
2691 if hasattr(track, "image") and track.image
2692 else None,
2693 "uri": track.uri,
2694 }
2695