- Python 71.2%
- HTML 26.6%
- JavaScript 1.6%
- Dockerfile 0.6%
|
All checks were successful
continuous-integration/drone/push Build is passing
Wire the web player into system media controls (MPRIS, Control Center, lock screen) and keyboard media keys: push track metadata and handle play/pause/next, with seek neutralized on the live stream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| cache | ||
| config | ||
| ingest | ||
| jingles | ||
| stream | ||
| .drone-manifest-ingest.yml | ||
| .drone-manifest-stream.yml | ||
| .drone.yml | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
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), track history, skip button, 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 to broadcast inRADIEO_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_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),/healthz.
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, /download, plus
/ingest/status.
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), keeping everything on a single origin.