Commit graph

27 commits

Author SHA1 Message Date
fa1be6df77 stream: make the station name a single configurable variable
Default it to "Nemu FM" and derive the tab title, logo, and dynamic
document title from one STATION_NAME constant for easy renaming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:12:48 +08:00
6b52795ae1 stream: auto-reconnect to the live stream with exponential backoff
If the stream drops (server restart, network loss), the player now retries
goLive() on error/ended/stalled with a delay that doubles each failure, capped
at 30s, and resets once audio flows again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:09:39 +08:00
155a13d50e stream: remember the listener's volume via localStorage
Defaults to 40% on first visit, then restores whatever the user last set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:00:55 +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
aad3c9d0f7 stream: let listeners download any track still in the cache
All checks were successful
continuous-integration/drone/push Build is passing
/download now accepts ?file=<name> to fetch any cached file, not just the
current track. History entries carry that filename token (via /history), so
the web UI renders each aired track as a download link. A shared
serve_attachment helper validates the request (basename-only, real audio file,
no hidden/.part files) before streaming it; LRU-evicted tracks return 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
453ecd1353 stream: fix jingle counter for liquidsoap 2.4.5 (on_track is a method)
On the pinned savonet/liquidsoap:v2.4.5 image, `source.on_track` no longer
exists as a module function — on_track is a method on the source value. The
old `music = source.on_track(music, …)` form fails type-checking, so the whole
radio.liq is rejected and the stream container never starts. Register the
handler in place via the method form instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
3469b2680c stream: interleave a random jingle every two songs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
1f6937f22c stream: let the listener download the current track (/download)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
7fc372f18d stream: show a history of aired tracks (/history)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
a468d78153 stream: add a skip-to-next button and /skip route
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
04ea54c03e stream: show a copyable stream URL for external players
Add a read-only field with the bare /radio.mp3 URL and a copy button
(clipboard API, with a select+execCommand fallback), so the stream can
easily be opened in VLC or another external player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
c6d642a945 stream: reconnect the player to live instead of stale buffer
Firefox kept resuming the <audio> element from a stale buffer after a
pause, drifting behind the live point. Load the stream with an anti-cache
query parameter, and on resume-from-pause reconnect to live rather than
replaying the buffered audio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +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
3ff4e24872 ci: build ingest and stream images for amd64 and arm64
Add Drone pipelines building both container images per architecture and
merging them into multi-arch manifests under registry.nemunai.re/radieo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:15:25 +08:00
ca5cbc61df docs: describe milestone 7 (web player, robustness)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:47:20 +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
9e0f4c2066 compose: healthcheck on ingest, stream waits for it
Add a Docker healthcheck probing ingest's /healthz (via python, since the slim
image has neither curl nor wget) and make the stream start only once ingest is
service_healthy, so it never briefly falls back on an empty cache at boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:47:05 +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
f4eaf8e7d1 stream: web player with now-playing
Serve a small web page at http://<host>:8000/ (stream/index.html) from the
Liquidsoap harbor, alongside the /radio.mp3 stream. It shows the track
currently on air and refreshes it from a /nowplaying JSON endpoint, fed by the
broadcast source's live metadata — accurate even though the ingest daemon runs
a track ahead (prefetch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:46:37 +08:00
3bd7edbb16 stream: quieter fallback logs — skip non-audio files
The fallback playlist probed every file in /cache, including .gitkeep and
in-progress .part downloads, logging ffmpeg "Invalid data" warnings at startup.
Filter candidates with check_next to keep only real audio files and skip hidden
ones, removing the noise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:46:08 +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
8774f5c2a1 Add smooth crossfade transition between tracks
Insert a 3s crossfade after the fallback so it applies to both
daemon-driven transitions and cache fallbacks, before mksafe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:28:57 +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
29ab0be7cb Milestone 1: Liquidsoap broadcasting skeleton
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>
2026-07-02 16:14:14 +08:00