No description
  • Python 71.2%
  • HTML 26.6%
  • JavaScript 1.6%
  • Dockerfile 0.6%
Find a file
Pierre-Olivier Mercier d302cf1c88
All checks were successful
continuous-integration/drone/push Build is passing
stream: scrobble listened tracks to ListenBrainz
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>
2026-07-04 17:51:41 +08:00
cache Milestone 1: Liquidsoap broadcasting skeleton 2026-07-02 16:14:14 +08:00
config ingest: weight yt-dlp sources with an optional WEIGHT:URL prefix 2026-07-04 16:16:04 +08:00
ingest stream: scrobble listened tracks to ListenBrainz 2026-07-04 17:51:41 +08:00
jingles stream: interleave a random jingle every two songs 2026-07-03 10:15:25 +08:00
stream stream: scrobble listened tracks to ListenBrainz 2026-07-04 17:51:41 +08:00
.drone-manifest-ingest.yml ci: build ingest and stream images for amd64 and arm64 2026-07-03 10:15:25 +08:00
.drone-manifest-stream.yml ci: build ingest and stream images for amd64 and arm64 2026-07-03 10:15:25 +08:00
.drone.yml ci: build ingest and stream images for amd64 and arm64 2026-07-03 10:15:25 +08:00
.env.example stream: scrobble listened tracks to ListenBrainz 2026-07-04 17:51:41 +08:00
.gitignore Milestone 6: ListenBrainz recommendations provider 2026-07-02 19:14:47 +08:00
docker-compose.yml stream: scrobble listened tracks to ListenBrainz 2026-07-04 17:51:41 +08:00
README.md stream: scrobble listened tracks to ListenBrainz 2026-07-04 17:51:41 +08:00

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 / PASSWORD and the playlist(s) to broadcast in RADIEO_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, 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 link then mints a public share on demand, only when clicked.
  • 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.

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 (Subsonic stream / yt-dlp download).
  • 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, and POST /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.