radieo/ingest/radieo/providers/navidrome.py
Pierre-Olivier Mercier 8c27498632 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>
2026-07-02 17:57:38 +08:00

68 lines
2.2 KiB
Python

"""NavidromeProvider: picks tracks from an OpenSubsonic playlist.
Emits ``subsonic`` tracks (locator = song id). The playlist is cached in
memory and refreshed periodically. Anti-repeat is applied by filtering out
tracks whose key is among the recently played ones; if that empties the pool
(short playlist), the filter is dropped so playback never stalls.
"""
import logging
import random
import time
import httpx
from .. import config
from ..db import Database
from ..models import Track
from ..subsonic import SubsonicClient, SubsonicError
log = logging.getLogger("radieo.provider.navidrome")
class NavidromeProvider:
name = "navidrome"
def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database):
self._client = client
self._playlist_ref = playlist_ref
self._db = db
self._playlist_id: str | None = None
self._songs: list[dict] = []
self._loaded_at = 0.0
def _ensure_songs(self) -> None:
now = time.time()
if self._songs and now - self._loaded_at < config.PLAYLIST_REFRESH:
return
if self._playlist_id is None:
self._playlist_id = self._client.resolve_playlist_id(
self._playlist_ref
)
songs = self._client.get_playlist_songs(self._playlist_id)
self._songs = songs
self._loaded_at = now
log.info("loaded %d songs from playlist %r", len(songs), self._playlist_ref)
def next(self) -> Track | None:
try:
self._ensure_songs()
except (SubsonicError, httpx.HTTPError, OSError) as exc:
log.warning("could not load playlist: %s", exc)
return None
if not self._songs:
return None
recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW)
candidates = [
s for s in self._songs if f"subsonic:{s['id']}" not in recent
] or self._songs
song = random.choice(candidates)
return Track(
backend="subsonic",
locator=str(song["id"]),
artist=song.get("artist", "Unknown artist"),
title=song.get("title", str(song["id"])),
origin=self.name,
source_ext=song.get("suffix"),
)