From 9cc2ede37d30d43abca84c4a1144045cbd92fcac Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 3 Jul 2026 18:59:22 +0800 Subject: [PATCH 1/2] ingest: rename the Navidrome source to OpenSubsonic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider only uses standard OpenSubsonic endpoints (getPlaylists, getPlaylist, search3, stream), so it works with any compatible server (Navidrome, Gonic, Airsonic…), not just Navidrome. BREAKING: environment variables are renamed RADIEO_NAVIDROME_URL/USER/PASSWORD/PLAYLIST -> RADIEO_SUBSONIC_* RADIEO_WEIGHT_NAVIDROME -> RADIEO_WEIGHT_SUBSONIC Update your .env accordingly. Internally the source key and provider are renamed navidrome -> subsonic, aligning with the existing 'subsonic' backend and fetcher. Co-Authored-By: Claude Opus 4.8 --- .env.example | 14 ++++++------ docker-compose.yml | 12 +++++----- ingest/radieo/__main__.py | 22 +++++++++---------- ingest/radieo/config.py | 20 ++++++++--------- ingest/radieo/models.py | 2 +- ingest/radieo/providers/listenbrainz.py | 10 ++++----- .../providers/{navidrome.py => subsonic.py} | 9 ++++---- ingest/radieo/subsonic.py | 7 +++--- 8 files changed, 49 insertions(+), 47 deletions(-) rename ingest/radieo/providers/{navidrome.py => subsonic.py} (89%) diff --git a/.env.example b/.env.example index c033ba4..7987060 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,14 @@ # radieo — configuration locale. Copier en `.env` et remplir. # docker compose lit automatiquement `.env` pour ces variables. -# --- Source Navidrome / OpenSubsonic --- +# --- Source OpenSubsonic (Navidrome, Gonic, Airsonic…) --- # URL de base de ton serveur (sans /rest). Laisser les champs vides désactive # la source : le stream joue alors uniquement les fichiers déjà dans cache/. -RADIEO_NAVIDROME_URL=https://navidrome.example.org -RADIEO_NAVIDROME_USER=monuser -RADIEO_NAVIDROME_PASSWORD=monmotdepasse +RADIEO_SUBSONIC_URL=https://subsonic.example.org +RADIEO_SUBSONIC_USER=monuser +RADIEO_SUBSONIC_PASSWORD=monmotdepasse # Nom OU identifiant de la playlist à diffuser. -RADIEO_NAVIDROME_PLAYLIST=Radio +RADIEO_SUBSONIC_PLAYLIST=Radio # --- Source yt-dlp --- # La liste d'URL se met dans config/urls.txt (copier config/urls.txt.example). @@ -18,12 +18,12 @@ RADIEO_NAVIDROME_PLAYLIST=Radio # Feed Atom de recommandations. URL http(s) du feed de syndication, ou chemin # local sous /config (ex. /config/recommendations.xml) pour tester. Vide = off. # ListenBrainz ne fait que *nommer* des morceaux : chacun est résolu vers -# Navidrome puis, à défaut, yt-dlp. +# la bibliothèque OpenSubsonic puis, à défaut, yt-dlp. RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/monuser/recommendations/weekly-exploration # --- Dosage du mix entre sources (optionnel) --- # Poids relatifs de tirage de chaque source (0 désactive la source). -RADIEO_WEIGHT_NAVIDROME=3 +RADIEO_WEIGHT_SUBSONIC=3 RADIEO_WEIGHT_YTDLP=1 RADIEO_WEIGHT_LISTENBRAINZ=2 diff --git a/docker-compose.yml b/docker-compose.yml index 76c1f99..8fd80b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,12 +10,12 @@ services: - RADIEO_CACHE_DIR=/cache - RADIEO_STATE_DIR=/state - RADIEO_HTTP_PORT=8080 - # Source Navidrome / OpenSubsonic (voir .env / .env.example). + # Source OpenSubsonic (Navidrome, Gonic, Airsonic… ; voir .env.example). # Laisser vide désactive la source : le stream joue alors son cache local. - - RADIEO_NAVIDROME_URL=${RADIEO_NAVIDROME_URL:-} - - RADIEO_NAVIDROME_USER=${RADIEO_NAVIDROME_USER:-} - - RADIEO_NAVIDROME_PASSWORD=${RADIEO_NAVIDROME_PASSWORD:-} - - RADIEO_NAVIDROME_PLAYLIST=${RADIEO_NAVIDROME_PLAYLIST:-} + - RADIEO_SUBSONIC_URL=${RADIEO_SUBSONIC_URL:-} + - RADIEO_SUBSONIC_USER=${RADIEO_SUBSONIC_USER:-} + - RADIEO_SUBSONIC_PASSWORD=${RADIEO_SUBSONIC_PASSWORD:-} + - RADIEO_SUBSONIC_PLAYLIST=${RADIEO_SUBSONIC_PLAYLIST:-} - RADIEO_RETENTION_KEEP=${RADIEO_RETENTION_KEEP:-20} # Source yt-dlp : liste d'URL dans config/urls.txt (créer depuis l'exemple). - RADIEO_YTDLP_URLS_FILE=/config/urls.txt @@ -23,7 +23,7 @@ services: # sous /config, ex. /config/recommendations.xml). Vide désactive la source. - RADIEO_LISTENBRAINZ_URL=${RADIEO_LISTENBRAINZ_URL:-} # Dosage du mix entre les sources (0 désactive). - - RADIEO_WEIGHT_NAVIDROME=${RADIEO_WEIGHT_NAVIDROME:-3} + - RADIEO_WEIGHT_SUBSONIC=${RADIEO_WEIGHT_SUBSONIC:-3} - RADIEO_WEIGHT_YTDLP=${RADIEO_WEIGHT_YTDLP:-1} - RADIEO_WEIGHT_LISTENBRAINZ=${RADIEO_WEIGHT_LISTENBRAINZ:-2} # Canonicalizer MusicBrainz (identité MBID inter-sources ; sans clé). diff --git a/ingest/radieo/__main__.py b/ingest/radieo/__main__.py index a1a76aa..5ee0dd9 100644 --- a/ingest/radieo/__main__.py +++ b/ingest/radieo/__main__.py @@ -35,24 +35,24 @@ def _build_pipeline(db: Database, canonicalizer): fetchers = {} # backend name -> fetcher subsonic_client = None # reused by ListenBrainz for resolution - if config.NAVIDROME_ENABLED: + if config.SUBSONIC_ENABLED: from .fetchers.subsonic import SubsonicFetcher - from .providers.navidrome import NavidromeProvider + from .providers.subsonic import SubsonicProvider from .subsonic import SubsonicClient subsonic_client = SubsonicClient( - config.NAVIDROME_URL, config.NAVIDROME_USER, config.NAVIDROME_PASSWORD + config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD ) - provider = NavidromeProvider(subsonic_client, config.NAVIDROME_PLAYLIST, db) - providers.append((provider, config.SOURCE_WEIGHTS.get("navidrome", 0))) + provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db) + providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0))) fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR) log.info( - "Navidrome source enabled (playlist=%r, weight=%d)", - config.NAVIDROME_PLAYLIST, - config.SOURCE_WEIGHTS.get("navidrome", 0), + "OpenSubsonic source enabled (playlist=%r, weight=%d)", + config.SUBSONIC_PLAYLIST, + config.SOURCE_WEIGHTS.get("subsonic", 0), ) else: - log.warning("Navidrome not configured (RADIEO_NAVIDROME_*): source off.") + log.warning("OpenSubsonic not configured (RADIEO_SUBSONIC_*): source off.") if config.SOURCE_WEIGHTS.get("ytdlp", 0) > 0 and config.YTDLP_URLS_FILE.exists(): from .fetchers.ytdlp import YtdlpFetcher @@ -77,7 +77,7 @@ def _build_pipeline(db: Database, canonicalizer): from .providers.listenbrainz import ListenBrainzProvider # ListenBrainz has no backend of its own: it resolves each suggestion to - # Navidrome then yt-dlp. Make sure the yt-dlp fetcher exists as a + # the Subsonic library then yt-dlp. Make sure the yt-dlp fetcher exists as a # resolution target even when the yt-dlp *source* is off. if "ytdlp" not in fetchers: from .fetchers.ytdlp import YtdlpFetcher @@ -94,7 +94,7 @@ def _build_pipeline(db: Database, canonicalizer): "ListenBrainz source enabled (feed=%s, weight=%d, resolve=%s)", config.LISTENBRAINZ_URL, config.SOURCE_WEIGHTS["listenbrainz"], - "navidrome+ytdlp" if subsonic_client else "ytdlp", + "subsonic+ytdlp" if subsonic_client else "ytdlp", ) else: log.info( diff --git a/ingest/radieo/config.py b/ingest/radieo/config.py index 2aa28f0..4965b55 100644 --- a/ingest/radieo/config.py +++ b/ingest/radieo/config.py @@ -19,7 +19,7 @@ HTTP_HOST = os.environ.get("RADIEO_HTTP_HOST", "0.0.0.0") HTTP_PORT = int(os.environ.get("RADIEO_HTTP_PORT", "8080")) # Transport-level retries for transient connection errors on outgoing HTTP -# (Navidrome, MusicBrainz, ListenBrainz). Applies to connect failures only. +# (OpenSubsonic, MusicBrainz, ListenBrainz). Applies to connect failures only. HTTP_RETRIES = int(os.environ.get("RADIEO_HTTP_RETRIES", "2")) # --- Prefetching / retention --- @@ -49,18 +49,18 @@ USER_AGENT = os.environ.get( "RADIEO_USER_AGENT", "radieo/0.1 (personal music radio)" ) -# --- Navidrome / OpenSubsonic source --- +# --- OpenSubsonic source (Navidrome, Gonic, Airsonic…) --- # Left empty means the provider is disabled (the stream then plays its own # local-cache fallback). Credentials are expected to come from a .env file. -NAVIDROME_URL = os.environ.get("RADIEO_NAVIDROME_URL", "").strip() -NAVIDROME_USER = os.environ.get("RADIEO_NAVIDROME_USER", "").strip() -NAVIDROME_PASSWORD = os.environ.get("RADIEO_NAVIDROME_PASSWORD", "") -NAVIDROME_PLAYLIST = os.environ.get("RADIEO_NAVIDROME_PLAYLIST", "").strip() +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() # How often to reload the playlist contents, in seconds. PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300")) -NAVIDROME_ENABLED = bool( - NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD and NAVIDROME_PLAYLIST +SUBSONIC_ENABLED = bool( + SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST ) # --- yt-dlp source --- @@ -74,7 +74,7 @@ YTDLP_REFRESH = float(os.environ.get("RADIEO_YTDLP_REFRESH", "300")) # --- ListenBrainz suggestions source --- # Atom recommendations feed. May be an http(s) URL (the real syndication feed) # or a local file path (for testing with a saved sample). Empty disables it. -# ListenBrainz only *names* tracks; each is resolved to Navidrome then yt-dlp. +# ListenBrainz only *names* tracks; each is resolved to the Subsonic library then yt-dlp. LISTENBRAINZ_URL = os.environ.get("RADIEO_LISTENBRAINZ_URL", "").strip() # How often to reload the feed, in seconds (it refreshes weekly). LISTENBRAINZ_REFRESH = float(os.environ.get("RADIEO_LISTENBRAINZ_REFRESH", "3600")) @@ -84,7 +84,7 @@ LISTENBRAINZ_ENABLED = bool(LISTENBRAINZ_URL) # Relative odds of drawing from each source. Isolated here on purpose so the # mix can later move to a config file. A weight of 0 disables a source. SOURCE_WEIGHTS = { - "navidrome": int(os.environ.get("RADIEO_WEIGHT_NAVIDROME", "3")), + "subsonic": int(os.environ.get("RADIEO_WEIGHT_SUBSONIC", "3")), "ytdlp": int(os.environ.get("RADIEO_WEIGHT_YTDLP", "1")), "listenbrainz": int(os.environ.get("RADIEO_WEIGHT_LISTENBRAINZ", "2")), } diff --git a/ingest/radieo/models.py b/ingest/radieo/models.py index c9d26fb..6d8f41c 100644 --- a/ingest/radieo/models.py +++ b/ingest/radieo/models.py @@ -25,7 +25,7 @@ class Track: locator: str # backend-specific: Subsonic song id, or a media URL artist: str title: str - origin: str # provider that produced it, e.g. "navidrome" + origin: str # provider that produced it, e.g. "subsonic" mbid: str | None = None # filled by the Canonicalizer (milestone 5) source_ext: str | None = None # filename hint, e.g. "mp3", "flac" source_url: str | None = None # container URL a track was picked from diff --git a/ingest/radieo/providers/listenbrainz.py b/ingest/radieo/providers/listenbrainz.py index 5ed89c4..0717cab 100644 --- a/ingest/radieo/providers/listenbrainz.py +++ b/ingest/radieo/providers/listenbrainz.py @@ -11,8 +11,8 @@ Track directly, which gives a source-agnostic identity for free (no MusicBrainz lookup needed). Unlike the other providers, ListenBrainz yields no audio of its own: it only -*names* tracks. Each pick is therefore **resolved** to a concrete backend — -Navidrome first (an OpenSubsonic ``search3``), then yt-dlp (a ``ytsearch1:`` +*names* tracks. Each pick is therefore **resolved** to a concrete backend — the +OpenSubsonic library first (a ``search3``), then yt-dlp (a ``ytsearch1:`` query) as a fallback — so the download layer is unchanged. ``RADIEO_LISTENBRAINZ_URL`` may be an ``http(s)`` URL (the real syndication @@ -114,7 +114,7 @@ class ListenBrainzProvider: def _resolve(self, rec: dict) -> Track | None: artist, title, mbid = rec["artist"], rec["title"], rec["mbid"] - song = self._search_navidrome(artist, title) + song = self._search_subsonic(artist, title) if song is not None: return Track( backend="subsonic", @@ -137,13 +137,13 @@ class ListenBrainzProvider: ) return None - def _search_navidrome(self, artist: str, title: str) -> dict | None: + def _search_subsonic(self, artist: str, title: str) -> dict | None: if self._subsonic is None: return None try: songs = self._subsonic.search_songs(title) except (SubsonicError, httpx.HTTPError, OSError) as exc: - log.warning("Navidrome search failed for %s — %s: %s", artist, title, exc) + log.warning("Subsonic search failed for %s — %s: %s", artist, title, exc) return None want_a, want_t = norm_name(artist), norm_name(title) for s in songs: diff --git a/ingest/radieo/providers/navidrome.py b/ingest/radieo/providers/subsonic.py similarity index 89% rename from ingest/radieo/providers/navidrome.py rename to ingest/radieo/providers/subsonic.py index 8a3fa0a..2aa7702 100644 --- a/ingest/radieo/providers/navidrome.py +++ b/ingest/radieo/providers/subsonic.py @@ -1,5 +1,6 @@ -"""NavidromeProvider: picks tracks from an OpenSubsonic playlist. +"""SubsonicProvider: picks tracks from an OpenSubsonic playlist. +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 @@ -18,11 +19,11 @@ from ..db import Database from ..models import Track from ..subsonic import SubsonicClient, SubsonicError -log = logging.getLogger("radieo.provider.navidrome") +log = logging.getLogger("radieo.provider.subsonic") -class NavidromeProvider: - name = "navidrome" +class SubsonicProvider: + name = "subsonic" def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database): self._client = client diff --git a/ingest/radieo/subsonic.py b/ingest/radieo/subsonic.py index 8118a9f..a23b521 100644 --- a/ingest/radieo/subsonic.py +++ b/ingest/radieo/subsonic.py @@ -1,7 +1,8 @@ -"""Minimal OpenSubsonic client (enough for Navidrome playback). +"""Minimal OpenSubsonic client (enough for playlist playback). +Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Uses salted-token authentication (``t = md5(password + salt)``), the scheme -recommended by the Subsonic API since 1.13.0 and supported by Navidrome. +recommended by the Subsonic API since 1.13.0. """ import hashlib @@ -105,7 +106,7 @@ class SubsonicClient: def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str: """Download a song to ``dest``; return the file extension used. - ``format=raw`` asks Navidrome for the original file (no transcoding), + ``format=raw`` asks the server for the original file (no transcoding), keeping quality and letting Liquidsoap decode it. """ params = {**self._auth_params(), "id": song_id, "format": "raw"} From a1fed6b4e38e7e7734418a139ea5674c65bb1203 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 3 Jul 2026 19:00:21 +0800 Subject: [PATCH 2/2] docs: rewrite README with features, setup and architecture Co-Authored-By: Claude Opus 4.8 --- README.md | 227 ++++++++++++++++++++++++++---------------------------- 1 file changed, 111 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index aaadaf4..b9cc519 100644 --- a/README.md +++ b/README.md @@ -7,145 +7,140 @@ The goal is a hassle-free stream that always has something playing, where the next track is picked automatically. It is meant for personal use (a couple of simultaneous listeners), not for public broadcasting. -## How it works +## Features -radieo is built as two layers, each running in its own Docker container and -sharing a cache volume: +- **Always-on stream**: MP3 at 192 kbps over HTTP, several simultaneous + listeners. +- **Automatic programming from mixable sources**, drawn at weighted random: + - a playlist from any [OpenSubsonic](https://opensubsonic.netlify.app/)-compatible + server ([Navidrome](https://www.navidrome.org/), Gonic, Airsonic…); + - a hand-maintained list of [yt-dlp](https://github.com/yt-dlp/yt-dlp) URLs + (Bandcamp, SoundCloud, YouTube…); playlist/album/label/artist URLs are + expanded and one track is picked at random each round; + - a [ListenBrainz](https://listenbrainz.org/) recommendations feed, whose + suggestions are resolved to a real file (Subsonic first, then yt-dlp). +- **Cross-source de-duplication**: each track is canonicalized to a MusicBrainz + recording MBID (no API key), so the same song from two sources collapses to + one; a recent-plays window prevents repeats. +- **Push-with-cache**: tracks are downloaded *ahead* of playback into a local + cache with LRU retention; if the pipeline ever runs dry the stream falls back + to the tracks already aired, and stays silent at a cold start rather than + looping. +- **Jingles**: station jingles inserted every two songs, plus time-of-day + jingles (noon, snack time, ...) played once when their time comes. +- **Smooth playback**: a 3 s crossfade between tracks. +- **Built-in web player** at `http://localhost:8000/`: now playing (linked to + its source page), track history, skip button, per-track download, volume + memory, live auto-reconnect, prefetch progress, and a synthwave look. +- **Robust in a container**: Docker healthcheck, graceful shutdown, retries on + transient HTTP errors. -- **`ingest`** (Python) — the brain. It decides what to play next, resolves and - downloads tracks into a local cache, keeps a pre-filled queue, and exposes the - next track over HTTP at `GET /next`. *(currently it only serves the cache - directory; the download providers come in later milestones — see roadmap)* -- **`stream`** (Liquidsoap) — deliberately dumb. It pulls the next track from - the `ingest` daemon, broadcasts the audio over HTTP, and falls back to the - already-aired tracks (via `/fallback.m3u`) if the daemon has nothing ready. - -Playback sources (planned): a [Navidrome](https://www.navidrome.org/) library -via the OpenSubsonic API, arbitrary tracks fetched with -[yt-dlp](https://github.com/yt-dlp/yt-dlp) (Bandcamp, SoundCloud, YouTube…), and -listening suggestions from a ListenBrainz RSS feed. - -## Usage +## Getting started Requirements: Docker with Compose v2. -```sh -# Drop some .mp3 files into the cache directory -cp /path/to/music/*.mp3 cache/ +### 1. Jingles (optional) -# Build and start the stream -docker compose up -d +Drop `.mp3` files into `jingles/`: they rotate in every two songs. For +time-of-day jingles, add files to these subfolders (each played once per day +just after its slot): -# Listen (VLC, a browser, any audio player) -# http://localhost:8000/radio.mp3 -``` +| Folder | Plays around | +| ----------------- | ------------ | +| `jingles/midi/` | 11:00 | +| `jingles/moment/` | 15:00, 21:00 | +| `jingles/gouter/` | 16:30 | -Stop it with `docker compose down`. +An empty folder simply means no jingle, the music plays through. -The stream is MP3 at 192 kbps. Multiple clients can listen at the same time. -New files dropped into `cache/` are picked up automatically (the playlist is -reloaded when the directory changes). - -## Configuration - -Copy `.env.example` to `.env` and fill in your Navidrome details: +### 2. Configure the sources (`.env`) ```sh cp .env.example .env -# edit .env: RADIEO_NAVIDROME_URL / USER / PASSWORD / PLAYLIST ``` -If the Navidrome variables are left empty, the source is simply disabled and -the stream plays whatever is already in `cache/` (the milestone-1/2 behaviour). +Fill in `.env`: -For the yt-dlp source, list the URLs to draw from in `config/urls.txt` (copy -`config/urls.txt.example`). Each line is either a direct track URL or a -container URL (playlist, album, label, artist page) from which one track is -picked at random. +- **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the + playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with + any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty + to disable this source. +- **Mix**: `RADIEO_WEIGHT_SUBSONIC` / `RADIEO_WEIGHT_YTDLP` / + `RADIEO_WEIGHT_LISTENBRAINZ` set the relative draw weight of each source + (`0` disables one). +- Optional: `RADIEO_RETENTION_KEEP` (cached tracks kept on disk), + `RADIEO_CANONICAL_ENABLED`, `RADIEO_USER_AGENT`. -For the ListenBrainz source, set `RADIEO_LISTENBRAINZ_URL` to your -recommendations feed (the Atom syndication URL, e.g. -`https://listenbrainz.org/syndication-feed/user//recommendations/weekly-exploration`, -or a local file path under `config/` for testing). ListenBrainz only *names* -tracks, so each suggestion is resolved to a real file: Navidrome first -(a `search3` lookup), then yt-dlp (`ytsearch1:`) as a fallback. The -MusicBrainz recording MBID that the feed already carries is used as the -track's canonical identity (no extra lookup needed). +### 3. yt-dlp URL list (`config/urls.txt`) -The relative mix between sources is set by `RADIEO_WEIGHT_NAVIDROME` / -`RADIEO_WEIGHT_YTDLP` / `RADIEO_WEIGHT_LISTENBRAINZ` (a weight of 0 disables a -source); an empty URL / missing file also disables the corresponding source. +```sh +cp config/urls.txt.example config/urls.txt +``` -Open the player at `http://localhost:8000/` — a small web page with an -`