All checks were successful
continuous-integration/drone/push Build is passing
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>
150 lines
6.6 KiB
Markdown
150 lines
6.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), 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`)
|
|
|
|
```sh
|
|
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`)
|
|
|
|
```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.
|
|
|
|
### 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), `/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.
|