No description
  • Python 71.2%
  • HTML 26.6%
  • JavaScript 1.6%
  • Dockerfile 0.6%
Find a file
Pierre-Olivier Mercier 622210197f
All checks were successful
continuous-integration/drone/push Build is passing
stream: expose OS media controls via the Media Session API
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>
2026-07-03 19:17:20 +08:00
cache Milestone 1: Liquidsoap broadcasting skeleton 2026-07-02 16:14:14 +08:00
config Milestone 4: yt-dlp provider and weighted source scheduler 2026-07-02 17:58:24 +08:00
ingest ingest: rename the Navidrome source to OpenSubsonic 2026-07-03 18:59:22 +08:00
jingles stream: interleave a random jingle every two songs 2026-07-03 10:15:25 +08:00
stream stream: expose OS media controls via the Media Session API 2026-07-03 19:17:20 +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 ingest: rename the Navidrome source to OpenSubsonic 2026-07-03 18:59:22 +08:00
.gitignore Milestone 6: ListenBrainz recommendations provider 2026-07-02 19:14:47 +08:00
docker-compose.yml ingest: rename the Navidrome source to OpenSubsonic 2026-07-03 18:59:22 +08:00
README.md stream: expose OS media controls via the Media Session API 2026-07-03 19:17:20 +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), 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 / 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.

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 (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), /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.