ingest: support multiple weighted OpenSubsonic playlists

RADIEO_SUBSONIC_PLAYLIST now accepts a comma-separated list of playlist
names or ids, each optionally weighted with a '=<number>' suffix (e.g.
'Chill=3, Focus, Party=2'); default weight is 1 and 0 disables an entry.
The provider caches each playlist independently and walks them in
weighted-random order on every pick, falling through past empty, renamed
or unreachable playlists so the source never stalls. Picking the
playlist first (by weight) then a song uniformly gives each its
configured share regardless of length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-04 16:30:21 +08:00
commit 40061446c9
5 changed files with 117 additions and 48 deletions

View file

@ -7,7 +7,10 @@
RADIEO_SUBSONIC_URL=https://subsonic.example.org RADIEO_SUBSONIC_URL=https://subsonic.example.org
RADIEO_SUBSONIC_USER=monuser RADIEO_SUBSONIC_USER=monuser
RADIEO_SUBSONIC_PASSWORD=monmotdepasse RADIEO_SUBSONIC_PASSWORD=monmotdepasse
# Nom OU identifiant de la playlist à diffuser. # Playlist(s) à diffuser : nom OU identifiant, séparés par des virgules.
# Chaque playlist peut être pondérée avec un suffixe '=<nombre>' (défaut : 1)
# pour ajuster sa fréquence de tirage ; un poids de 0 la désactive.
# RADIEO_SUBSONIC_PLAYLIST=Chill=3, Focus, Party=2
RADIEO_SUBSONIC_PLAYLIST=Radio RADIEO_SUBSONIC_PLAYLIST=Radio
# --- Source yt-dlp --- # --- Source yt-dlp ---

View file

@ -68,7 +68,9 @@ cp .env.example .env
Fill in `.env`: Fill in `.env`:
- **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the - **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the
playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with playlist(s) to broadcast in `RADIEO_SUBSONIC_PLAYLIST` — a comma-separated
list of names or ids, each optionally weighted with a `=<number>` suffix
(e.g. `Chill=3, Focus, Party=2`; default weight 1, `0` disables). Works with
any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty
to disable this source. To make the player's "source" link work for library to disable this source. To make the player's "source" link work for library
tracks, enable sharing on the server (Navidrome: `ND_ENABLESHARING=true`); the tracks, enable sharing on the server (Navidrome: `ND_ENABLESHARING=true`); the

View file

@ -43,12 +43,12 @@ def _build_pipeline(db: Database, canonicalizer):
subsonic_client = SubsonicClient( subsonic_client = SubsonicClient(
config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD
) )
provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db) provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLISTS, db)
providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0))) providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0)))
fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR) fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR)
log.info( log.info(
"OpenSubsonic source enabled (playlist=%r, weight=%d)", "OpenSubsonic source enabled (playlists=%r, weight=%d)",
config.SUBSONIC_PLAYLIST, config.SUBSONIC_PLAYLISTS,
config.SOURCE_WEIGHTS.get("subsonic", 0), config.SOURCE_WEIGHTS.get("subsonic", 0),
) )
else: else:

View file

@ -55,12 +55,46 @@ USER_AGENT = os.environ.get(
SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip() SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip()
SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip() SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip()
SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "") SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "")
SUBSONIC_PLAYLIST = os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "").strip()
def _parse_playlists(raw: str) -> list[tuple[str, float]]:
"""Parse a comma-separated 'ref[=weight]' playlist list.
Each item is a playlist name or id, with an optional relative weight after
'=' (e.g. 'Chill Vibes=3'). Refs are freeform they may contain digits or
colons so the weight is a trailing '=<number>' rather than a prefix. An
item whose '=' tail is not numeric is taken as part of the name (no weight,
default 1). A non-positive weight disables the playlist.
"""
out: list[tuple[str, float]] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
ref, sep, tail = item.rpartition("=")
if sep:
try:
weight = float(tail.strip())
except ValueError:
ref, weight = item, 1.0 # '=' belongs to the name, not a weight
else:
ref = ref.strip()
else:
ref, weight = item, 1.0
if ref and weight > 0:
out.append((ref, weight))
return out
# Comma-separated list of playlists, each optionally weighted with '=<number>'.
SUBSONIC_PLAYLISTS = _parse_playlists(
os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "")
)
# How often to reload the playlist contents, in seconds. # How often to reload the playlist contents, in seconds.
PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300")) PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300"))
SUBSONIC_ENABLED = bool( SUBSONIC_ENABLED = bool(
SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLISTS
) )
# --- yt-dlp source --- # --- yt-dlp source ---

View file

@ -1,11 +1,17 @@
"""SubsonicProvider: picks tracks from an OpenSubsonic playlist. """SubsonicProvider: picks tracks from one or more OpenSubsonic playlists.
Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic). Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic).
Emits ``subsonic`` tracks (locator = song id). The playlist is cached in Emits ``subsonic`` tracks (locator = song id). Each configured playlist is
memory and refreshed periodically. A cheap local anti-repeat filters out songs cached in memory and refreshed periodically. On every pick the provider walks
whose id was played recently; if that empties the pool (short playlist), the its playlists in weighted-random order (per-playlist weights bias which one is
filter is dropped so playback never stalls. The authoritative, source-agnostic drawn from, independently of playlist length) and returns a song from the first
anti-repeat lives in the Scheduler (on the canonical key). that yields one so an empty, renamed or briefly-unreachable playlist never
stalls the source.
A cheap local anti-repeat filters out songs whose id was played recently; if
that empties a playlist's pool (short playlist), the filter is dropped for it so
playback never stalls. The authoritative, source-agnostic anti-repeat lives in
the Scheduler (on the canonical key).
""" """
import logging import logging
@ -22,43 +28,66 @@ from ..subsonic import SubsonicClient, SubsonicError
log = logging.getLogger("radieo.provider.subsonic") log = logging.getLogger("radieo.provider.subsonic")
class _Playlist:
"""Cached state for a single configured playlist."""
def __init__(self, ref: str, weight: float):
self.ref = ref
self.weight = weight
self.id: str | None = None
self.songs: list[dict] = []
self.loaded_at = 0.0
class SubsonicProvider: class SubsonicProvider:
name = "subsonic" name = "subsonic"
def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database): def __init__(
self, client: SubsonicClient, playlists: list[tuple[str, float]], db: Database
):
self._client = client self._client = client
self._playlist_ref = playlist_ref self._playlists = [_Playlist(ref, weight) for ref, weight in playlists]
self._db = db self._db = db
self._playlist_id: str | None = None
self._songs: list[dict] = []
self._loaded_at = 0.0
def _ensure_songs(self) -> None: def _ensure_songs(self, pl: _Playlist) -> None:
now = time.time() now = time.time()
if self._songs and now - self._loaded_at < config.PLAYLIST_REFRESH: if pl.songs and now - pl.loaded_at < config.PLAYLIST_REFRESH:
return return
if self._playlist_id is None: if pl.id is None:
self._playlist_id = self._client.resolve_playlist_id( pl.id = self._client.resolve_playlist_id(pl.ref)
self._playlist_ref songs = self._client.get_playlist_songs(pl.id)
) pl.songs = songs
songs = self._client.get_playlist_songs(self._playlist_id) pl.loaded_at = now
self._songs = songs log.info("loaded %d songs from playlist %r", len(songs), pl.ref)
self._loaded_at = now
log.info("loaded %d songs from playlist %r", len(songs), self._playlist_ref) def _weighted_order(self) -> list[_Playlist]:
"""Playlists ordered by weighted-random sampling without replacement.
Same EfraimidisSpirakis key as the yt-dlp provider
(``random() ** (1 / weight)``): a heavier playlist tends to come first
while staying probabilistic, and the ordering lets ``next`` fall through
to the following playlist when one is empty or fails to load.
"""
keyed = [
(random.random() ** (1.0 / pl.weight), i, pl)
for i, pl in enumerate(self._playlists)
]
keyed.sort(reverse=True)
return [pl for _, _, pl in keyed]
def next(self) -> Track | None: 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_locators(config.ANTIREPEAT_WINDOW) recent = self._db.recent_locators(config.ANTIREPEAT_WINDOW)
for pl in self._weighted_order():
try:
self._ensure_songs(pl)
except (SubsonicError, httpx.HTTPError, OSError) as exc:
log.warning("could not load playlist %r: %s", pl.ref, exc)
continue
if not pl.songs:
continue
candidates = [ candidates = [
s for s in self._songs if str(s["id"]) not in recent s for s in pl.songs if str(s["id"]) not in recent
] or self._songs ] or pl.songs
song = random.choice(candidates) song = random.choice(candidates)
return Track( return Track(
backend="subsonic", backend="subsonic",
@ -68,3 +97,4 @@ class SubsonicProvider:
origin=self.name, origin=self.name,
source_ext=song.get("suffix"), source_ext=song.get("suffix"),
) )
return None