# 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 `=` 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//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 ) to scrobble what you listen to: ``` RADIEO_LISTENBRAINZ_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=` (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.