A Qobuz login/metadata/artwork phase (and a Soulseek peer-queue or YouTube
info-fetch) can sit at 0% for a few minutes before byte progress flows, which
read as a stuck bar. While a downloading job's progress is still 0, render the
indeterminate "preparing…" bar (same style as the searching/finishing phases);
it flips to the determinate % bar on the first reported byte and never back
(the progress aggregator only increases from 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure the docs ahead of sharing the project for others to self-host. The
README is now a focused get-it-running path (overview, architecture, prereqs,
quick start, operational notes, security, backup/restore). Moved the fuller
feature tour, the maintainer pre-built-image release flow, and the Gluetun VPN
guide into a new docs/DEPLOYMENT.md, cross-linked both ways. The pre-built-image
section is now labelled a maintainer process with a LYRA_REGISTRY override so a
self-hoster can point at their own registry (or just build from source).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the /lastfm rows only showed a status chip; following an artist or
wanting an album meant opening the modal first. Add an inline Follow button to
each Artists-tab row and an inline Want button to each Albums-tab row, reusing
the same endpoints as the modals (resolve MB id/release-group as needed, then
POST /api/artists or /api/discover/want). Optimistic per-row state flips the
button to the Following/Wanted chip without a reload; the button is disabled
while the request is in flight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A track beyond the MusicBrainz tracklist (a Qobuz bonus track) kept streamrip's
raw name (e.g. "13. John Mayer - St. Patrick's Day (Album Version).flac") while
the main tracklist got clean "## Title" names. plan_track_names now canonicalizes
such extras: strip the leading track-number prefix and a leading "<artist> - ",
yielding "13 St. Patrick's Day (Album Version).flac". Requires an explicit
delimiter after the number so titles like "99 Luftballons" are left alone; with
no tracklist resolved at all the on-disk stem is still kept.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Soulseek's slskd search polls up to ~2 min; searching adapters sequentially
added that on top of Qobuz's instant search. Run the per-adapter searches
concurrently via a ThreadPoolExecutor so total match time is the slowest
single source, not their sum. Only Qobuz drives streamrip's shared asyncio
loop and there's one Qobuz adapter, so no two threads use that loop at once.
Results collected in adapter order (map preserves order) for deterministic
candidate ordering; per-adapter failures still swallowed (down source = no
candidates).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
streamrip reports progress only per concurrent track, so Job.downloadProgress
came solely from the file-count poller — 0% until every track landed, then a
jump to 100%. Patch streamrip.media.track.get_progress_callback to aggregate
per-track byte deltas over an estimated album total (avg started-track size ×
len(album.tracks)) into one smooth, generally-climbing 0..1 fraction routed
through the pipeline's on_progress. Capped at 0.99; the pipeline sets 1.0 on
completion. No signature changes — track count read from the resolved album.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Qobuz progress smoothing, parallel source searches, bonus-track filename
canonicalization. Lyra is live on the VM; these are worker-only polish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds scripts/build-push.sh (build + push lyra-web/lyra-worker :latest and
:<sha> to git.jger.nl) + a README 'Deploying to a server (pre-built images)'
section. The server pulls these via the vm-download ops repo (docker/lyra).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 4xx/5xx (rate limit / auth) now raises instead of parsing an error body;
callers already handle it (discovery per-source try/except; album fetch
returns []). Closes the last small deferred cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ListenBrainz surfaces producers/session players/band members (Max Martin,
Dominic Howard) that co-occur in listens but have no releases of their own —
they showed as bare rows with an empty page. _derive_albums now drops a
surfaced artist (deletes their pending suggestion) when their MB discography
has no core release-group (Album/Single/EP, no secondary type), catching both
empty discographies and soundtrack/production-only credits, while keeping real
singles/EP-only artists. On browse failure the suggestion is kept.
Test fake browser gains default_core so scoring tests keep surfacing candidates.
worker 249 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/discover/reset sets discover.resetRequested; the worker, at the
start of the next sweep, clears all PENDING suggestions + contributions +
seed throttles and nulls WatchedArtist.lastDiscoveredAt (keeping
followed/wanted/dismissed decisions), then regenerates everything with current
logic. A 'Reset' button on /discover (2-step confirm) triggers it.
worker 247, web 190 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Client filters the row's album thumbnails to full albums only (hides
singles/EPs left over from pre-albums-only sweeps).
- Worker: re-deriving an artist deletes its pending album suggestions that are
no longer picks (stale singles / now-owned), keeping wanted/dismissed rows,
so the suggestion set self-cleans instead of accumulating.
worker 246, web 189 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each suggested album is now a small horizontal item (30px cover + title/badge
on one line, like Recently Pressed but smaller) stacked vertically, so both
albums fit under each other without enlarging the row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/discover is now one list of artist rows (no Artists|Albums tabs). Each row
shows the artist (name links to the full page — ArtistModal dropped from
Discover), the 'Similar to' reason, and its newest/most-played album
thumbnails with badges. Album-click opens the AlbumModal (cover + tracklist +
Want). Find-similar results link to the full page too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/discover now returns rows[] grouped by artist, each carrying its album
suggestions (with albumReason) + the seed names, ordered by score. Album
suggestions whose artist has no pending artist suggestion still yield a row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_derive_albums now also picks the artist's most-played album (Last.fm,
matched into the browsed discography by MBID then normalized title),
alongside the newest. Same release -> one row tagged newest,popular.
albumReason persisted; run_discovery threads an optional popularity provider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LastfmAlbumPopularity.top_albums(conn, name) ranks an artist's albums by
Last.fm playcount (read-through ApiCache, 12h; errors -> []). registry
build_album_popularity returns it only when lastfm.api_key is set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design for collapsing the Artists|Albums tabs into one artist-centric row,
surfacing each similar artist's newest AND most-popular (Last.fm) album inline,
album-click → AlbumModal tracklist, artist name → full page (drop the Discover
ArtistModal). Approved design; implementation plan next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discovery seeded only from followed artists. Now it also seeds from artists
you own albums by (LibraryItem.artistMbid, captured since the MBID own-state
work) but don't follow — so your library shapes suggestions, not just your
follows.
- New DiscoverySeed throttle table (migration add_discovery_seed) mirroring
WatchedArtist.lastDiscoveredAt, so each library-derived seed is swept once
per interval (WatchedArtist seeds keep their own throttle).
- _seed_artists merges both seed sources least-recently-swept first, capped at
chunk_size; run_discovery stamps the right throttle per seed and evicts
contributions from seeds that are neither followed nor owned.
- Artists you already have (followed OR owned) are excluded from the suggestion
candidates — discovery surfaces only genuinely new artists.
- discover.seedFromLibrary config (default true) + a Discovery settings toggle.
NOTE: existing LibraryItem rows have null artistMbid (populated on new
import/scan), so a re-scan is needed before old albums seed discovery.
worker 236, web 187 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each suggestion now carries the followed artists (seeds) that surfaced it,
from DiscoverySeedContribution joined to the current watched roster (an
unfollowed seed drops out), highest-scoring seed first. /discover renders
"Similar to Arctic Monkeys, Coldplay +2" under each artist and album row —
both kinds key off the candidate's artistMbid, so an album inherits its
artist's seeds. web 187 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Own-state ("in library") relied on an exact artist-NAME match, so an owned
album whose name differed from the MB canonical (punctuation/locale/feat.)
showed as not-owned even when followed. Capture the MusicBrainz artist MBID
on LibraryItem and prefer it for matching.
- New LibraryItem.artistMbid (migration add_library_artist_mbid). Populated at
import (pipeline) + scan, both of which already hold the resolved target;
the pipeline also now stores rgMbid (it previously didn't). COALESCE on
conflict backfills MBIDs on re-scan without clobbering.
- /api/artists "in library, not followed" matches by artistMbid first, name
case-insensitively as fallback (rows predating the column). Library route
prefers the item's own artistMbid over the release join.
Last.fm/discover own-state still use name-match (they work) — a smaller
follow-up. worker 233, web 186 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The verify step checked track COUNT but not playtime, so a truncated file or
a short preview substituted for a real track could import as "complete". Add
a total-duration check: sum the staged audio's playtime (mutagen) and fail
the job when it falls below 85% of MusicBrainz's expected total
(target.total_duration_s, already resolved). Generous tolerance so
edition/encoding differences don't false-positive; bonus tracks (over-long)
always pass.
The duration reader is injected (measure_duration param, default the real
mutagen-based reader) so the fail/pass paths are unit-tested; it returns None
for the offline fakes (no real audio), skipping the check exactly like the
staged-count guard. worker 233 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_record_contributions now normalizes each source's top-N to [0,1] by its own
top score before summing across sources. ListenBrainz co-occurrence counts
(thousands) no longer drown Last.fm's 0–1 match scores, so a small-scale
source contributes real ranking signal instead of only adding candidates.
Discovery score assertions updated to the normalized scale (single-candidate
fakes normalize to 1.0); added a cross-source test showing a small-scale
source's candidate ranks equal to a large-scale one. min_score is now on the
[0, n_sources] scale (default 0.0 → unaffected). worker 231 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Select mode on /library: album cards become multi-select, with a bulk
bar to Delete selected (2-step confirm) or Ignore selected. POST
/api/library/bulk { action, ids } applies the op per id and reports ok/failed
counts.
Extracted the delete + ignore logic into lib/library-ops.ts
(removeLibraryItem, ignoreLibraryItem); the single DELETE route now reuses
removeLibraryItem. web 185 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Request.force (migration add_request_force): the pipeline's intake now
skips the "already in library" dedupe for a forced job, so an owned album is
re-downloaded. The import step still keeps the new copy only when it's higher
quality, so a forced upgrade can never downgrade what's on disk.
- Worker: _is_force_job → dedupe bypassed when Request.force.
- POST /api/library/[id]/upgrade finds the matching MonitoredRelease and
enqueues a force request (guards against stacking on an in-flight job).
- Library route exposes monitoredReleaseId; the album modal shows a
"Replace / upgrade" button when a release exists.
Worker + web tests added (pipeline force re-acquire; upgrade route).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/library/integrity stats every album folder on disk (via the /music
mount) and flags: missing folders, folders with no audio, zero-byte/
unreadable (truncated) files, and on-disk track count drift vs the names
captured at import/scan. Metadata-only (readdir + stat), so it runs only when
the user clicks "Check now" from a new Integrity section on /library; each
flagged album opens its modal to fix or delete. SectionHeader.note widened to
ReactNode to host the button. web 178 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/library/duplicates groups library items that look like the same
release held more than once — sharing a MusicBrainz release-group, or the
same artist + edition-normalized title (stripEditionQualifiers), unioned so
transitive matches collapse into one group. The (artist,album) unique
constraint rules out exact dupes, so these near-dupes are what's worth
flagging.
/library shows a "Possible duplicates" section; each entry opens the album
modal to edit or delete. web 175 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct an owned album's artist/album/year from the Library modal ("Edit
metadata"). PATCH /api/library/[id] renames the folder to the canonical
Artist/Album (Year) scheme on the /music mount, updates the LibraryItem,
and re-resolves the MusicBrainz release-group (cover art) when the
artist/album changed (best-effort — keeps the old rgMbid on an MB outage).
- New shared lib/library-path.ts (safeName, albumDir, yearFromPath); the
manual-import route now uses it so edits and imports name folders
identically.
- Library GET falls back to the folder's "(YYYY)" for the year when there's
no matching release, so edited/manual years show in the grid.
- Does NOT rewrite embedded file tags (a worker/mutagen job) — Lyra reads
from the folder + DB. Guards paths inside the library root; 409 on folder
or (artist,album) collision.
web 172 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add MonitoredRelease.ignored (migration add_release_ignored). The monitor's
enqueue_due skips ignored releases, and they drop off the Wanted list —
independent of `monitored`, so auto-monitor-future can't silently re-grab
an album the user chose to ignore.
- Worker enqueue_due: AND NOT mr.ignored.
- /api/releases/[id] PATCH now accepts `ignored`; /api/wanted filters it out;
artist detail exposes `ignored`.
- UI: Ignore button on Wanted rows; discography shows an "Ignored" badge with
an Ignore/Un-ignore toggle (monitor + search disabled while ignored).
Worker + web tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The library scan silently skipped album folders with no MusicBrainz match
(or an unreadable file). Persist them so they're visible and fixable.
- New UnmatchedAlbum model (migration add_unmatched_album), keyed by path.
- scan_chunk records a skipped album as unmatched (reason "no MusicBrainz
match" or "unreadable: <err>"), clears the row when an album later
resolves, and prunes stale rows (a prior scan's, or a removed folder)
when a scan completes.
- GET /api/library/unmatched lists them; DELETE ?id= dismisses one.
- /library shows a "Couldn't match" section (path + reason + Dismiss).
Worker + web tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Sort dropdown to /library next to the filter: Recently added
(default, importedAt desc — the prior order), Release date (album year,
newest first, unknown-year last), Album A–Z, Artist A–Z. Client-side sort
of the already-fetched albums; importedAt was already on the route, just
declared on the client type now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Sort dropdown to /artists next to the filter: Recently followed
(default, createdAt desc — the prior order), Name A–Z, Most in library,
Most monitored, Most releases. Client-side sort of the already-fetched
roster; the filter box and "In library, not followed" section are
unchanged. Exposes WatchedArtist.createdAt on /api/artists for the sort.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Suggested artists and Suggested albums were stacked sections; make them
tabs like /lastfm (.tabs bar + count badge). Discover-now/freshness and
Find-similar stay above the tabs.
The Albums tab filters to full-length albums (isFullAlbum) — a client-side
stopgap that hides any pre-D1 singles/EP suggestions still in the DB (new
sweeps already filter server-side via discover.albumsOnly).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discovery's "Suggested albums" surfaced singles and EPs because
_derive_albums filtered with is_core_release (Album/Single/EP). Add a
discovery-only _album_kind_ok gate that, when discover.albumsOnly (new
config, default true), requires primary_type == "Album" with no secondary
types. is_core_release is untouched — the monitor/scan still use it.
Exposed discover.albumsOnly in the discover config route + a Discovery
settings toggle. Worker + web tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Running behind a VPN (Gluetun)" README section: the whole-stack
network_mode: service:gluetun setup (DATABASE_URL @db -> @localhost, ports moved
to gluetun, FIREWALL_INPUT_PORTS + FIREWALL_OUTBOUND_SUBNETS), a compose sketch,
and caveats (Qobuz geo-sensitivity, the IPv6 sysctl not applying but harmless, MB
per-IP rate limit covered by the cache, bind mounts unaffected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A manually-imported album had no release-group MBID, so it showed the ◉
placeholder until a Scan matched it. Now the import resolves the release group
(searchReleaseGroup, via the shared MB cache) and stores it on the new
LibraryItem.rgMbid column (migration add_library_rg_mbid). The library route
prefers LibraryItem.rgMbid, falling back to the MonitoredRelease join for
scanned items — so a manual album gets cover art immediately. Best-effort: a
no-match / MB outage just leaves rgMbid null.
web 164 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Cover art now loads through GET /api/cover/[rgMbid], which fetches from the
Cover Art Archive once, caches the image on disk (LYRA_COVER_CACHE, default
/tmp/lyra-covers), and serves it with a 30-day Cache-Control — so the browser
stops re-fetching it on every Library / Recently-Pressed render. Missing covers
get a negative marker (re-checked daily). CoverArt points at the endpoint.
- Recently Pressed dedupes by artist+album (re-requesting/re-downloading an album
left multiple completed Requests showing the same album repeatedly); newest of
each is kept.
web 164 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real cause of the failed 584MB album upload: Next.js caps the request body
at 10MB for any route that passes through middleware, and the import route went
through the auth gate. Exclude /api/library/import from the middleware matcher so
the body streams uncapped to busboy, and authenticate it inline instead (new
lib/auth.isAuthed reads + verifies the session cookie from the request).
web 161 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual import used request.formData(), which buffers the whole body and caps out
around ~15MB — a real FLAC album is hundreds of MB (Hollywood's Bleeding is
584MB), so it failed with "expected multipart form data". Now the multipart body
is STREAMED with busboy: each file pipes straight to a hidden .import-<uuid>
staging dir on the library filesystem, then the dir is atomically renamed into
place. No full-body buffering, no size cap. Cleaned up on any failure; guards
(400 missing fields/audio, 409 exists) unchanged. web 161 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A real folder drag silently did nothing for a user (the logic is fine — a
simulated drop works — so a real drop was either erroring silently or not
firing). Harden it and give a guaranteed alternative:
- onDrop now captures the entries synchronously, wraps the async walk in
try/catch, and TOASTS on any failure or empty result (no more silent no-op).
- dragEnter preventDefault + dragover dropEffect="copy" for reliable drop-target
registration.
- New "Choose a folder" affordance (a webkitdirectory <input>) that sidesteps
drag entirely — pick the album folder and it collects the files + prefills
artist/album/year from the folder name. Plus "Choose files".
tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual import only accepted individual audio files — dragging a folder yielded
nothing (dataTransfer.files doesn't recurse). Now the drop zone walks the entry
tree via webkitGetAsEntry, so you can drag a whole album folder (the natural
shape of a ripped CD) and it collects the audio + cover inside. When a single
folder is dropped, artist/album/year are prefilled by parsing its name
("Artist - Album (Year)"), only filling fields you haven't typed into. Clicking
still opens the multi-file picker. No API change — same files reach the route.
web 161 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second Library Management feature. An "Add album" button on the Library page
opens a modal: drag-drop (or pick) the audio files (+ optional cover image),
enter artist/album/year, and Lyra writes the album into the library.
- POST /api/library/import (auth-gated, multipart): sanitizes the folder name
like the worker's safe_name, assembles into a sibling ".importing" temp dir
then renames into place (never a half-written album), writes a cover.jpg from
any image, and records a source="manual" LibraryItem with the on-disk
trackNames + a rough quality class from the extension (a later Scan re-probes /
MB-resolves for cover art + hi-res detection). Guards: 400 (missing
artist/album or no audio), 409 (folder exists / already in library).
- Library page: ImportAlbum drop-zone modal wired to refresh the grid.
web 161 tests (write-to-disk + trackNames + guards + filename sanitization).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Also: DELETE now prunes an emptied artist folder (best-effort).
First Library Management feature. On the Library album modal, "Delete album"
(two-step confirm) removes the album:
- deletes the folder on disk (guarded to only ever remove a path strictly inside
the library root, LYRA_LIBRARY_ROOT, default /music),
- drops the LibraryItem,
- sets the matching MonitoredRelease monitored=false + currentQualityClass=null
so the monitor won't immediately re-download it (re-Want later to restore).
Requires the web container to see the library, so docker-compose now bind-mounts
MUSIC_DIR at /music (rw) for web too. New DELETE /api/library/[id] (auth-gated,
path-guarded). web 157 tests (delete happy-path + 404 + outside-root guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completeness only compared the adapter's self-reported track_count to expected,
so a silently-skipped track (0-byte placeholder — see the Qobuz 24K Magic bug)
imported as a "complete" album. Now the verify also counts the NON-EMPTY audio
actually staged and fails "incomplete download" when fewer than expected are
present (guarded with `staged and ...` so fake adapters that stage nothing keep
using the count path). A real broken download now goes needs_attention (or falls
through to another source) instead of importing a dead track.
Full per-track duration matching against MB recording lengths (the rest of #16)
remains a larger follow-up. worker 224 tests / 7-skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docker-compose only health-checked db. Add:
- web: an unauthenticated GET /api/health (excluded from the auth middleware) +
a compose healthcheck that hits it (node global fetch — no curl/wget in image).
- worker: a background daemon thread touches /tmp/worker.alive every ~15s
(independent of the main loop, so it stays fresh through a long download) +
a compose healthcheck on the file's mtime (unhealthy if >90s stale).
Now `docker compose ps` reports real health for all services.
worker 223 tests, web 154, tsc + build + compose config clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>