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_USER=monuser
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
# --- Source yt-dlp ---

View file

@ -68,7 +68,9 @@ cp .env.example .env
Fill in `.env`:
- **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
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

View file

@ -43,12 +43,12 @@ def _build_pipeline(db: Database, canonicalizer):
subsonic_client = SubsonicClient(
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)))
fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR)
log.info(
"OpenSubsonic source enabled (playlist=%r, weight=%d)",
config.SUBSONIC_PLAYLIST,
"OpenSubsonic source enabled (playlists=%r, weight=%d)",
config.SUBSONIC_PLAYLISTS,
config.SOURCE_WEIGHTS.get("subsonic", 0),
)
else:

View file

@ -55,12 +55,46 @@ USER_AGENT = os.environ.get(
SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip()
SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip()
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.
PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300"))
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 ---

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).
Emits ``subsonic`` tracks (locator = song id). The playlist is cached in
memory and refreshed periodically. A cheap local anti-repeat filters out songs
whose id was played recently; if that empties the pool (short playlist), the
filter is dropped so playback never stalls. The authoritative, source-agnostic
anti-repeat lives in the Scheduler (on the canonical key).
Emits ``subsonic`` tracks (locator = song id). Each configured playlist is
cached in memory and refreshed periodically. On every pick the provider walks
its playlists in weighted-random order (per-playlist weights bias which one is
drawn from, independently of playlist length) and returns a song from the first
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
@ -22,49 +28,73 @@ from ..subsonic import SubsonicClient, SubsonicError
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:
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._playlist_ref = playlist_ref
self._playlists = [_Playlist(ref, weight) for ref, weight in playlists]
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()
if self._songs and now - self._loaded_at < config.PLAYLIST_REFRESH:
if pl.songs and now - pl.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)
if pl.id is None:
pl.id = self._client.resolve_playlist_id(pl.ref)
songs = self._client.get_playlist_songs(pl.id)
pl.songs = songs
pl.loaded_at = now
log.info("loaded %d songs from playlist %r", len(songs), pl.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:
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)
candidates = [
s for s in self._songs if str(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"),
)
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 = [
s for s in pl.songs if str(s["id"]) not in recent
] or pl.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"),
)
return None