radieo/README.md
Pierre-Olivier Mercier 112a4b0c89
Some checks are pending
continuous-integration/drone/push Build is running
ingest: bias yt-dlp picks towards a discography's newest releases
Bandcamp label/artist pages list releases newest-first, so give the first
YTDLP_RECENT_COUNT entries a YTDLP_RECENT_BOOST multiplier when picking,
leaning the radio towards fresh music. Flat extraction carries no dates, so
list position is used as the recency proxy rather than a real date lookup.

The boost is gated on the source URL being a bandcamp discography listing:
single /album/ and /track/ pages (whether a URL-file line or one the pick
recursed into) list tracks in track order, not by recency, and non-bandcamp
sources have unverified ordering, so all of those keep a uniform pick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:02:37 +08:00

167 lines
7.8 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. 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.