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:
parent
5dd50f37b7
commit
40061446c9
5 changed files with 117 additions and 48 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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 Efraimidis–Spirakis 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue