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>
68 lines
2.2 KiB
Python
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"),
|
|
)
|