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
|
||||
simultaneous listeners), not for public broadcasting.
|
||||
|
||||
## How it works
|
||||
## Features
|
||||
|
||||
radieo is built as two layers, each running in its own Docker container and
|
||||
sharing a cache volume:
|
||||
- **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.
|
||||
- **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
|
||||
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
|
||||
## Getting started
|
||||
|
||||
Requirements: Docker with Compose v2.
|
||||
|
||||
```sh
|
||||
# Drop some .mp3 files into the cache directory
|
||||
cp /path/to/music/*.mp3 cache/
|
||||
### 1. Jingles (optional)
|
||||
|
||||
# Build and start the stream
|
||||
docker compose up -d
|
||||
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):
|
||||
|
||||
# Listen (VLC, a browser, any audio player)
|
||||
# http://localhost:8000/radio.mp3
|
||||
```
|
||||
| Folder | Plays around |
|
||||
| ----------------- | ------------ |
|
||||
| `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.
|
||||
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:
|
||||
### 2. Configure the sources (`.env`)
|
||||
|
||||
```sh
|
||||
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
|
||||
the stream plays whatever is already in `cache/` (the milestone-1/2 behaviour).
|
||||
Fill in `.env`:
|
||||
|
||||
For the yt-dlp source, list the URLs to draw from in `config/urls.txt` (copy
|
||||
`config/urls.txt.example`). Each line is either a direct track URL or a
|
||||
container URL (playlist, album, label, artist page) from which one track is
|
||||
picked at random.
|
||||
- **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`.
|
||||
|
||||
For the ListenBrainz source, set `RADIEO_LISTENBRAINZ_URL` to your
|
||||
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).
|
||||
### 3. yt-dlp URL list (`config/urls.txt`)
|
||||
|
||||
The relative mix between sources is set by `RADIEO_WEIGHT_NAVIDROME` /
|
||||
`RADIEO_WEIGHT_YTDLP` / `RADIEO_WEIGHT_LISTENBRAINZ` (a weight of 0 disables a
|
||||
source); an empty URL / missing file also disables the corresponding source.
|
||||
```sh
|
||||
cp config/urls.txt.example config/urls.txt
|
||||
```
|
||||
|
||||
Open the player at `http://localhost:8000/` — a small web page with an
|
||||
`<audio>` player and the current track (title/artist), which it refreshes from
|
||||
`GET /nowplaying`. The raw stream stays at `http://localhost:8000/radio.mp3`.
|
||||
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.
|
||||
|
||||
## 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
|
||||
`/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.
|
||||
```
|
||||
RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/<you>/recommendations/weekly-exploration
|
||||
```
|
||||
|
||||
**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
|
||||
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.
|
||||
### 5. Run it
|
||||
|
||||
The radio is feature-complete for personal use. A future nicety would be moving
|
||||
the source weights and settings from environment variables into a single config
|
||||
file.
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 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.
|
||||
2. ✅ **Ingestion daemon** — Python daemon exposing `GET /next`; Liquidsoap
|
||||
switches to a `request.dynamic` source with the cache as fallback.
|
||||
3. ✅ **Navidrome provider** — play from an OpenSubsonic playlist, with caching,
|
||||
LRU retention and play history.
|
||||
4. ✅ **yt-dlp provider** — fetch tracks from a maintained URL/artist list;
|
||||
weighted mixing between sources.
|
||||
5. ✅ **Canonicalizer** — MusicBrainz MBID lookup for source-agnostic
|
||||
de-duplication.
|
||||
6. ✅ **ListenBrainz provider** — parse the recommendations feed and resolve
|
||||
each suggestion to Navidrome or yt-dlp.
|
||||
7. ✅ **Polish** — crossfade, web player, quieter logs, robustness (graceful
|
||||
shutdown, healthcheck, HTTP retries). *(Config file: still env-based.)*
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue