docs: rewrite README with features, setup and architecture
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9cc2ede37d
commit
a1fed6b4e3
1 changed files with 119 additions and 124 deletions
227
README.md
227
README.md
|
|
@ -7,145 +7,140 @@ 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
|
next track is picked automatically. It is meant for personal use (a couple of
|
||||||
simultaneous listeners), not for public broadcasting.
|
simultaneous listeners), not for public broadcasting.
|
||||||
|
|
||||||
## How it works
|
## Features
|
||||||
|
|
||||||
radieo is built as two layers, each running in its own Docker container and
|
- **Always-on stream**: MP3 at 192 kbps over HTTP, several simultaneous
|
||||||
sharing a cache volume:
|
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.
|
||||||
|
- **Robust in a container**: Docker healthcheck, graceful shutdown, retries on
|
||||||
|
transient HTTP errors.
|
||||||
|
|
||||||
- **`ingest`** (Python) — the brain. It decides what to play next, resolves and
|
## Getting started
|
||||||
downloads tracks into a local cache, keeps a pre-filled queue, and exposes the
|
|
||||||
next track over HTTP at `GET /next`. *(currently it only serves the cache
|
|
||||||
directory; the download providers come in later milestones — see roadmap)*
|
|
||||||
- **`stream`** (Liquidsoap) — deliberately dumb. It pulls the next track from
|
|
||||||
the `ingest` daemon, broadcasts the audio over HTTP, and falls back to the
|
|
||||||
already-aired tracks (via `/fallback.m3u`) if the daemon has nothing ready.
|
|
||||||
|
|
||||||
Playback sources (planned): a [Navidrome](https://www.navidrome.org/) library
|
|
||||||
via the OpenSubsonic API, arbitrary tracks fetched with
|
|
||||||
[yt-dlp](https://github.com/yt-dlp/yt-dlp) (Bandcamp, SoundCloud, YouTube…), and
|
|
||||||
listening suggestions from a ListenBrainz RSS feed.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Requirements: Docker with Compose v2.
|
Requirements: Docker with Compose v2.
|
||||||
|
|
||||||
```sh
|
### 1. Jingles (optional)
|
||||||
# Drop some .mp3 files into the cache directory
|
|
||||||
cp /path/to/music/*.mp3 cache/
|
|
||||||
|
|
||||||
# Build and start the stream
|
Drop `.mp3` files into `jingles/`: they rotate in every two songs. For
|
||||||
docker compose up -d
|
time-of-day jingles, add files to these subfolders (each played once per day
|
||||||
|
just after its slot):
|
||||||
|
|
||||||
# Listen (VLC, a browser, any audio player)
|
| Folder | Plays around |
|
||||||
# http://localhost:8000/radio.mp3
|
| ----------------- | ------------ |
|
||||||
```
|
| `jingles/midi/` | 11:00 |
|
||||||
|
| `jingles/moment/` | 15:00, 21:00 |
|
||||||
|
| `jingles/gouter/` | 16:30 |
|
||||||
|
|
||||||
Stop it with `docker compose down`.
|
An empty folder simply means no jingle, the music plays through.
|
||||||
|
|
||||||
The stream is MP3 at 192 kbps. Multiple clients can listen at the same time.
|
### 2. Configure the sources (`.env`)
|
||||||
New files dropped into `cache/` are picked up automatically (the playlist is
|
|
||||||
reloaded when the directory changes).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and fill in your Navidrome details:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# edit .env: RADIEO_NAVIDROME_URL / USER / PASSWORD / PLAYLIST
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If the Navidrome variables are left empty, the source is simply disabled and
|
Fill in `.env`:
|
||||||
the stream plays whatever is already in `cache/` (the milestone-1/2 behaviour).
|
|
||||||
|
|
||||||
For the yt-dlp source, list the URLs to draw from in `config/urls.txt` (copy
|
- **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the
|
||||||
`config/urls.txt.example`). Each line is either a direct track URL or a
|
playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with
|
||||||
container URL (playlist, album, label, artist page) from which one track is
|
any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty
|
||||||
picked at random.
|
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`.
|
||||||
|
|
||||||
For the ListenBrainz source, set `RADIEO_LISTENBRAINZ_URL` to your
|
### 3. yt-dlp URL list (`config/urls.txt`)
|
||||||
recommendations feed (the Atom syndication URL, e.g.
|
|
||||||
`https://listenbrainz.org/syndication-feed/user/<you>/recommendations/weekly-exploration`,
|
|
||||||
or a local file path under `config/` for testing). ListenBrainz only *names*
|
|
||||||
tracks, so each suggestion is resolved to a real file: Navidrome first
|
|
||||||
(a `search3` lookup), then yt-dlp (`ytsearch1:`) as a fallback. The
|
|
||||||
MusicBrainz recording MBID that the feed already carries is used as the
|
|
||||||
track's canonical identity (no extra lookup needed).
|
|
||||||
|
|
||||||
The relative mix between sources is set by `RADIEO_WEIGHT_NAVIDROME` /
|
```sh
|
||||||
`RADIEO_WEIGHT_YTDLP` / `RADIEO_WEIGHT_LISTENBRAINZ` (a weight of 0 disables a
|
cp config/urls.txt.example config/urls.txt
|
||||||
source); an empty URL / missing file also disables the corresponding source.
|
```
|
||||||
|
|
||||||
Open the player at `http://localhost:8000/` — a small web page with an
|
Add one URL per line: a single track, or a playlist/album/label/artist page to
|
||||||
`<audio>` player and the current track (title/artist), which it refreshes from
|
pick from. The file is mounted read-only, so you can edit it without rebuilding.
|
||||||
`GET /nowplaying`. The raw stream stays at `http://localhost:8000/radio.mp3`.
|
A missing file just disables the yt-dlp source.
|
||||||
|
|
||||||
## Current status
|
### 4. ListenBrainz suggestions
|
||||||
|
|
||||||
**Milestone 7 — polish: done.**
|
Point `RADIEO_LISTENBRAINZ_URL` (in `.env`) at your recommendations syndication
|
||||||
|
feed, e.g.:
|
||||||
|
|
||||||
- A small web player is served by Liquidsoap at `http://localhost:8000/`
|
```
|
||||||
(alongside the stream), showing the track currently on air; it reads
|
RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/<you>/recommendations/weekly-exploration
|
||||||
`/nowplaying` (JSON), fed from the broadcast source's live metadata — so it
|
```
|
||||||
is accurate even though the ingest daemon runs a track ahead (prefetch).
|
|
||||||
- A 3 s crossfade smooths transitions between tracks.
|
|
||||||
- The fallback only replays tracks **already aired**: the ingest daemon exposes
|
|
||||||
them as an `/fallback.m3u` playlist (served into a local file the stream
|
|
||||||
watches). The pre-fetch buffer (downloaded but not-yet-played tracks) is
|
|
||||||
excluded, so at cold start the list is empty and the stream stays **silent**
|
|
||||||
rather than looping the two or three tracks being pre-fetched. Once the buffer
|
|
||||||
drains mid-stream, it degrades gracefully across every track heard so far
|
|
||||||
instead of a tight loop.
|
|
||||||
- Robustness: the ingest daemon shuts down cleanly on SIGTERM (fast
|
|
||||||
`docker compose down`), has a Docker healthcheck on `/healthz` (the stream
|
|
||||||
waits for it to be healthy), and outgoing HTTP calls retry transient
|
|
||||||
connection errors.
|
|
||||||
|
|
||||||
**Playback (milestones 3–6).**
|
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.
|
||||||
|
|
||||||
- Three playback sources feed a weighted scheduler: a Navidrome/OpenSubsonic
|
### 5. Run it
|
||||||
playlist, a hand-maintained list of yt-dlp URLs (`config/urls.txt`), and a
|
|
||||||
ListenBrainz recommendations feed. Container URLs (playlist/album/label/artist)
|
|
||||||
are expanded and one track is drawn at random.
|
|
||||||
- ListenBrainz suggestions carry a MusicBrainz recording MBID, a title and an
|
|
||||||
artist; each is resolved to a concrete file (Navidrome `search3` first, then
|
|
||||||
a yt-dlp `ytsearch1:` fallback) and keyed directly by its MBID — so the same
|
|
||||||
song is de-duplicated across all three sources for free.
|
|
||||||
- Each track is canonicalized to a MusicBrainz recording MBID (no API key
|
|
||||||
needed; ~1 req/s, best-effort, results cached in SQLite). This gives a
|
|
||||||
source-agnostic identity, so the same song from two sources collapses to one;
|
|
||||||
when no confident match is found it falls back to a normalized
|
|
||||||
`(artist, title)` key. The scheduler uses this canonical key for anti-repeat,
|
|
||||||
with the providers applying a cheap locator filter first.
|
|
||||||
- Each source has its own fetcher (Subsonic stream / yt-dlp download); files are
|
|
||||||
cached ahead of playback (prefetch buffer) and decoded by Liquidsoap.
|
|
||||||
- Play history and LRU retention are tracked in a SQLite database under
|
|
||||||
`state/`: only the N most recently played files are kept on disk
|
|
||||||
(`RADIEO_RETENTION_KEEP`, default 20). Orphaned download temp files are swept
|
|
||||||
on startup.
|
|
||||||
- `GET /next` returns the next track as an annotated Liquidsoap URI with real
|
|
||||||
title/artist metadata (or an empty body when nothing is ready).
|
|
||||||
- `stream` (Liquidsoap v2.4.5) pulls via `request.dynamic` and falls back to the
|
|
||||||
local `cache/` directory; `mksafe` guarantees silence rather than a crash.
|
|
||||||
- HTTP stream served at `http://localhost:8000/radio.mp3` (MP3, 192 kbps),
|
|
||||||
multiple simultaneous listeners supported.
|
|
||||||
|
|
||||||
The radio is feature-complete for personal use. A future nicety would be moving
|
```sh
|
||||||
the source weights and settings from environment variables into a single config
|
docker compose up -d
|
||||||
file.
|
```
|
||||||
|
|
||||||
## Roadmap
|
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`.
|
||||||
|
|
||||||
1. ✅ **Broadcasting skeleton** — Liquidsoap serving the cache directory.
|
The station name shown in the player is the `STATION_NAME` constant near the top
|
||||||
2. ✅ **Ingestion daemon** — Python daemon exposing `GET /next`; Liquidsoap
|
of `stream/index.html`.
|
||||||
switches to a `request.dynamic` source with the cache as fallback.
|
|
||||||
3. ✅ **Navidrome provider** — play from an OpenSubsonic playlist, with caching,
|
## Architecture
|
||||||
LRU retention and play history.
|
|
||||||
4. ✅ **yt-dlp provider** — fetch tracks from a maintained URL/artist list;
|
radieo is two Docker containers sharing a cache volume. `ingest` is the brain;
|
||||||
weighted mixing between sources.
|
`stream` is a deliberately dumb broadcaster.
|
||||||
5. ✅ **Canonicalizer** — MusicBrainz MBID lookup for source-agnostic
|
|
||||||
de-duplication.
|
```
|
||||||
6. ✅ **ListenBrainz provider** — parse the recommendations feed and resolve
|
Providers Scheduler Fetchers Broadcast
|
||||||
each suggestion to Navidrome or yt-dlp.
|
───────── ───────── ──────── ─────────
|
||||||
7. ✅ **Polish** — crossfade, web player, quieter logs, robustness (graceful
|
Subsonic ──┐ ┌ Subsonic ┐
|
||||||
shutdown, healthcheck, HTTP retries). *(Config file: still env-based.)*
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue