Milestone 3: Navidrome (OpenSubsonic) playback provider
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>
This commit is contained in:
parent
f8eb0655eb
commit
8c27498632
17 changed files with 594 additions and 71 deletions
115
ingest/radieo/subsonic.py
Normal file
115
ingest/radieo/subsonic.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Minimal OpenSubsonic client (enough for Navidrome playback).
|
||||
|
||||
Uses salted-token authentication (``t = md5(password + salt)``), the scheme
|
||||
recommended by the Subsonic API since 1.13.0 and supported by Navidrome.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger("radieo.subsonic")
|
||||
|
||||
# Advertised API version and client name.
|
||||
_API_VERSION = "1.16.1"
|
||||
_CLIENT = "radieo"
|
||||
|
||||
# Content-Type -> file extension, used to name downloaded files.
|
||||
_CTYPE_EXT = {
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/flac": ".flac",
|
||||
"audio/x-flac": ".flac",
|
||||
"audio/ogg": ".ogg",
|
||||
"application/ogg": ".ogg",
|
||||
"audio/opus": ".opus",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/aac": ".aac",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
}
|
||||
|
||||
|
||||
class SubsonicError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SubsonicClient:
|
||||
def __init__(self, base_url: str, user: str, password: str):
|
||||
self._base = base_url.rstrip("/")
|
||||
self._user = user
|
||||
self._password = password
|
||||
self._http = httpx.Client(timeout=30.0, follow_redirects=True)
|
||||
|
||||
def _auth_params(self) -> dict[str, str]:
|
||||
salt = secrets.token_hex(8)
|
||||
token = hashlib.md5((self._password + salt).encode()).hexdigest()
|
||||
return {
|
||||
"u": self._user,
|
||||
"t": token,
|
||||
"s": salt,
|
||||
"v": _API_VERSION,
|
||||
"c": _CLIENT,
|
||||
"f": "json",
|
||||
}
|
||||
|
||||
def _get_json(self, view: str, **params) -> dict:
|
||||
url = f"{self._base}/rest/{view}"
|
||||
resp = self._http.get(url, params={**self._auth_params(), **params})
|
||||
resp.raise_for_status()
|
||||
body = resp.json()["subsonic-response"]
|
||||
if body.get("status") != "ok":
|
||||
err = body.get("error", {})
|
||||
raise SubsonicError(
|
||||
f"{view}: {err.get('code')} {err.get('message')}"
|
||||
)
|
||||
return body
|
||||
|
||||
def ping(self) -> None:
|
||||
self._get_json("ping")
|
||||
|
||||
def resolve_playlist_id(self, name_or_id: str) -> str:
|
||||
"""Accept either a playlist id or a playlist name."""
|
||||
body = self._get_json("getPlaylists")
|
||||
playlists = body.get("playlists", {}).get("playlist", [])
|
||||
for pl in playlists:
|
||||
if pl.get("id") == name_or_id or pl.get("name") == name_or_id:
|
||||
return pl["id"]
|
||||
raise SubsonicError(f"playlist not found: {name_or_id!r}")
|
||||
|
||||
def get_playlist_songs(self, playlist_id: str) -> list[dict]:
|
||||
body = self._get_json("getPlaylist", id=playlist_id)
|
||||
return body.get("playlist", {}).get("entry", [])
|
||||
|
||||
def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str:
|
||||
"""Download a song to ``dest``; return the file extension used.
|
||||
|
||||
``format=raw`` asks Navidrome for the original file (no transcoding),
|
||||
keeping quality and letting Liquidsoap decode it.
|
||||
"""
|
||||
params = {**self._auth_params(), "id": song_id, "format": "raw"}
|
||||
with self._http.stream(
|
||||
"GET", f"{self._base}/rest/stream", params=params
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
ctype = resp.headers.get("content-type", "").split(";")[0].strip()
|
||||
if ctype.startswith(("application/json", "text/xml")):
|
||||
raise SubsonicError(
|
||||
f"stream {song_id}: error response {resp.read()[:200]!r}"
|
||||
)
|
||||
ext = _CTYPE_EXT.get(ctype)
|
||||
if ext is None and hint_ext:
|
||||
ext = "." + hint_ext.lstrip(".")
|
||||
if ext is None:
|
||||
ext = ".mp3"
|
||||
with open(dest, "wb") as fh:
|
||||
for chunk in resp.iter_bytes(chunk_size=65536):
|
||||
fh.write(chunk)
|
||||
return ext
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
Loading…
Add table
Add a link
Reference in a new issue