Commit graph

20 commits

Author SHA1 Message Date
40061446c9 ingest: support multiple weighted OpenSubsonic playlists
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>
2026-07-04 16:30:21 +08:00
5dd50f37b7 ingest: weight yt-dlp sources with an optional WEIGHT:URL prefix
Source lines may now start with a numeric weight (e.g. 3:https://…) to
bias how often each is drawn. The prefix is unambiguous since a URL
scheme can never be numeric; unprefixed lines default to weight 1 and a
weight of 0 disables the line. Selection uses Efraimidis-Spirakis
weighted sampling without replacement, preserving the fall-through to
the next source when one fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:16:04 +08:00
976f009297 stream: let listeners remove a track from the queue
Some checks failed
continuous-integration/drone/push Build is failing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:18:58 +08:00
493e55ed18 stream: let listeners queue a yt-dlp URL on request
All checks were successful
continuous-integration/drone/push Build is passing
Add an input in the queue tab to enqueue a yt-dlp URL: a single track, or
a whole playlist/album. Requests are a priority lane in the ingest queue —
pop_next serves them before the auto radio, so the next /next plays the
request without cutting the current track. They download lazily (a few
ahead), so a large playlist queues instantly and bypasses anti-repeat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:18:06 +08:00
efd7307cc6 ingest: give every track a source link
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>
2026-07-04 11:42:29 +08:00
62302ac21d stream: show the queue of upcoming tracks (/queue)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:18:00 +08:00
bfa7cc1046 stream: show the source provider of the current track
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:18:00 +08:00
9cc2ede37d ingest: rename the Navidrome source to OpenSubsonic
The provider only uses standard OpenSubsonic endpoints (getPlaylists,
getPlaylist, search3, stream), so it works with any compatible server
(Navidrome, Gonic, Airsonic…), not just Navidrome.

BREAKING: environment variables are renamed
  RADIEO_NAVIDROME_URL/USER/PASSWORD/PLAYLIST -> RADIEO_SUBSONIC_*
  RADIEO_WEIGHT_NAVIDROME                     -> RADIEO_WEIGHT_SUBSONIC
Update your .env accordingly.

Internally the source key and provider are renamed navidrome -> subsonic,
aligning with the existing 'subsonic' backend and fetcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:59:22 +08:00
032a9b86b8 ingest: resolve bandcamp label sources down to individual tracks
A yt-dlp source pointing at a label/artist page flat-extracts to a mix of
/track/ and /album/ URLs. The provider used each verbatim as a locator, so an
/album/ URL was handed to the fetcher as if it were a track: yt-dlp then
(mis)downloaded the whole album into one file and tagged it from the
playlist-level info, which carries the album title and no artist — surfacing as
"Unknown artist" on the stream.

Drill picked container entries down to a single track before emitting a
locator, bounded by a small depth so nested containers (label -> album ->
track) resolve while a real track (which flat-extracts to just itself) is the
base case. Locators are now always downloadable tracks, so the existing
tag/fetch path and anti-repeat keying work as intended.

Also make guess_metadata trust the explicit artist tag over the "Artist -
Title" title split: some label uploads double the artist into the title
("Artist - Artist - Title"), which the blind last-" - " split mis-parsed. When
that artist prefixes the title we peel it off (repeatedly), falling back to the
split only when there is no artist tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
1648030eba stream: surface ingest prefetch progress in the player
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:22:53 +08:00
c12e522fee stream: link the now-playing title to its source page
For yt-dlp tracks the locator is the original web page, so pass it as a
url annotation, carry it through the stream metadata and history, and
turn the track title into a link back to that page (both live and in the
history). Subsonic ids are opaque and stay plain text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:14:50 +08:00
fbdb2d6bb3 ingest: tag bandcamp downloads and correct label-account artists
All checks were successful
continuous-integration/drone/push Build is passing
yt-dlp's flat extraction mis-attributes bandcamp uploads: on a label
account the artist becomes the label, and many files ship untagged. After
a download, guess (artist, title, album) from the richer download metadata
and the "Artist - Title" filename shape, confirm against MusicBrainz, and
write ID3 tags into the file. Existing tags are respected; MusicBrainz is
primary, filename parsing only a fallback.

The file's tags are the single source of truth: the provider reads them
back on the next pick, so the corrected identity (incl. MBID) is in place
before the scheduler keys and anti-repeat-checks the track, and cache
reuse inherits it too.

Fetchers now return (path, Track) so the corrected metadata flows back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:01:24 +08:00
80f27d2795 stream: fallback only replays already-aired tracks
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>
2026-07-03 10:15:25 +08:00
d486558883 ingest: retry transient HTTP connection errors
Give the outgoing HTTP clients (Navidrome, MusicBrainz, ListenBrainz) a
transport-level retry budget (RADIEO_HTTP_RETRIES, default 2) so a brief
connection blip on a remote endpoint no longer drops a fetch or a lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:47:13 +08:00
f578efa145 ingest: graceful shutdown on SIGTERM
Handle SIGTERM (and SIGINT) to stop the HTTP server cleanly instead of being
killed after Docker's grace period, so `docker compose down` returns in ~1s and
the queue, HTTP clients and database are closed properly. shutdown() runs on a
helper thread since it must not run on the serving thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:46:53 +08:00
66d93e5034 Milestone 6: ListenBrainz recommendations provider
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>
2026-07-02 19:14:47 +08:00
7e0f08b863 Milestone 5: MusicBrainz MBID canonicalizer
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>
2026-07-02 18:46:30 +08:00
d1db6a11d8 Milestone 4: yt-dlp provider and weighted source scheduler
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>
2026-07-02 17:58:24 +08:00
8c27498632 Milestone 3: Navidrome (OpenSubsonic) playback provider
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>
2026-07-02 17:57:38 +08:00
f8eb0655eb Milestone 2: ingestion daemon driving the stream
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>
2026-07-02 17:57:38 +08:00