Compare commits
2 commits
85cd5d1b74
...
a1fed6b4e3
| Author | SHA1 | Date | |
|---|---|---|---|
| a1fed6b4e3 | |||
| 9cc2ede37d |
9 changed files with 168 additions and 171 deletions
14
.env.example
14
.env.example
|
|
@ -1,14 +1,14 @@
|
|||
# radieo — configuration locale. Copier en `.env` et remplir.
|
||||
# docker compose lit automatiquement `.env` pour ces variables.
|
||||
|
||||
# --- Source Navidrome / OpenSubsonic ---
|
||||
# --- Source OpenSubsonic (Navidrome, Gonic, Airsonic…) ---
|
||||
# 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/.
|
||||
RADIEO_NAVIDROME_URL=https://navidrome.example.org
|
||||
RADIEO_NAVIDROME_USER=monuser
|
||||
RADIEO_NAVIDROME_PASSWORD=monmotdepasse
|
||||
RADIEO_SUBSONIC_URL=https://subsonic.example.org
|
||||
RADIEO_SUBSONIC_USER=monuser
|
||||
RADIEO_SUBSONIC_PASSWORD=monmotdepasse
|
||||
# Nom OU identifiant de la playlist à diffuser.
|
||||
RADIEO_NAVIDROME_PLAYLIST=Radio
|
||||
RADIEO_SUBSONIC_PLAYLIST=Radio
|
||||
|
||||
# --- Source yt-dlp ---
|
||||
# La liste d'URL se met dans config/urls.txt (copier config/urls.txt.example).
|
||||
|
|
@ -18,12 +18,12 @@ RADIEO_NAVIDROME_PLAYLIST=Radio
|
|||
# Feed Atom de recommandations. URL http(s) du feed de syndication, ou chemin
|
||||
# local sous /config (ex. /config/recommendations.xml) pour tester. Vide = off.
|
||||
# ListenBrainz ne fait que *nommer* des morceaux : chacun est résolu vers
|
||||
# Navidrome puis, à défaut, yt-dlp.
|
||||
# la bibliothèque OpenSubsonic puis, à défaut, yt-dlp.
|
||||
RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/monuser/recommendations/weekly-exploration
|
||||
|
||||
# --- Dosage du mix entre sources (optionnel) ---
|
||||
# Poids relatifs de tirage de chaque source (0 désactive la source).
|
||||
RADIEO_WEIGHT_NAVIDROME=3
|
||||
RADIEO_WEIGHT_SUBSONIC=3
|
||||
RADIEO_WEIGHT_YTDLP=1
|
||||
RADIEO_WEIGHT_LISTENBRAINZ=2
|
||||
|
||||
|
|
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ services:
|
|||
- RADIEO_CACHE_DIR=/cache
|
||||
- RADIEO_STATE_DIR=/state
|
||||
- RADIEO_HTTP_PORT=8080
|
||||
# Source Navidrome / OpenSubsonic (voir .env / .env.example).
|
||||
# Source OpenSubsonic (Navidrome, Gonic, Airsonic… ; voir .env.example).
|
||||
# Laisser vide désactive la source : le stream joue alors son cache local.
|
||||
- RADIEO_NAVIDROME_URL=${RADIEO_NAVIDROME_URL:-}
|
||||
- RADIEO_NAVIDROME_USER=${RADIEO_NAVIDROME_USER:-}
|
||||
- RADIEO_NAVIDROME_PASSWORD=${RADIEO_NAVIDROME_PASSWORD:-}
|
||||
- RADIEO_NAVIDROME_PLAYLIST=${RADIEO_NAVIDROME_PLAYLIST:-}
|
||||
- RADIEO_SUBSONIC_URL=${RADIEO_SUBSONIC_URL:-}
|
||||
- RADIEO_SUBSONIC_USER=${RADIEO_SUBSONIC_USER:-}
|
||||
- RADIEO_SUBSONIC_PASSWORD=${RADIEO_SUBSONIC_PASSWORD:-}
|
||||
- RADIEO_SUBSONIC_PLAYLIST=${RADIEO_SUBSONIC_PLAYLIST:-}
|
||||
- RADIEO_RETENTION_KEEP=${RADIEO_RETENTION_KEEP:-20}
|
||||
# Source yt-dlp : liste d'URL dans config/urls.txt (créer depuis l'exemple).
|
||||
- RADIEO_YTDLP_URLS_FILE=/config/urls.txt
|
||||
|
|
@ -23,7 +23,7 @@ services:
|
|||
# sous /config, ex. /config/recommendations.xml). Vide désactive la source.
|
||||
- RADIEO_LISTENBRAINZ_URL=${RADIEO_LISTENBRAINZ_URL:-}
|
||||
# Dosage du mix entre les sources (0 désactive).
|
||||
- RADIEO_WEIGHT_NAVIDROME=${RADIEO_WEIGHT_NAVIDROME:-3}
|
||||
- RADIEO_WEIGHT_SUBSONIC=${RADIEO_WEIGHT_SUBSONIC:-3}
|
||||
- RADIEO_WEIGHT_YTDLP=${RADIEO_WEIGHT_YTDLP:-1}
|
||||
- RADIEO_WEIGHT_LISTENBRAINZ=${RADIEO_WEIGHT_LISTENBRAINZ:-2}
|
||||
# Canonicalizer MusicBrainz (identité MBID inter-sources ; sans clé).
|
||||
|
|
|
|||
|
|
@ -35,24 +35,24 @@ def _build_pipeline(db: Database, canonicalizer):
|
|||
fetchers = {} # backend name -> fetcher
|
||||
subsonic_client = None # reused by ListenBrainz for resolution
|
||||
|
||||
if config.NAVIDROME_ENABLED:
|
||||
if config.SUBSONIC_ENABLED:
|
||||
from .fetchers.subsonic import SubsonicFetcher
|
||||
from .providers.navidrome import NavidromeProvider
|
||||
from .providers.subsonic import SubsonicProvider
|
||||
from .subsonic import SubsonicClient
|
||||
|
||||
subsonic_client = SubsonicClient(
|
||||
config.NAVIDROME_URL, config.NAVIDROME_USER, config.NAVIDROME_PASSWORD
|
||||
config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD
|
||||
)
|
||||
provider = NavidromeProvider(subsonic_client, config.NAVIDROME_PLAYLIST, db)
|
||||
providers.append((provider, config.SOURCE_WEIGHTS.get("navidrome", 0)))
|
||||
provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db)
|
||||
providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0)))
|
||||
fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR)
|
||||
log.info(
|
||||
"Navidrome source enabled (playlist=%r, weight=%d)",
|
||||
config.NAVIDROME_PLAYLIST,
|
||||
config.SOURCE_WEIGHTS.get("navidrome", 0),
|
||||
"OpenSubsonic source enabled (playlist=%r, weight=%d)",
|
||||
config.SUBSONIC_PLAYLIST,
|
||||
config.SOURCE_WEIGHTS.get("subsonic", 0),
|
||||
)
|
||||
else:
|
||||
log.warning("Navidrome not configured (RADIEO_NAVIDROME_*): source off.")
|
||||
log.warning("OpenSubsonic not configured (RADIEO_SUBSONIC_*): source off.")
|
||||
|
||||
if config.SOURCE_WEIGHTS.get("ytdlp", 0) > 0 and config.YTDLP_URLS_FILE.exists():
|
||||
from .fetchers.ytdlp import YtdlpFetcher
|
||||
|
|
@ -77,7 +77,7 @@ def _build_pipeline(db: Database, canonicalizer):
|
|||
from .providers.listenbrainz import ListenBrainzProvider
|
||||
|
||||
# ListenBrainz has no backend of its own: it resolves each suggestion to
|
||||
# Navidrome then yt-dlp. Make sure the yt-dlp fetcher exists as a
|
||||
# the Subsonic library then yt-dlp. Make sure the yt-dlp fetcher exists as a
|
||||
# resolution target even when the yt-dlp *source* is off.
|
||||
if "ytdlp" not in fetchers:
|
||||
from .fetchers.ytdlp import YtdlpFetcher
|
||||
|
|
@ -94,7 +94,7 @@ def _build_pipeline(db: Database, canonicalizer):
|
|||
"ListenBrainz source enabled (feed=%s, weight=%d, resolve=%s)",
|
||||
config.LISTENBRAINZ_URL,
|
||||
config.SOURCE_WEIGHTS["listenbrainz"],
|
||||
"navidrome+ytdlp" if subsonic_client else "ytdlp",
|
||||
"subsonic+ytdlp" if subsonic_client else "ytdlp",
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
# Transport-level retries for transient connection errors on outgoing HTTP
|
||||
# (Navidrome, MusicBrainz, ListenBrainz). Applies to connect failures only.
|
||||
# (OpenSubsonic, MusicBrainz, ListenBrainz). Applies to connect failures only.
|
||||
HTTP_RETRIES = int(os.environ.get("RADIEO_HTTP_RETRIES", "2"))
|
||||
|
||||
# --- Prefetching / retention ---
|
||||
|
|
@ -49,18 +49,18 @@ USER_AGENT = os.environ.get(
|
|||
"RADIEO_USER_AGENT", "radieo/0.1 (personal music radio)"
|
||||
)
|
||||
|
||||
# --- Navidrome / OpenSubsonic source ---
|
||||
# --- OpenSubsonic source (Navidrome, Gonic, Airsonic…) ---
|
||||
# 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.
|
||||
NAVIDROME_URL = os.environ.get("RADIEO_NAVIDROME_URL", "").strip()
|
||||
NAVIDROME_USER = os.environ.get("RADIEO_NAVIDROME_USER", "").strip()
|
||||
NAVIDROME_PASSWORD = os.environ.get("RADIEO_NAVIDROME_PASSWORD", "")
|
||||
NAVIDROME_PLAYLIST = os.environ.get("RADIEO_NAVIDROME_PLAYLIST", "").strip()
|
||||
SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip()
|
||||
SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip()
|
||||
SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "")
|
||||
SUBSONIC_PLAYLIST = os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "").strip()
|
||||
# How often to reload the playlist contents, in seconds.
|
||||
PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300"))
|
||||
|
||||
NAVIDROME_ENABLED = bool(
|
||||
NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD and NAVIDROME_PLAYLIST
|
||||
SUBSONIC_ENABLED = bool(
|
||||
SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST
|
||||
)
|
||||
|
||||
# --- yt-dlp source ---
|
||||
|
|
@ -74,7 +74,7 @@ YTDLP_REFRESH = float(os.environ.get("RADIEO_YTDLP_REFRESH", "300"))
|
|||
# --- ListenBrainz suggestions source ---
|
||||
# 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.
|
||||
# ListenBrainz only *names* tracks; each is resolved to Navidrome then yt-dlp.
|
||||
# ListenBrainz only *names* tracks; each is resolved to the Subsonic library then yt-dlp.
|
||||
LISTENBRAINZ_URL = os.environ.get("RADIEO_LISTENBRAINZ_URL", "").strip()
|
||||
# How often to reload the feed, in seconds (it refreshes weekly).
|
||||
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
|
||||
# mix can later move to a config file. A weight of 0 disables a source.
|
||||
SOURCE_WEIGHTS = {
|
||||
"navidrome": int(os.environ.get("RADIEO_WEIGHT_NAVIDROME", "3")),
|
||||
"subsonic": int(os.environ.get("RADIEO_WEIGHT_SUBSONIC", "3")),
|
||||
"ytdlp": int(os.environ.get("RADIEO_WEIGHT_YTDLP", "1")),
|
||||
"listenbrainz": int(os.environ.get("RADIEO_WEIGHT_LISTENBRAINZ", "2")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class Track:
|
|||
locator: str # backend-specific: Subsonic song id, or a media URL
|
||||
artist: str
|
||||
title: str
|
||||
origin: str # provider that produced it, e.g. "navidrome"
|
||||
origin: str # provider that produced it, e.g. "subsonic"
|
||||
mbid: str | None = None # filled by the Canonicalizer (milestone 5)
|
||||
source_ext: str | None = None # filename hint, e.g. "mp3", "flac"
|
||||
source_url: str | None = None # container URL a track was picked from
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ Track directly, which gives a source-agnostic identity for free (no MusicBrainz
|
|||
lookup needed).
|
||||
|
||||
Unlike the other providers, ListenBrainz yields no audio of its own: it only
|
||||
*names* tracks. Each pick is therefore **resolved** to a concrete backend —
|
||||
Navidrome first (an OpenSubsonic ``search3``), then yt-dlp (a ``ytsearch1:``
|
||||
*names* tracks. Each pick is therefore **resolved** to a concrete backend — the
|
||||
OpenSubsonic library first (a ``search3``), then yt-dlp (a ``ytsearch1:``
|
||||
query) as a fallback — so the download layer is unchanged.
|
||||
|
||||
``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:
|
||||
artist, title, mbid = rec["artist"], rec["title"], rec["mbid"]
|
||||
|
||||
song = self._search_navidrome(artist, title)
|
||||
song = self._search_subsonic(artist, title)
|
||||
if song is not None:
|
||||
return Track(
|
||||
backend="subsonic",
|
||||
|
|
@ -137,13 +137,13 @@ class ListenBrainzProvider:
|
|||
)
|
||||
return None
|
||||
|
||||
def _search_navidrome(self, artist: str, title: str) -> dict | None:
|
||||
def _search_subsonic(self, artist: str, title: str) -> dict | None:
|
||||
if self._subsonic is None:
|
||||
return None
|
||||
try:
|
||||
songs = self._subsonic.search_songs(title)
|
||||
except (SubsonicError, httpx.HTTPError, OSError) as exc:
|
||||
log.warning("Navidrome search failed for %s — %s: %s", artist, title, exc)
|
||||
log.warning("Subsonic search failed for %s — %s: %s", artist, title, exc)
|
||||
return None
|
||||
want_a, want_t = norm_name(artist), norm_name(title)
|
||||
for s in songs:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""NavidromeProvider: picks tracks from an OpenSubsonic playlist.
|
||||
"""SubsonicProvider: 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
|
||||
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
|
||||
|
|
@ -18,11 +19,11 @@ from ..db import Database
|
|||
from ..models import Track
|
||||
from ..subsonic import SubsonicClient, SubsonicError
|
||||
|
||||
log = logging.getLogger("radieo.provider.navidrome")
|
||||
log = logging.getLogger("radieo.provider.subsonic")
|
||||
|
||||
|
||||
class NavidromeProvider:
|
||||
name = "navidrome"
|
||||
class SubsonicProvider:
|
||||
name = "subsonic"
|
||||
|
||||
def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database):
|
||||
self._client = client
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""Minimal OpenSubsonic client (enough for Navidrome playback).
|
||||
"""Minimal OpenSubsonic client (enough for playlist playback).
|
||||
|
||||
Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…).
|
||||
Uses salted-token authentication (``t = md5(password + salt)``), the scheme
|
||||
recommended by the Subsonic API since 1.13.0 and supported by Navidrome.
|
||||
recommended by the Subsonic API since 1.13.0.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
|
@ -105,7 +106,7 @@ class SubsonicClient:
|
|||
def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str:
|
||||
"""Download a song to ``dest``; return the file extension used.
|
||||
|
||||
``format=raw`` asks Navidrome for the original file (no transcoding),
|
||||
``format=raw`` asks the server for the original file (no transcoding),
|
||||
keeping quality and letting Liquidsoap decode it.
|
||||
"""
|
||||
params = {**self._auth_params(), "id": song_id, "format": "raw"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue