radieo/README.md
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

185 lines
8.6 KiB
Markdown

# radieo
A personal music radio: an always-on HTTP audio stream, automatically fed from
several sources and broadcast with [Liquidsoap](https://www.liquidsoap.info/).
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](https://opensubsonic.netlify.app/)-compatible
server ([Navidrome](https://www.navidrome.org/), Gonic, Airsonic…);
- a hand-maintained list of [yt-dlp](https://github.com/yt-dlp/yt-dlp) URLs
(Bandcamp, SoundCloud, YouTube…); playlist/album/label/artist URLs are
expanded and one track is picked at random each round;
- a [ListenBrainz](https://listenbrainz.org/) 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`)
```sh
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`)
```sh
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
```sh
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.