diff --git a/.env.example b/.env.example index 7987060..98be7a3 100644 --- a/.env.example +++ b/.env.example @@ -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 '=' (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 --- diff --git a/README.md b/README.md index ab62d10..650d438 100644 --- a/README.md +++ b/README.md @@ -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 `=` 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 diff --git a/ingest/radieo/__main__.py b/ingest/radieo/__main__.py index cd3b00b..1b82cd2 100644 --- a/ingest/radieo/__main__.py +++ b/ingest/radieo/__main__.py @@ -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: diff --git a/ingest/radieo/config.py b/ingest/radieo/config.py index c7c204f..71ee9a4 100644 --- a/ingest/radieo/config.py +++ b/ingest/radieo/config.py @@ -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 '=' 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 '='. +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 --- diff --git a/ingest/radieo/providers/subsonic.py b/ingest/radieo/providers/subsonic.py index 2aa7702..6011222 100644 --- a/ingest/radieo/providers/subsonic.py +++ b/ingest/radieo/providers/subsonic.py @@ -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