All checks were successful
continuous-integration/drone/push Build is passing
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>
185 lines
8.6 KiB
Markdown
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.
|