Commit graph

55 commits

Author SHA1 Message Date
a1f7fc29b3 stream: name a yt-dlp track's provider from its source domain
All checks were successful
continuous-integration/drone/push Build is passing
yt-dlp pulls from many sites, so a fixed "YouTube" label was wrong for
bandcamp, soundcloud, etc. Derive the provider name from the source page's
host instead (www.youtube.com -> YouTube, *.bandcamp.com -> Bandcamp),
falling back to the bare host for unmapped sites. Other origins keep their
fixed PROVIDER_NAMES labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:39:12 +08:00
d302cf1c88 stream: scrobble listened tracks to ListenBrainz
All checks were successful
continuous-integration/drone/push Build is passing
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>
2026-07-04 17:51:41 +08:00
b04e717f40 docs: document the yt-dlp newest-releases boost
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:02:54 +08:00
112a4b0c89 ingest: bias yt-dlp picks towards a discography's newest releases
Some checks are pending
continuous-integration/drone/push Build is running
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>
2026-07-04 17:02:37 +08:00
49783218d8 ingest: rotate recently-played tracks oldest-first when anti-repeat is exhausted
When every candidate is within the anti-repeat window, the fallback replayed
at random, ignoring how long ago each was heard. With a small feed (or a
window larger than a source's pool) this is the *normal* path, and random
picking clusters the same tracks together.

Play the least-recently-heard candidate instead, so tracks rotate at the
widest spacing the pool allows.

- db: add last_played_at(keys) -> key -> most-recent play timestamp.
- providers/listenbrainz: sort the exhausted pool oldest-first.
- scheduler: on exhaustion, return the oldest-played of the drawn candidates
  rather than the last one drawn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:41:43 +08:00
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
8054c98dd1 stream: split radio.liq into pipeline, web and ingest-proxy parts
Extract the HTTP surface out of radio.liq into two included files: web.liq
(static assets, PWA, local player API) and ingest_proxy.liq (relays to the
ingest daemon). radio.liq keeps only the streaming pipeline and ends with the
%include directives, evaluated after the pipeline so the handlers see radio,
now_playing, history, etc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:11:56 +08:00
c73b71d32f stream: make the web player installable as a PWA
Wire up a web manifest, service worker and icon routes so the player can
be installed on mobile. The static manifest stays generic; the page
regenerates it at runtime from STATION_NAME so an instance keeps its name.
The service worker only caches the app shell, never the live stream or the
playback APIs. Icons (192/512, maskable and apple-touch) are rasterized
from the favicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:09:08 +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
ef1a19504e stream: add a synthwave CRT scan sweep behind the card
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:06:15 +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
d30f687185 stream: switch queue and history with glossy tabs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:18:00 +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
126cb8f8ac stream: show "Connexion en cours" until prefetch is known
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
96a1ba89e6 stream: add a whole-station restart of the current track
All checks were successful
continuous-integration/drone/push Build is passing
Wire the Media Session previous-track control to a new POST /restart-track
route that requeues the current song from the start for every listener, and
keep next-track as skip. Exposing both handlers also makes Android (Chrome)
show the skip button, which it hides when only nexttrack is set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:50:53 +08:00
622210197f stream: expose OS media controls via the Media Session API
All checks were successful
continuous-integration/drone/push Build is passing
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>
2026-07-03 19:17:20 +08:00
a1fed6b4e3 docs: rewrite README with features, setup and architecture
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:00:21 +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
85cd5d1b74 stream: show a random Navidrome-style background image
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
dcdfda2fdb stream: listen on IPv6 as well as IPv4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
534ade0ba5 stream: add a synthwave favicon
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +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
a65cc61ccd stream: play a scheduled jingle right after key times of day
Adds special jingle folders (midi, gouter, bisous) that take priority
over the default jingle rotation once per day, briefly after 11h00,
15h00, 16h30 and 21h00.

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
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