Commit Graph

270 Commits

Author SHA1 Message Date
Jonathan 48d5f287b2 style(discover): compact stacked album items in each row
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>
2026-07-15 00:00:37 +02:00
Jonathan 947c86bc8f feat(discover): unified artist rows with inline album thumbnails
/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>
2026-07-14 23:46:14 +02:00
Jonathan a8c1600111 fix(discover): dismissing an artist also dismisses its albums
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:43:30 +02:00
Jonathan 202fc0db3c feat(discover): group suggestions into artist rows
/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>
2026-07-14 23:41:56 +02:00
Jonathan e8fd4bb0a9 feat(discover): supply Last.fm popularity to the sweep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:40:33 +02:00
Jonathan 194b4911c7 feat(discover): derive newest + most-popular album per artist
_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>
2026-07-14 23:39:18 +02:00
Jonathan efdd592761 feat(discover): Last.fm album-popularity helper
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>
2026-07-14 23:36:41 +02:00
Jonathan a09093f798 feat(discover): add DiscoverySuggestion.albumReason
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:34:58 +02:00
Jonathan e74d1d9c7b docs: implementation plan for Discover unified rows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:33:04 +02:00
Jonathan 9dd235c2bc docs: spec for Discover unified rows + newest/most-popular albums
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>
2026-07-14 23:29:23 +02:00
Jonathan e04c1c9795 feat(discover): also seed discovery from owned-album artists
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>
2026-07-14 22:39:52 +02:00
Jonathan a47d37a787 feat(discover): show why each item is suggested
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>
2026-07-14 22:07:05 +02:00
Jonathan 18a22db7fb feat(library): match own-state on artist MBID, not just name
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>
2026-07-14 21:55:24 +02:00
Jonathan 1c1b98967a feat(pipeline): reject downloads far shorter than the MB total duration
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>
2026-07-14 21:49:15 +02:00
Jonathan 1943fe2bdf feat(discover): max-normalize similarity scores across sources
_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>
2026-07-14 21:43:02 +02:00
Jonathan 0f64e3c83a feat(library): bulk operations (delete / ignore)
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>
2026-07-14 21:37:01 +02:00
Jonathan 8c8a34e320 feat(library): replace/upgrade an owned album on demand
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>
2026-07-14 21:28:54 +02:00
Jonathan 7719b2a0d8 feat(library): on-demand integrity check
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>
2026-07-14 21:20:39 +02:00
Jonathan 6453cfd27b feat(library): surface possible duplicate albums
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>
2026-07-14 21:16:03 +02:00
Jonathan f78c88df3f feat(library): edit/fix album metadata
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>
2026-07-14 21:12:02 +02:00
Jonathan 855a0f15fe feat(library): ignore a release (do-not-monitor)
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>
2026-07-14 21:03:54 +02:00
Jonathan 6ca39859fa feat(library): surface albums the scan couldn't match
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>
2026-07-14 20:56:31 +02:00
Jonathan 9e5d5d3af4 feat(library): sort control on the pressings grid
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>
2026-07-14 20:49:43 +02:00
Jonathan 2c3d6e77d5 feat(artists): sort control on the roster
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>
2026-07-14 20:46:24 +02:00
Jonathan 72e9c2d47d feat(discover): Artists|Albums tabs on /discover
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>
2026-07-14 20:42:33 +02:00
Jonathan c9905937cc feat(discover): suggest full albums only (filter singles/EPs)
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>
2026-07-14 20:37:28 +02:00
Jonathan 7957c621cf docs: how to run Lyra behind a Gluetun VPN container
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>
2026-07-14 20:29:39 +02:00
Jonathan 0147975b14 feat(web): MB-resolve manual imports for instant cover art
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>
2026-07-14 20:05:59 +02:00
Jonathan d52517ef39 feat(web): cache cover art + dedupe Recently Pressed
- 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>
2026-07-14 20:03:36 +02:00
Jonathan 36a34181c9 fix(web): exclude large-upload route from auth middleware (10MB body cap)
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>
2026-07-14 14:41:06 +02:00
Jonathan ce628ca245 fix(web): stream large album uploads to disk (busboy) instead of buffering
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>
2026-07-14 14:37:25 +02:00
Jonathan a94ba97eee fix(web): make album-folder import robust + add a folder picker
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>
2026-07-14 14:26:53 +02:00
Jonathan 0a2a5cbdc4 feat(web): drop a whole album folder into manual import
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>
2026-07-14 14:15:39 +02:00
Jonathan 93ba9c7e16 feat(web): Library Management — manual album import (drag-and-drop)
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).
2026-07-14 14:03:35 +02:00
Jonathan 0d3fe8d907 feat(web): Library Management — delete an album (files + record)
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>
2026-07-14 13:45:57 +02:00
Jonathan 426b36775f fix(worker): reject a download with 0-byte/missing tracks (#16 partial)
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>
2026-07-14 13:36:11 +02:00
Jonathan eb51489ba9 feat(ops): web + worker container healthchecks (#20)
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>
2026-07-14 13:33:21 +02:00
Jonathan 7e5c083cef fix(web): surface a toast on failed mutations (#14)
Several client mutations either ignored a non-ok response (silent no-op) or
toasted success unconditionally even when the request failed. Sweep them so every
mutation gives feedback:

- artists: toggle auto-monitor, unfollow → error toast on failure.
- discography + wanted: toggle monitor / search-now → success or error toast.
- discover suggestion follow/want/dismiss → error toast (was toasting success
  even on failure).
- The Floor: queue request + retry → error toast on failure.
- artist-modal + preview follow/want, Last.fm want → error toast on failure
  (closes the deferred "want() shows no toast on non-ok" item).

web 153 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:25:31 +02:00
Jonathan bc42e546f0 fix(worker): raise http.client header limit so Qobuz tracks stop failing
Bruno Mars "24K Magic" track 1 repeatedly downloaded as a 0-byte file. The
worker log showed the real cause: streamrip's track download hit
HTTPException("got more than 100 headers") — Qobuz's CDN returns >100 response
headers for some tracks and Python's http.client rejects them at the default
_MAXHEADERS=100, so streamrip skips the track and leaves an empty placeholder.

- Raise http.client._MAXHEADERS to 1000 on import of the Qobuz adapter, so those
  responses parse and the track downloads.
- _count_staged_audio now skips 0-byte files (a failed-track placeholder is not a
  real track) — improves the download-progress estimate and stops counting dead
  tracks. (A verify-level "reject 0-byte tracks" safety net is a follow-up under
  #16 per-track duration matching — it needs the pipeline fakes to write real
  files, deferred to avoid disproportionate churn here.)

worker 223 tests / 7-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:17:20 +02:00
Jonathan 9a95c33b7e fix(worker): a corrupt/unreadable album no longer aborts the whole scan
Live-testing #9 surfaced a pre-existing gap: one truncated file mutagen chokes
on ("file said 4 bytes, read 0 bytes") raised out of _process_album and aborted
the entire library scan (worklist abandoned). Now scan_chunk catches a per-album
error, rolls back, logs it, counts it as skipped, marks the item done, and
continues — so a single bad file can't block the rest of the library.

worker tests: new case asserts a corrupt album is skipped while a good one still
imports (and captures its on-disk trackNames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:00:02 +02:00
Jonathan dcf0bb0696 feat: show real on-disk tracklist in the Library album modal (#9)
The Library modal showed MusicBrainz's canonical tracklist, hiding incomplete
downloads / edition or track-order mismatches, and showed nothing for albums
without an rgMbid. Now it shows the REAL files on disk.

- LibraryItem gains trackNames String[] (migration add_library_track_names):
  the album's on-disk "## Title.ext" audio filenames.
- Captured at import (pipeline._import lists the final album folder) and at scan
  (scan._record_owned now also refreshes trackNames on re-scan via ON CONFLICT
  DO UPDATE + xmax=0 to preserve the newly-imported flag). New shared
  library.list_audio_files() helper.
- /api/library exposes trackNames; the AlbumModal prefers on-disk tracks when
  present (parsed "## Title" → position/title, zero network, labeled "On disk ·
  N tracks"), falling back to the MusicBrainz listing otherwise. Items imported
  before this column show the MB fallback until re-scanned.

worker 221 tests / 7-skip, web 153, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:55:54 +02:00
Jonathan 746d61b264 feat(worker): reclaim in-flight jobs on startup (crash-resume)
A worker crash/restart mid-pipeline left its job stuck in matching/matched/
downloading/tagging with no auto-recovery — only the manual Retry (needs_attention
only) could rescue it. Now on startup the worker resets any such orphaned job
back to 'requested' so it gets re-claimed.

Safe at startup: this fresh worker owns none of those jobs, staging is already
cleared (clear_staging_root runs first), and the pipeline redoes intake→import
idempotently with an atomic import. Terminal jobs (imported/needs_attention) are
left untouched; claimedAt/error/downloadProgress are reset. Logged on startup.

worker 218 tests / 7-skip (in-flight→requested, terminal untouched, re-claimable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:37:16 +02:00
Jonathan 4144188a28 feat(web): "In library, not followed" section on the Artists page
Adds a section at the bottom of the Artists overview listing artists you own
music by (LibraryItem) but don't follow (WatchedArtist), each with a Follow
action — so you can start monitoring artists already in your library.

- /api/artists GET now also returns `ownedNotFollowed`: distinct LibraryItem
  artist names not matching the watched roster (case-insensitive; the known
  have-via-scan name-match caveat, #18).
- Follow resolves the library name → MBID (/api/mb/artists) then POST /api/artists
  with the canonical MB name (mirrors the Last.fm/discover follow flow); a
  no-match surfaces a toast. Followed names hide immediately via a session set,
  even if the MB canonical name differs from the library name.

web 153 tests (owned-not-followed + case-insensitive match asserted), tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:28:29 +02:00
Jonathan 1a612f1808 feat(web): settings helptext behind a "?" popup instead of inline paragraphs
The Qobuz/Monitor/Discovery help I just added was always-visible and verbose.
Replace each with a compact "?" button (new reusable HelpButton) that opens the
same content in a popup (reuses the Modal), keeping the settings tabs clean.

web tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:21:17 +02:00
Jonathan 24d4ba6098 feat(web): AlbumModal follow everywhere + Settings polish (#10)
Follow affordance in ALL album modals (user request): thread artistMbid through
the remaining callers — Library, Wanted, and artist Discography. /api/library
and /api/wanted now expose artistMbid (from the joined MonitoredRelease); the
artist-detail route already exposed mbid. So every album modal now links the
artist name to their page + shows a Follow/Following button.

Settings polish (#10):
- Qobuz tab: drop the email/password inputs (the engine prefers the more
  reliable user-id + auth-token path); a "Clear legacy login" affordance appears
  only when old email/password creds are still stored, and the PUT no longer
  sends them.
- Clear-button alignment fixed: .field label is a flex row and .field-grid
  bottom-aligns inputs, so a set-secret field (with its · set + Clear) lines up
  with a plain sibling field.
- Library tab gains a read-only Staging path panel (/staging, STAGING_DIR, why
  fast local disk, rebuild-to-change).
- Monitor + Discovery tabs gain grounded helptext (quality-class 1/2/3 cutoff,
  poll/retry/upgrade-window; sweep cadence + seeds-per-chunk + ListenBrainz URL).

web 152 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:09:52 +02:00
Jonathan 17bed331a9 feat(web): Follow button + clickable artist in album modals
AlbumModal showed the artist name as static text — no way to follow the artist
or jump to them. Adds a Follow affordance (todo #13):

- AlbumInfo gains optional `artistMbid`. When present, the modal title links the
  artist name to /discover/artist/{mbid} and shows a Follow/Following button that
  reflects live follow state.
- Follow state fetched via a new lightweight GET /api/artists?mbid= check
  (avoids pulling the full watched list + discography); Follow reuses
  POST /api/artists. Non-ok POST surfaces a toast (also chips at todo #14).
- Wired artistMbid in the three discovery contexts where following is valuable:
  Discover, Discover artist preview, and Last.fm. Library/discography callers
  pass none (already-followed artists) so the button stays hidden — graceful.

web 152 tests (the ?mbid= follow-state check asserted), tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:58:01 +02:00
Jonathan 938b8f32f9 feat(web): surface Discover freshness ("last discovered N ago") + true in-progress
/discover felt "stale until I hit Discover now": it showed the result string but
never WHEN the sweep last ran, and "Discovering…" ended the moment the worker
claimed the request (requested→false) rather than when the sweep finished.

- /api/discover/run GET now also returns `inProgress` (discover.inProgress) and
  `lastRunAt` (the discover.result Config row's updatedAt = last completion).
- The client treats requested||inProgress as "running", so "Discovering…"
  persists for the whole sweep, and polls until both clear before refreshing.
- The tool row shows "Last discovered {N ago} · {result}", a sweeping message
  while running, and a clear "Not yet run" empty state.

web 151 tests (inProgress + lastRunAt asserted), tsc + build clean. No worker
change (signals already written); NOT a page cache (would worsen staleness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:47:58 +02:00
Jonathan b1757b631c feat(worker): smooth download % from adapter byte-level progress
The Floor bar used a file-count poller that jumps 0→~99% for small/fast albums
(streamrip reports only 0/1). But yt-dlp and slskd already emit real byte-level
progress via the on_progress callback the pipeline was discarding
(lambda _pct: None).

- Thread a throttled on_progress → Job.downloadProgress (write on a >=1% move or
  every 0.5s; clamped 0..1; write failures swallowed). Called synchronously from
  adapter.download on the pipeline thread, so it reuses the main conn safely.
- The file-count poller stays as the FALLBACK: it skips writing while a
  `reports` flag is set (an adapter is driving real progress), and the flag is
  cleared per attempt so a fall-through to streamrip still gets the estimate.
- Smooth bars for YouTube + Soulseek now; Qobuz/streamrip keeps the file-count
  estimate until its per-track callback is hooked later.

worker 216 tests / 7-skip (on_progress write/throttle/clamp covered; existing
pipeline tests now exercise the fake's on_progress end-to-end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:41:51 +02:00
Jonathan c63f765c1f feat(web): cache Last.fm browse data + Refresh button (rides ApiCache)
/lastfm hit the Last.fm API live on every load / period switch / page. Cache the
raw Last.fm payload in the shared ApiCache table (12h TTL) while keeping the
own-state annotation (followed/owned/wanted) computed LIVE on top — so a follow
made after the payload was cached shows immediately.

- cached() gains a { force } option to bypass a fresh row (still serves stale if
  the forced fetch fails); CACHE_TTL.HOUR added.
- All three routes wrap their Last.fm call: lfm-top-artists / lfm-top-albums
  :{user}:{period}:{page}, lfm-artist-plays:{user}:{artist} (keys normalized).
- A "Refresh" button on /lastfm force-bypasses the cache (?refresh=1).
- Factored the copy-pasted getCreds/PERIODS/LIMIT into a route-only _creds.ts
  (closes the Last.fm-routes DRY todo #19) — prisma stays out of any client
  bundle since lib/lastfm.ts is imported by a client component.

web 150 tests (cache-hit + refresh + own-state-live asserted), tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:31:27 +02:00
Jonathan 034ae40084 feat(worker): share the MusicBrainz cache from scans/discovery
The worker's MB browser (discography + artist search, used by monitor sweeps,
library scans, and Last.fm discovery) re-fetched every time. Route it through
the same ApiCache table + logical keys the web app uses, so a discography one
process fetched warms the other, and repeat lookups stop hitting MB.

- apicache.py cached_api(): read-through, serve-stale-on-outage, never caches
  null, best-effort (a cache-DB error degrades to a live fetch, never breaks
  scan/sweep/discovery). Its own DEDICATED autocommit connection so it can't
  commit the worker's in-flight transaction, and autocommit avoids freezing
  Postgres now() (which would break TTL math).
- CachingMbBrowser decorates MusicBrainzBrowser, serializing to the SAME
  camelCase JSON shape the web writes (round-trip tested). registry.build_browser
  wraps it when a DSN is present; the Last.fm source's browser rides it too.
- Hourly prune drops ApiCache rows > 90d so the table stays bounded.
- worker 213 tests / 7-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:17:51 +02:00