Compare commits

..

No commits in common. "a1fed6b4e38e7e7734418a139ea5674c65bb1203" and "85cd5d1b74dbf90795db638e6206caeb3f864d55" have entirely different histories.

9 changed files with 171 additions and 168 deletions

View file

@ -1,14 +1,14 @@
# radieo — configuration locale. Copier en `.env` et remplir. # radieo — configuration locale. Copier en `.env` et remplir.
# docker compose lit automatiquement `.env` pour ces variables. # docker compose lit automatiquement `.env` pour ces variables.
# --- Source OpenSubsonic (Navidrome, Gonic, Airsonic…) --- # --- Source Navidrome / OpenSubsonic ---
# URL de base de ton serveur (sans /rest). Laisser les champs vides désactive # URL de base de ton serveur (sans /rest). Laisser les champs vides désactive
# la source : le stream joue alors uniquement les fichiers déjà dans cache/. # la source : le stream joue alors uniquement les fichiers déjà dans cache/.
RADIEO_SUBSONIC_URL=https://subsonic.example.org RADIEO_NAVIDROME_URL=https://navidrome.example.org
RADIEO_SUBSONIC_USER=monuser RADIEO_NAVIDROME_USER=monuser
RADIEO_SUBSONIC_PASSWORD=monmotdepasse RADIEO_NAVIDROME_PASSWORD=monmotdepasse
# Nom OU identifiant de la playlist à diffuser. # Nom OU identifiant de la playlist à diffuser.
RADIEO_SUBSONIC_PLAYLIST=Radio RADIEO_NAVIDROME_PLAYLIST=Radio
# --- Source yt-dlp --- # --- Source yt-dlp ---
# La liste d'URL se met dans config/urls.txt (copier config/urls.txt.example). # La liste d'URL se met dans config/urls.txt (copier config/urls.txt.example).
@ -18,12 +18,12 @@ RADIEO_SUBSONIC_PLAYLIST=Radio
# Feed Atom de recommandations. URL http(s) du feed de syndication, ou chemin # Feed Atom de recommandations. URL http(s) du feed de syndication, ou chemin
# local sous /config (ex. /config/recommendations.xml) pour tester. Vide = off. # local sous /config (ex. /config/recommendations.xml) pour tester. Vide = off.
# ListenBrainz ne fait que *nommer* des morceaux : chacun est résolu vers # ListenBrainz ne fait que *nommer* des morceaux : chacun est résolu vers
# la bibliothèque OpenSubsonic puis, à défaut, yt-dlp. # Navidrome puis, à défaut, yt-dlp.
RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/monuser/recommendations/weekly-exploration RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/monuser/recommendations/weekly-exploration
# --- Dosage du mix entre sources (optionnel) --- # --- Dosage du mix entre sources (optionnel) ---
# Poids relatifs de tirage de chaque source (0 désactive la source). # Poids relatifs de tirage de chaque source (0 désactive la source).
RADIEO_WEIGHT_SUBSONIC=3 RADIEO_WEIGHT_NAVIDROME=3
RADIEO_WEIGHT_YTDLP=1 RADIEO_WEIGHT_YTDLP=1
RADIEO_WEIGHT_LISTENBRAINZ=2 RADIEO_WEIGHT_LISTENBRAINZ=2

227
README.md
View file

@ -7,140 +7,145 @@ 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.
## Features ## How it works
- **Always-on stream**: MP3 at 192 kbps over HTTP, several simultaneous radieo is built as two layers, each running in its own Docker container and
listeners. sharing a cache volume:
- **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.
## Getting started - **`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
Requirements: Docker with Compose v2. Requirements: Docker with Compose v2.
### 1. Jingles (optional) ```sh
# Drop some .mp3 files into the cache directory
cp /path/to/music/*.mp3 cache/
Drop `.mp3` files into `jingles/`: they rotate in every two songs. For # Build and start the stream
time-of-day jingles, add files to these subfolders (each played once per day docker compose up -d
just after its slot):
| Folder | Plays around | # Listen (VLC, a browser, any audio player)
| ----------------- | ------------ | # http://localhost:8000/radio.mp3
| `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. Stop it with `docker compose down`.
### 2. Configure the sources (`.env`) 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:
```sh ```sh
cp .env.example .env cp .env.example .env
# edit .env: RADIEO_NAVIDROME_URL / USER / PASSWORD / PLAYLIST
``` ```
Fill in `.env`: 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).
- **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the For the yt-dlp source, list the URLs to draw from in `config/urls.txt` (copy
playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with `config/urls.txt.example`). Each line is either a direct track URL or a
any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty container URL (playlist, album, label, artist page) from which one track is
to disable this source. picked at random.
- **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`) 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).
```sh The relative mix between sources is set by `RADIEO_WEIGHT_NAVIDROME` /
cp config/urls.txt.example config/urls.txt `RADIEO_WEIGHT_YTDLP` / `RADIEO_WEIGHT_LISTENBRAINZ` (a weight of 0 disables a
``` source); an empty URL / missing file also disables the corresponding source.
Add one URL per line: a single track, or a playlist/album/label/artist page to Open the player at `http://localhost:8000/` — a small web page with an
pick from. The file is mounted read-only, so you can edit it without rebuilding. `<audio>` player and the current track (title/artist), which it refreshes from
A missing file just disables the yt-dlp source. `GET /nowplaying`. The raw stream stays at `http://localhost:8000/radio.mp3`.
### 4. ListenBrainz suggestions ## Current status
Point `RADIEO_LISTENBRAINZ_URL` (in `.env`) at your recommendations syndication **Milestone 7 — polish: done.**
feed, e.g.:
``` - A small web player is served by Liquidsoap at `http://localhost:8000/`
RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/<you>/recommendations/weekly-exploration (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.
ListenBrainz only *names* tracks; each suggestion is resolved to a concrete file **Playback (milestones 36).**
(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 - 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.
```sh The radio is feature-complete for personal use. A future nicety would be moving
docker compose up -d the source weights and settings from environment variables into a single config
``` file.
Open the player at **`http://localhost:8000/`**; the raw stream is at ## Roadmap
`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 1. ✅ **Broadcasting skeleton** — Liquidsoap serving the cache directory.
of `stream/index.html`. 2. ✅ **Ingestion daemon** — Python daemon exposing `GET /next`; Liquidsoap
switches to a `request.dynamic` source with the cache as fallback.
## Architecture 3. ✅ **Navidrome provider** — play from an OpenSubsonic playlist, with caching,
LRU retention and play history.
radieo is two Docker containers sharing a cache volume. `ingest` is the brain; 4. ✅ **yt-dlp provider** — fetch tracks from a maintained URL/artist list;
`stream` is a deliberately dumb broadcaster. weighted mixing between sources.
5. ✅ **Canonicalizer** — MusicBrainz MBID lookup for source-agnostic
``` de-duplication.
Providers Scheduler Fetchers Broadcast 6. ✅ **ListenBrainz provider** — parse the recommendations feed and resolve
───────── ───────── ──────── ───────── each suggestion to Navidrome or yt-dlp.
Subsonic ──┐ ┌ Subsonic ┐ 7. ✅ **Polish** — crossfade, web player, quieter logs, robustness (graceful
yt-dlp ────┼─▶ weighted pick ─▶ Canonicalizer┤ ├▶ cache ─▶ queue ─▶ Liquidsoap ─▶ HTTP shutdown, healthcheck, HTTP retries). *(Config file: still env-based.)*
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.

View file

@ -10,12 +10,12 @@ services:
- RADIEO_CACHE_DIR=/cache - RADIEO_CACHE_DIR=/cache
- RADIEO_STATE_DIR=/state - RADIEO_STATE_DIR=/state
- RADIEO_HTTP_PORT=8080 - RADIEO_HTTP_PORT=8080
# Source OpenSubsonic (Navidrome, Gonic, Airsonic… ; voir .env.example). # Source Navidrome / OpenSubsonic (voir .env / .env.example).
# Laisser vide désactive la source : le stream joue alors son cache local. # Laisser vide désactive la source : le stream joue alors son cache local.
- RADIEO_SUBSONIC_URL=${RADIEO_SUBSONIC_URL:-} - RADIEO_NAVIDROME_URL=${RADIEO_NAVIDROME_URL:-}
- RADIEO_SUBSONIC_USER=${RADIEO_SUBSONIC_USER:-} - RADIEO_NAVIDROME_USER=${RADIEO_NAVIDROME_USER:-}
- RADIEO_SUBSONIC_PASSWORD=${RADIEO_SUBSONIC_PASSWORD:-} - RADIEO_NAVIDROME_PASSWORD=${RADIEO_NAVIDROME_PASSWORD:-}
- RADIEO_SUBSONIC_PLAYLIST=${RADIEO_SUBSONIC_PLAYLIST:-} - RADIEO_NAVIDROME_PLAYLIST=${RADIEO_NAVIDROME_PLAYLIST:-}
- RADIEO_RETENTION_KEEP=${RADIEO_RETENTION_KEEP:-20} - RADIEO_RETENTION_KEEP=${RADIEO_RETENTION_KEEP:-20}
# Source yt-dlp : liste d'URL dans config/urls.txt (créer depuis l'exemple). # Source yt-dlp : liste d'URL dans config/urls.txt (créer depuis l'exemple).
- RADIEO_YTDLP_URLS_FILE=/config/urls.txt - RADIEO_YTDLP_URLS_FILE=/config/urls.txt
@ -23,7 +23,7 @@ services:
# sous /config, ex. /config/recommendations.xml). Vide désactive la source. # sous /config, ex. /config/recommendations.xml). Vide désactive la source.
- RADIEO_LISTENBRAINZ_URL=${RADIEO_LISTENBRAINZ_URL:-} - RADIEO_LISTENBRAINZ_URL=${RADIEO_LISTENBRAINZ_URL:-}
# Dosage du mix entre les sources (0 désactive). # Dosage du mix entre les sources (0 désactive).
- RADIEO_WEIGHT_SUBSONIC=${RADIEO_WEIGHT_SUBSONIC:-3} - RADIEO_WEIGHT_NAVIDROME=${RADIEO_WEIGHT_NAVIDROME:-3}
- RADIEO_WEIGHT_YTDLP=${RADIEO_WEIGHT_YTDLP:-1} - RADIEO_WEIGHT_YTDLP=${RADIEO_WEIGHT_YTDLP:-1}
- RADIEO_WEIGHT_LISTENBRAINZ=${RADIEO_WEIGHT_LISTENBRAINZ:-2} - RADIEO_WEIGHT_LISTENBRAINZ=${RADIEO_WEIGHT_LISTENBRAINZ:-2}
# Canonicalizer MusicBrainz (identité MBID inter-sources ; sans clé). # Canonicalizer MusicBrainz (identité MBID inter-sources ; sans clé).

View file

@ -35,24 +35,24 @@ def _build_pipeline(db: Database, canonicalizer):
fetchers = {} # backend name -> fetcher fetchers = {} # backend name -> fetcher
subsonic_client = None # reused by ListenBrainz for resolution subsonic_client = None # reused by ListenBrainz for resolution
if config.SUBSONIC_ENABLED: if config.NAVIDROME_ENABLED:
from .fetchers.subsonic import SubsonicFetcher from .fetchers.subsonic import SubsonicFetcher
from .providers.subsonic import SubsonicProvider from .providers.navidrome import NavidromeProvider
from .subsonic import SubsonicClient from .subsonic import SubsonicClient
subsonic_client = SubsonicClient( subsonic_client = SubsonicClient(
config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD config.NAVIDROME_URL, config.NAVIDROME_USER, config.NAVIDROME_PASSWORD
) )
provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db) provider = NavidromeProvider(subsonic_client, config.NAVIDROME_PLAYLIST, db)
providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0))) providers.append((provider, config.SOURCE_WEIGHTS.get("navidrome", 0)))
fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR) fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR)
log.info( log.info(
"OpenSubsonic source enabled (playlist=%r, weight=%d)", "Navidrome source enabled (playlist=%r, weight=%d)",
config.SUBSONIC_PLAYLIST, config.NAVIDROME_PLAYLIST,
config.SOURCE_WEIGHTS.get("subsonic", 0), config.SOURCE_WEIGHTS.get("navidrome", 0),
) )
else: else:
log.warning("OpenSubsonic not configured (RADIEO_SUBSONIC_*): source off.") log.warning("Navidrome not configured (RADIEO_NAVIDROME_*): source off.")
if config.SOURCE_WEIGHTS.get("ytdlp", 0) > 0 and config.YTDLP_URLS_FILE.exists(): if config.SOURCE_WEIGHTS.get("ytdlp", 0) > 0 and config.YTDLP_URLS_FILE.exists():
from .fetchers.ytdlp import YtdlpFetcher from .fetchers.ytdlp import YtdlpFetcher
@ -77,7 +77,7 @@ def _build_pipeline(db: Database, canonicalizer):
from .providers.listenbrainz import ListenBrainzProvider from .providers.listenbrainz import ListenBrainzProvider
# ListenBrainz has no backend of its own: it resolves each suggestion to # ListenBrainz has no backend of its own: it resolves each suggestion to
# the Subsonic library then yt-dlp. Make sure the yt-dlp fetcher exists as a # Navidrome then yt-dlp. Make sure the yt-dlp fetcher exists as a
# resolution target even when the yt-dlp *source* is off. # resolution target even when the yt-dlp *source* is off.
if "ytdlp" not in fetchers: if "ytdlp" not in fetchers:
from .fetchers.ytdlp import YtdlpFetcher from .fetchers.ytdlp import YtdlpFetcher
@ -94,7 +94,7 @@ def _build_pipeline(db: Database, canonicalizer):
"ListenBrainz source enabled (feed=%s, weight=%d, resolve=%s)", "ListenBrainz source enabled (feed=%s, weight=%d, resolve=%s)",
config.LISTENBRAINZ_URL, config.LISTENBRAINZ_URL,
config.SOURCE_WEIGHTS["listenbrainz"], config.SOURCE_WEIGHTS["listenbrainz"],
"subsonic+ytdlp" if subsonic_client else "ytdlp", "navidrome+ytdlp" if subsonic_client else "ytdlp",
) )
else: else:
log.info( log.info(

View file

@ -19,7 +19,7 @@ HTTP_HOST = os.environ.get("RADIEO_HTTP_HOST", "0.0.0.0")
HTTP_PORT = int(os.environ.get("RADIEO_HTTP_PORT", "8080")) HTTP_PORT = int(os.environ.get("RADIEO_HTTP_PORT", "8080"))
# Transport-level retries for transient connection errors on outgoing HTTP # Transport-level retries for transient connection errors on outgoing HTTP
# (OpenSubsonic, MusicBrainz, ListenBrainz). Applies to connect failures only. # (Navidrome, MusicBrainz, ListenBrainz). Applies to connect failures only.
HTTP_RETRIES = int(os.environ.get("RADIEO_HTTP_RETRIES", "2")) HTTP_RETRIES = int(os.environ.get("RADIEO_HTTP_RETRIES", "2"))
# --- Prefetching / retention --- # --- Prefetching / retention ---
@ -49,18 +49,18 @@ USER_AGENT = os.environ.get(
"RADIEO_USER_AGENT", "radieo/0.1 (personal music radio)" "RADIEO_USER_AGENT", "radieo/0.1 (personal music radio)"
) )
# --- OpenSubsonic source (Navidrome, Gonic, Airsonic…) --- # --- Navidrome / OpenSubsonic source ---
# Left empty means the provider is disabled (the stream then plays its own # Left empty means the provider is disabled (the stream then plays its own
# local-cache fallback). Credentials are expected to come from a .env file. # local-cache fallback). Credentials are expected to come from a .env file.
SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip() NAVIDROME_URL = os.environ.get("RADIEO_NAVIDROME_URL", "").strip()
SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip() NAVIDROME_USER = os.environ.get("RADIEO_NAVIDROME_USER", "").strip()
SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "") NAVIDROME_PASSWORD = os.environ.get("RADIEO_NAVIDROME_PASSWORD", "")
SUBSONIC_PLAYLIST = os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "").strip() NAVIDROME_PLAYLIST = os.environ.get("RADIEO_NAVIDROME_PLAYLIST", "").strip()
# How often to reload the playlist contents, in seconds. # How often to reload the playlist contents, in seconds.
PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300")) PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300"))
SUBSONIC_ENABLED = bool( NAVIDROME_ENABLED = bool(
SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD and NAVIDROME_PLAYLIST
) )
# --- yt-dlp source --- # --- yt-dlp source ---
@ -74,7 +74,7 @@ YTDLP_REFRESH = float(os.environ.get("RADIEO_YTDLP_REFRESH", "300"))
# --- ListenBrainz suggestions source --- # --- ListenBrainz suggestions source ---
# Atom recommendations feed. May be an http(s) URL (the real syndication feed) # Atom recommendations feed. May be an http(s) URL (the real syndication feed)
# or a local file path (for testing with a saved sample). Empty disables it. # or a local file path (for testing with a saved sample). Empty disables it.
# ListenBrainz only *names* tracks; each is resolved to the Subsonic library then yt-dlp. # ListenBrainz only *names* tracks; each is resolved to Navidrome then yt-dlp.
LISTENBRAINZ_URL = os.environ.get("RADIEO_LISTENBRAINZ_URL", "").strip() LISTENBRAINZ_URL = os.environ.get("RADIEO_LISTENBRAINZ_URL", "").strip()
# How often to reload the feed, in seconds (it refreshes weekly). # How often to reload the feed, in seconds (it refreshes weekly).
LISTENBRAINZ_REFRESH = float(os.environ.get("RADIEO_LISTENBRAINZ_REFRESH", "3600")) LISTENBRAINZ_REFRESH = float(os.environ.get("RADIEO_LISTENBRAINZ_REFRESH", "3600"))
@ -84,7 +84,7 @@ LISTENBRAINZ_ENABLED = bool(LISTENBRAINZ_URL)
# Relative odds of drawing from each source. Isolated here on purpose so the # Relative odds of drawing from each source. Isolated here on purpose so the
# mix can later move to a config file. A weight of 0 disables a source. # mix can later move to a config file. A weight of 0 disables a source.
SOURCE_WEIGHTS = { SOURCE_WEIGHTS = {
"subsonic": int(os.environ.get("RADIEO_WEIGHT_SUBSONIC", "3")), "navidrome": int(os.environ.get("RADIEO_WEIGHT_NAVIDROME", "3")),
"ytdlp": int(os.environ.get("RADIEO_WEIGHT_YTDLP", "1")), "ytdlp": int(os.environ.get("RADIEO_WEIGHT_YTDLP", "1")),
"listenbrainz": int(os.environ.get("RADIEO_WEIGHT_LISTENBRAINZ", "2")), "listenbrainz": int(os.environ.get("RADIEO_WEIGHT_LISTENBRAINZ", "2")),
} }

View file

@ -25,7 +25,7 @@ class Track:
locator: str # backend-specific: Subsonic song id, or a media URL locator: str # backend-specific: Subsonic song id, or a media URL
artist: str artist: str
title: str title: str
origin: str # provider that produced it, e.g. "subsonic" origin: str # provider that produced it, e.g. "navidrome"
mbid: str | None = None # filled by the Canonicalizer (milestone 5) mbid: str | None = None # filled by the Canonicalizer (milestone 5)
source_ext: str | None = None # filename hint, e.g. "mp3", "flac" source_ext: str | None = None # filename hint, e.g. "mp3", "flac"
source_url: str | None = None # container URL a track was picked from source_url: str | None = None # container URL a track was picked from

View file

@ -11,8 +11,8 @@ Track directly, which gives a source-agnostic identity for free (no MusicBrainz
lookup needed). lookup needed).
Unlike the other providers, ListenBrainz yields no audio of its own: it only Unlike the other providers, ListenBrainz yields no audio of its own: it only
*names* tracks. Each pick is therefore **resolved** to a concrete backend the *names* tracks. Each pick is therefore **resolved** to a concrete backend
OpenSubsonic library first (a ``search3``), then yt-dlp (a ``ytsearch1:`` Navidrome first (an OpenSubsonic ``search3``), then yt-dlp (a ``ytsearch1:``
query) as a fallback so the download layer is unchanged. query) as a fallback so the download layer is unchanged.
``RADIEO_LISTENBRAINZ_URL`` may be an ``http(s)`` URL (the real syndication ``RADIEO_LISTENBRAINZ_URL`` may be an ``http(s)`` URL (the real syndication
@ -114,7 +114,7 @@ class ListenBrainzProvider:
def _resolve(self, rec: dict) -> Track | None: def _resolve(self, rec: dict) -> Track | None:
artist, title, mbid = rec["artist"], rec["title"], rec["mbid"] artist, title, mbid = rec["artist"], rec["title"], rec["mbid"]
song = self._search_subsonic(artist, title) song = self._search_navidrome(artist, title)
if song is not None: if song is not None:
return Track( return Track(
backend="subsonic", backend="subsonic",
@ -137,13 +137,13 @@ class ListenBrainzProvider:
) )
return None return None
def _search_subsonic(self, artist: str, title: str) -> dict | None: def _search_navidrome(self, artist: str, title: str) -> dict | None:
if self._subsonic is None: if self._subsonic is None:
return None return None
try: try:
songs = self._subsonic.search_songs(title) songs = self._subsonic.search_songs(title)
except (SubsonicError, httpx.HTTPError, OSError) as exc: except (SubsonicError, httpx.HTTPError, OSError) as exc:
log.warning("Subsonic search failed for %s%s: %s", artist, title, exc) log.warning("Navidrome search failed for %s%s: %s", artist, title, exc)
return None return None
want_a, want_t = norm_name(artist), norm_name(title) want_a, want_t = norm_name(artist), norm_name(title)
for s in songs: for s in songs:

View file

@ -1,6 +1,5 @@
"""SubsonicProvider: picks tracks from an OpenSubsonic playlist. """NavidromeProvider: picks tracks from an OpenSubsonic playlist.
Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic).
Emits ``subsonic`` tracks (locator = song id). The playlist is cached in Emits ``subsonic`` tracks (locator = song id). The playlist is cached in
memory and refreshed periodically. A cheap local anti-repeat filters out songs memory and refreshed periodically. A cheap local anti-repeat filters out songs
whose id was played recently; if that empties the pool (short playlist), the whose id was played recently; if that empties the pool (short playlist), the
@ -19,11 +18,11 @@ from ..db import Database
from ..models import Track from ..models import Track
from ..subsonic import SubsonicClient, SubsonicError from ..subsonic import SubsonicClient, SubsonicError
log = logging.getLogger("radieo.provider.subsonic") log = logging.getLogger("radieo.provider.navidrome")
class SubsonicProvider: class NavidromeProvider:
name = "subsonic" name = "navidrome"
def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database): def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database):
self._client = client self._client = client

View file

@ -1,8 +1,7 @@
"""Minimal OpenSubsonic client (enough for playlist playback). """Minimal OpenSubsonic client (enough for Navidrome playback).
Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic).
Uses salted-token authentication (``t = md5(password + salt)``), the scheme Uses salted-token authentication (``t = md5(password + salt)``), the scheme
recommended by the Subsonic API since 1.13.0. recommended by the Subsonic API since 1.13.0 and supported by Navidrome.
""" """
import hashlib import hashlib
@ -106,7 +105,7 @@ class SubsonicClient:
def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str: def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str:
"""Download a song to ``dest``; return the file extension used. """Download a song to ``dest``; return the file extension used.
``format=raw`` asks the server for the original file (no transcoding), ``format=raw`` asks Navidrome for the original file (no transcoding),
keeping quality and letting Liquidsoap decode it. keeping quality and letting Liquidsoap decode it.
""" """
params = {**self._auth_params(), "id": song_id, "format": "raw"} params = {**self._auth_params(), "id": song_id, "format": "raw"}