radieo/ingest/radieo/subsonic.py
Pierre-Olivier Mercier 9cc2ede37d ingest: rename the Navidrome source to OpenSubsonic
The provider only uses standard OpenSubsonic endpoints (getPlaylists,
getPlaylist, search3, stream), so it works with any compatible server
(Navidrome, Gonic, Airsonic…), not just Navidrome.

BREAKING: environment variables are renamed
  RADIEO_NAVIDROME_URL/USER/PASSWORD/PLAYLIST -> RADIEO_SUBSONIC_*
  RADIEO_WEIGHT_NAVIDROME                     -> RADIEO_WEIGHT_SUBSONIC
Update your .env accordingly.

Internally the source key and provider are renamed navidrome -> subsonic,
aligning with the existing 'subsonic' backend and fetcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:59:22 +08:00

133 lines
4.3 KiB
Python

"""Minimal OpenSubsonic client (enough for playlist playback).
Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…).
Uses salted-token authentication (``t = md5(password + salt)``), the scheme
recommended by the Subsonic API since 1.13.0.
"""
import hashlib
import logging
import secrets
from pathlib import Path
import httpx
from . import config
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,
transport=httpx.HTTPTransport(retries=config.HTTP_RETRIES),
)
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 search_songs(self, query: str, count: int = 25) -> list[dict]:
"""Full-text song search (``search3``). Songs only, no artists/albums."""
body = self._get_json(
"search3",
query=query,
songCount=count,
artistCount=0,
albumCount=0,
)
return body.get("searchResult3", {}).get("song", [])
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 the server 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()