The web player decides when a track counts as listened (caught near its
start and heard to ~90%, capped at 4 min) and triggers POST /scrobble.
The token stays server-side (RADIEO_LISTENBRAINZ_TOKEN), submitting the
listen with the canonical MusicBrainz MBID when available. Each airing is
deduplicated so multiple tabs submit it once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bandcamp label/artist pages list releases newest-first, so give the first
YTDLP_RECENT_COUNT entries a YTDLP_RECENT_BOOST multiplier when picking,
leaning the radio towards fresh music. Flat extraction carries no dates, so
list position is used as the recency proxy rather than a real date lookup.
The boost is gated on the source URL being a bandcamp discography listing:
single /album/ and /track/ pages (whether a URL-file line or one the pick
recursed into) list tracks in track order, not by recency, and non-bandcamp
sources have unverified ordering, so all of those keep a uniform pick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RADIEO_SUBSONIC_PLAYLIST now accepts a comma-separated list of playlist
names or ids, each optionally weighted with a '=<number>' suffix (e.g.
'Chill=3, Focus, Party=2'); default weight is 1 and 0 disables an entry.
The provider caches each playlist independently and walks them in
weighted-random order on every pick, falling through past empty, renamed
or unreachable playlists so the source never stalls. Picking the
playlist first (by weight) then a song uniformly gives each its
configured share regardless of length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The player's "source" link only worked for direct yt-dlp URLs. Two other
cases had no linkable page: ListenBrainz picks resolved via ytsearch1: (the
locator is a search query) and Subsonic library tracks (an opaque song id).
Centralise the rule in Track.page_url and cover both: the yt-dlp fetcher now
records the concrete video URL it resolved into source_url, and a Subsonic
track links to the stream's new /share endpoint, which asks ingest to mint a
public share (createShare) on demand and redirects to it — so a share is only
created when a listener actually clicks, never per played track.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the web player into system media controls (MPRIS, Control Center,
lock screen) and keyboard media keys: push track metadata and handle
play/pause/next, with seek neutralized on the live stream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fallback played the whole /cache directory, which at cold start holds
only the 2-3 tracks being pre-fetched — so it looped them until the
request.dynamic buffer filled. Restrict the fallback to tracks already
aired: the ingest daemon exposes them at GET /fallback.m3u (played_at set,
still on disk), and the stream fetches that into a local /tmp/fallback.m3u
that playlist watches. Cold start is now silent (assumed) instead of a tight
loop, and a mid-stream drain degrades across the whole listening history.
A local file (not a remote playlist URL) is used to avoid Liquidsoap's http
resolver mis-sniffing the response as text/html; mime_type is forced so an
empty header-only m3u still parses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a third playback source: a ListenBrainz recommendations Atom feed. Each
suggestion already carries a MusicBrainz recording MBID, title and artist, so
it is keyed directly by MBID (source-agnostic identity, no extra lookup) and
resolved to a concrete file — Navidrome search3 first, then a yt-dlp
ytsearch1: fallback.
- providers/listenbrainz.py: parse the Atom/HTML feed, anti-repeat on the MBID
key, resolve Navidrome-then-yt-dlp. Feed may be an http(s) URL or a local
path (for testing).
- subsonic.py: add search_songs (search3) for resolution.
- canonicalizer.py: short-circuit when a Track already has an MBID, so
feed-provided MBIDs are trusted and MusicBrainz is not hit.
- __main__.py: wire the provider in; register the yt-dlp fetcher as a
resolution backend even when the yt-dlp source is off; close providers on
shutdown.
- config/compose/.env.example: RADIEO_LISTENBRAINZ_URL + weight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give tracks a source-agnostic identity so the same song from different
sources no longer replays in a loop.
- Canonicalizer resolves (artist, title) to a MusicBrainz recording MBID
(no API key; ~1 req/s, descriptive User-Agent, best-effort). Hits and
confirmed misses are cached in SQLite; transient errors are not.
- Track.key becomes mbid:<id> when resolved, else a normalized
name:<artist>|<title> fallback — still source-agnostic.
- Scheduler now owns the authoritative anti-repeat on the canonical key,
canonicalizing the drawn track with a bounded retry; providers keep a
cheap recent-locator filter to limit retries.
- db: canonical_cache table, history.locator column with migration for
existing databases, recent_locators().
- Canonicalization can be turned off via RADIEO_CANONICAL_ENABLED=0.
Verified: MBID hit/cache/miss, cross-source key collapse, scheduler
dodging a recent play, schema migration, and full stack (Navidrome +
yt-dlp) with zero Python tracebacks and a valid 192 kbps MP3 stream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a second playback source and a weighted scheduler mixing it with
Navidrome:
- Scheduler picks a provider by SOURCE_WEIGHTS, falling through to the
others when one has nothing ready, so no source can stall playback.
- YtdlpProvider reads a hand-maintained config/urls.txt; container URLs
(playlist/album/label/artist) are flat-extracted and one entry is
drawn at random, honouring the anti-repeat window. Adds Track.source_url.
- YtdlpFetcher downloads bestaudio via the yt-dlp library, reusing the
atomic hidden-temp-then-rename pattern; Liquidsoap decodes the result.
- Queue now dispatches to a fetcher registry keyed by backend.
- Sweep orphaned download temp files on daemon startup (leftovers from a
killed container otherwise pile up and trip the stream fallback).
Verified end-to-end: yt-dlp opus decoded and served as 192 kbps MP3, and
the 3:1 default mix observed in play history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the directory-scan queue with a real ingestion pipeline:
provider -> fetcher -> cache -> ready queue, driven by a background
prefetch thread.
- subsonic.py: minimal OpenSubsonic client (salted-token auth,
getPlaylists/getPlaylist, raw streaming download).
- providers/navidrome.py: pick tracks from a playlist (by name or id),
with anti-repeat and periodic playlist reload.
- fetchers/subsonic.py: atomic download into the shared cache.
- db.py: SQLite state — append-only play history (anti-repeat + stats)
and cache_files LRU retention (keep the N most recently played).
- queue.py: prefetch buffer + retention on play; graceful degradation
to the stream's local-cache fallback when no source is configured.
- api.py: GET /next now carries real title/artist metadata.
- Config via .env (Navidrome credentials), persistent state/ volume,
httpx dependency.
Verified end-to-end against a live Navidrome: playlist resolved,
tracks downloaded and broadcast, retention and history correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the Python `ingest` container exposing `GET /next`, which returns the
next track as an annotated Liquidsoap URI (or an empty body when nothing is
ready). Liquidsoap switches from a static playlist to a `request.dynamic`
source pulling from the daemon, with the local cache as fallback and mksafe
for guaranteed continuous output.
For now the daemon just cycles through the files already in the cache; the
download providers (Navidrome, yt-dlp, ListenBrainz) come in later milestones.
Also commit the implementation plan (PLAN.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Liquidsoap (v2.4.5) container that plays the /cache directory in random
order and broadcasts it over HTTP at :8000/radio.mp3 (MP3 192 kbps).
mksafe guarantees a continuous stream (silence when the cache is empty).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>