Replace the directory-scan queue with a real ingestion pipeline: provider -> fetcher -> cache -> ready queue, driven by a background prefetch thread. - subsonic.py: minimal OpenSubsonic client (salted-token auth, getPlaylists/getPlaylist, raw streaming download). - providers/navidrome.py: pick tracks from a playlist (by name or id), with anti-repeat and periodic playlist reload. - fetchers/subsonic.py: atomic download into the shared cache. - db.py: SQLite state — append-only play history (anti-repeat + stats) and cache_files LRU retention (keep the N most recently played). - queue.py: prefetch buffer + retention on play; graceful degradation to the stream's local-cache fallback when no source is configured. - api.py: GET /next now carries real title/artist metadata. - Config via .env (Navidrome credentials), persistent state/ volume, httpx dependency. Verified end-to-end against a live Navidrome: playlist resolved, tracks downloaded and broadcast, retention and history correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
67 lines
2 KiB
Python
67 lines
2 KiB
Python
"""HTTP API exposing the next track to the stream layer.
|
|
|
|
Endpoints:
|
|
GET /next -> annotated Liquidsoap URI, or an empty body when nothing is
|
|
ready (Liquidsoap then falls back to the local cache).
|
|
GET /healthz -> "ok"
|
|
"""
|
|
|
|
import logging
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
from .models import Track
|
|
from .queue import TrackQueue
|
|
|
|
log = logging.getLogger("radieo.api")
|
|
|
|
|
|
def annotate_uri(path: Path, track: Track) -> str:
|
|
"""Build an annotated Liquidsoap request URI for a cache file."""
|
|
|
|
def esc(value: str) -> str:
|
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
return (
|
|
f'annotate:title="{esc(track.title)}",artist="{esc(track.artist)}"'
|
|
f":{path}"
|
|
)
|
|
|
|
|
|
class IngestServer(ThreadingHTTPServer):
|
|
def __init__(self, address, queue: TrackQueue):
|
|
super().__init__(address, _Handler)
|
|
self.queue = queue
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
server: IngestServer
|
|
|
|
def do_GET(self): # noqa: N802 (name imposed by BaseHTTPRequestHandler)
|
|
if self.path == "/next":
|
|
self._serve_next()
|
|
elif self.path == "/healthz":
|
|
self._text(200, "ok\n")
|
|
else:
|
|
self._text(404, "not found\n")
|
|
|
|
def _serve_next(self):
|
|
result = self.server.queue.pop_next()
|
|
if result is None:
|
|
# Empty body: tells Liquidsoap to use its fallback for now.
|
|
self._text(200, "")
|
|
return
|
|
path, track = result
|
|
log.info("next -> %s", track)
|
|
self._text(200, annotate_uri(path, track) + "\n")
|
|
|
|
def _text(self, code: int, body: str):
|
|
data = body.encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def log_message(self, fmt, *args):
|
|
log.debug("%s - %s", self.address_string(), fmt % args)
|