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>
7.3 KiB
radieo
A personal music radio: an always-on HTTP audio stream, automatically fed from several sources and broadcast with Liquidsoap.
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.
Features
- 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-compatible server (Navidrome, Gonic, Airsonic…);
- a hand-maintained list of yt-dlp URLs (Bandcamp, SoundCloud, YouTube…); playlist/album/label/artist URLs are expanded and one track is picked at random each round;
- a ListenBrainz 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 — a Bandcamp/YouTube page for yt-dlp tracks, or an on-demand Subsonic share for library tracks), track history and the upcoming queue, skip/restart controls, per-track download, volume memory, live auto-reconnect, prefetch progress, and a synthwave look. - OS media controls: the player exposes current track metadata and play/pause/next through the Media Session API, so it wires into system media controls (MPRIS on Linux, macOS Control Center, Windows, mobile lock screen) and keyboard media keys.
- Robust in a container: Docker healthcheck, graceful shutdown, retries on transient HTTP errors.
Getting started
Requirements: Docker with Compose v2.
1. Jingles (optional)
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):
| Folder | Plays around |
|---|---|
jingles/midi/ |
11:00 |
jingles/moment/ |
15:00, 21:00 |
jingles/gouter/ |
16:30 |
An empty folder simply means no jingle, the music plays through.
2. Configure the sources (.env)
cp .env.example .env
Fill in .env:
- OpenSubsonic server:
RADIEO_SUBSONIC_URL/USER/PASSWORDand the playlist(s) to broadcast inRADIEO_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,0disables). 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 link then mints a public share on demand, only when clicked. - Mix:
RADIEO_WEIGHT_SUBSONIC/RADIEO_WEIGHT_YTDLP/RADIEO_WEIGHT_LISTENBRAINZset the relative draw weight of each source (0disables one). - Optional:
RADIEO_RETENTION_KEEP(cached tracks kept on disk),RADIEO_CANONICAL_ENABLED,RADIEO_USER_AGENT.
3. yt-dlp URL list (config/urls.txt)
cp config/urls.txt.example config/urls.txt
Add one URL per line: a single track, or a playlist/album/label/artist page to pick from. The file is mounted read-only, so you can edit it without rebuilding. A missing file just disables the yt-dlp source.
4. ListenBrainz suggestions
Point RADIEO_LISTENBRAINZ_URL (in .env) at your recommendations syndication
feed, e.g.:
RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/<you>/recommendations/weekly-exploration
ListenBrainz only names tracks; each suggestion is resolved to a concrete file
(an OpenSubsonic search3, then a yt-dlp ytsearch1: fallback) and keyed by the
MusicBrainz MBID the feed already carries. Leave the variable empty to disable.
A local file path under config/ also works for testing.
5. Run it
docker compose up -d
Open the player at http://localhost:8000/; the raw stream is at
http://localhost:8000/radio.mp3 (open it in VLC or any audio player). Stop with
docker compose down.
The station name shown in the player is the STATION_NAME constant near the top
of stream/index.html.
Architecture
radieo is two Docker containers sharing a cache volume. ingest is the brain;
stream is a deliberately dumb broadcaster.
Providers Scheduler Fetchers Broadcast
───────── ───────── ──────── ─────────
Subsonic ──┐ ┌ Subsonic ┐
yt-dlp ────┼─▶ weighted pick ─▶ Canonicalizer┤ ├▶ cache ─▶ queue ─▶ Liquidsoap ─▶ HTTP
ListenBrz ─┘ + anti-repeat (MBID) └ yt-dlp ──┘ (LRU) /next (request.dynamic
(SQLite) + jingles + fallback)
ingest (Python) chooses what to play, resolves and downloads it ahead of
time, and serves it over an internal HTTP API:
- Providers produce a resolved reference (which backend + a locator);
fetchers turn that into a local file (
Subsonicstream /yt-dlpdownload). - The scheduler draws a source by weight, applies anti-repeat, and runs the canonicalizer (MusicBrainz MBID, cached, rate-limited, best-effort) for a source-agnostic identity.
- A prefetch queue keeps a few ready tracks; a SQLite database under
state/holds play history, the MBID cache, and LRU cache-file retention. - API:
GET /next(annotated Liquidsoap URI),/fallback.m3u(already-aired tracks),/status(prefetch progress),/queue(upcoming tracks),/healthz, andPOST /share?id=<songId>(mint a Subsonic share on demand).
stream (Liquidsoap) pulls /next via request.dynamic, inserts jingles
(a switch that also handles the time-of-day slots), applies the crossfade and
mksafe, and outputs the MP3. On the same harbor port 8000 it also serves the
web player and its API: /nowplaying, /history, /skip, /restart-track,
/download, and — proxied from ingest — /queue, /ingest/status, and
/share (which forwards to ingest, then redirects to the created share).
Only port 8000 is published to the host. The browser never talks to ingest
directly — the Liquidsoap harbor acts as a small reverse proxy for the data the
player needs (e.g. /ingest/status, /queue, /share), keeping everything on a
single origin.