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_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 ---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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 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:
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue