The web player decides when a track counts as listened (caught near its start and heard to ~90%, capped at 4 min) and triggers POST /scrobble. The token stays server-side (RADIEO_LISTENBRAINZ_TOKEN), submitting the listen with the canonical MusicBrainz MBID when available. Each airing is deduplicated so multiple tabs submit it once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.6 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.
To favour fresh music, set RADIEO_YTDLP_RECENT_BOOST (in .env) above 1.0:
when picking from a Bandcamp label/artist/discography page — which lists
releases newest-first — the newest RADIEO_YTDLP_RECENT_COUNT releases (default
5) get that multiplier on their odds (e.g. 2.0 makes them twice as likely).
The default 1.0 disables it. The boost only applies to such discography
listings; single /album/ and /track/ pages and non-Bandcamp sources keep a
uniform pick.
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. Scrobbling to ListenBrainz (optional)
Set RADIEO_LISTENBRAINZ_TOKEN (in .env) to your ListenBrainz user token
(from https://listenbrainz.org/settings/) to scrobble what you listen to:
RADIEO_LISTENBRAINZ_TOKEN=<your-user-token>
The web player decides when a track counts as listened — caught near its
start (server position < 10 s) and actually heard to ~90 % of its length (capped
at 4 min, ListenBrainz's own rule) — then triggers POST /scrobble. The token
stays server-side (never exposed to the browser); the stream submits the listen,
using the canonical MusicBrainz MBID when the canonicalizer found one. Only
listeners on the web page scrobble (external players like VLC don't), and each
airing is submitted once even with several tabs open. Leave the variable empty
to disable.
6. 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.