101 Commits

Author SHA1 Message Date
Jonathan 67efdedb51 WIP: slskd download resume (idempotent enqueue) — INCOMPLETE, do not deploy
Adds the _needs_enqueue() helper (a file is (re)enqueued only if it has no
transfer yet or its last one ended Errored/Cancelled/Rejected). This is step 1
of making SlskdClient._download idempotent so a retry/hard-kill resumes instead
of re-downloading the whole album from scratch.

REMAINING (continue here):
1. Add SlskdClient._existing_states(username) -> {filename: state} (GET
   /api/v0/transfers/downloads/{username}, best-effort, {} on error).
2. In _download(): before the POST, compute
     existing = self._existing_states(username)
     to_enqueue = [f for f in files if _needs_enqueue(existing.get(f["filename"], ""))]
   POST only to_enqueue (skip the POST entirely when empty); poll loop unchanged.
   Effect: a completed prior copy is retrieved immediately; a partial one resumes.
3. Tests in test_slskd_cancel.py: resume-skips-completed (no POST, immediate
   retrieve) + resume-only-enqueues-missing (partial). Traced: the 3 existing
   download tests still pass (the extra pre-loop GET consumes poll0 and degrades
   gracefully) — but RUN the suite to confirm.
4. Merge to main, build+push worker image, deploy — but only AFTER the in-flight
   Drake "Scorpion" job (soulseekboy43) has imported, so the deploy's worker
   recreate doesn't interrupt it.

Deferred follow-up (bigger, needs candidate reconstruction): pipeline
peer-stickiness (reuse the persisted chosen candidate on retry instead of
re-searching) + cancel a reclaimed job's orphaned transfers at startup. That is
what fully stops the cross-peer duplicate the user saw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:47:35 +02:00
Jonathan 395944f3e1 fix(worker): scale slskd download backstop to album size (stop abandoning big albums)
The absolute download backstop was a flat _MAX_XFER_POLLS=1200 (~60min). A large
album (e.g. Drake "Scorpion", 521MB / 25 FLACs) from a slow-but-healthy serial
peer (~400KB/s, serving one file at a time) needs ~60-70min, so it was cut off
at 60min and — because an abandoned transfer is cancelled and re-enqueued from
scratch — re-downloaded from zero every attempt, never finishing and eventually
going needs_attention at the attempt cap.

Replace the flat cap with _max_polls(total_bytes): budget the backstop at a
conservative floor throughput (~64KB/s), clamped to [20min, 2h]. Scorpion now
gets the full 2h ceiling instead of 60min. The 90s stall timeout is unchanged,
so a truly dead/queued peer is still abandoned fast.

Follow-up (not in this change): resume across retries (skip files slskd already
completed) so an abandoned large download doesn't restart from zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:27:44 +02:00
Jonathan 10430037fe fix(worker): cap slskd ETA + rollback on progress-write failure (stop txn-abort job churn)
A near-stalled Soulseek peer drives the EWMA speed toward zero, so
_eta_seconds returned int(remaining/speed) values far past int4 max. Writing
that to Job.downloadEtaSeconds (an integer column) raised "integer out of
range", which aborted the SHARED pipeline connection's transaction. The
on_progress except clause logged but never rolled back, so every subsequent
query cascaded "current transaction is aborted" and the whole job failed and
requeued (9 such failures / 24h observed in prod).

- Cap _eta_seconds at ~100h (359999s), well inside int4.
- Roll back the shared conn in on_progress's except so a failed progress
  write can never poison the pipeline txn, honoring the existing docstring
  promise that "a progress write must never fail the download".
- Regression test for the near-stall cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:42:13 +02:00
Jonathan 01657d4e1b fix(web): order Recently Pressed by press time + cap at 10
Recently Pressed was derived from the request list (ordered by Request.createdAt), so
an album requested long ago but imported just now sorted to the bottom — freshly
pressed albums never appeared near the top ("new albums don't get added"). Order by
press time (job.updatedAt ≈ import time) instead, and show only the 10 most recent.
The "Pressed" stat tile and the "N in library" note still reflect the true total; the
per-row date label now shows when it was pressed, not requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:17:54 +02:00
Jonathan 0ad1fd4cd5 fix(worker): trust the requested artist when it's in the file path (stop MP3-over-FLAC)
Confidence scoring read the artist from the immediate folder as "Artist - Album", so
common FLAC rip layouts — artist in a parent folder ("Rihanna\(2009) Rated R\…") or
buried ("2009 - Rihanna - Rated R") — parsed with no/garbage artist and scored below
the 0.7 gate. Real FLACs got filtered out and a clean-named MP3 (0.88) won, so Lyra
downloaded lossy when lossless was available (Rihanna "Rated R": 8 FLACs all <0.5).

_parse_search_responses now takes the requested artist and, when it appears anywhere
in the candidate's path (via _path_matches), sets it as the candidate artist so
confidence reflects reality. Especially helps blocklisted-artist fallbacks, whose
album-only results come from varied folder structures. 337 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:05:38 +02:00
Jonathan 7ed8ca2848 fix(worker): prefer the original release-group over same-titled reissues
The real root cause of "Hybrid Theory" re-downloading forever: MB lists the 2000
original and a 2023 reissue as SEPARATE same-titled Album release-groups. They tie on
(artist, title, is_album), so _best_release_group kept whichever MB relevance-ordered
first — the 2023 reissue (13 tracks / ~52 min). Its inflated tracklist+runtime then
failed the completeness/duration checks against the standard 12-track album sources
deliver, looping endlessly. Add an earliest-first-release-date tie-break so the
original group wins. (Complements _pick_release, which handles reissue releases
*within* a group.) 335 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:32:25 +02:00
Jonathan 5f59283bf0 fix(worker): resolve MB to the original edition + loosen duration gate
Root cause of albums re-downloading forever despite completing (Linkin Park "Hybrid
Theory"): the MB resolver read the tracklist from an arbitrary releases[0], often a
later reissue/deluxe. HT resolved to a 2023 13-track reissue (~52 min); a complete
2000 12-track download (~38 min) then failed _download_problem's duration gate
(2267s < 2877s×0.85) and fell through peer after peer, never importing.

- _pick_release: choose the ORIGINAL standard edition (Official, earliest date)
  instead of releases[0], so completeness/duration are judged against the edition
  sources actually deliver.
- _DURATION_MIN_RATIO 0.85 -> 0.65: a source legitimately delivering a shorter
  edition than MB's release shouldn't be rejected; only genuinely truncated/preview
  downloads (a fraction of expected runtime) should be.

Together with the est-time peer ranking, this stops the download→reject→re-download
loop. 334 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:27:44 +02:00
Jonathan d2a86932e0 fix(worker): rank Soulseek peers by estimated transfer time, not raw speed
Peer selection ranked (free-slot, advertised-speed, short-queue), ignoring file
size. But Soulseek FLAC rips of one album vary ~2x in size (a 24-bit/bloated rip vs
a standard CD rip) and Lyra scores them the SAME quality class (the slskd search
exposes no bit-depth, so every FLAC is class 2). So a peer advertising high speed
but serving 40MB/track files would out-rank a smaller standard rip that actually
finishes sooner — then blow past the 60-min per-peer backstop, time out, fall
through, and restart at 0% (Linkin Park "Hybrid Theory" looping for hours).

Rank by estimated transfer time instead: album bytes ÷ advertised speed, after the
free-slot check. Prefers whichever peer FINISHES first — fast peers and/or smaller
standard-edition rips — with no quality loss (all class 2). Equal-size case still
reduces to speed order, so existing ranking behaviour is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:17:44 +02:00
Jonathan 73f1ae6e2f feat(web): order the In-queue tab next-up-first with a "Next up" marker
The queue tab showed newest-first (API returns createdAt desc) but the worker claims
the oldest queued job next (claim_next: ORDER BY createdAt ASC) — so the item that ran
next was at the BOTTOM. Sort the queue bucket ascending so it matches processing
order, and badge the first claimable row (oldest, not individually paused) as "Next up".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:56:42 +02:00
Jonathan c49cee9a20 feat: download ETA on the press bar from measured Soulseek throughput
Show a live "~Xm left" countdown on the download progress bar. The slskd download
loop already polls byte transfer every 3s; measure the actual throughput (EWMA-
smoothed bytes/sec), compute seconds-remaining from the album's total bytes, and
surface it. New nullable Job.downloadEtaSeconds (migration; web entrypoint runs
prisma migrate deploy), threaded through the on_progress callback (optional 2nd arg,
so other adapters/callers are unaffected — they report no ETA). API exposes it;
queue.tsx renders etaLabel() after the track count. Null when unknown (no sample yet
or a source that doesn't report bytes). Worker 330 tests, web 214 tests, both green;
verified live-rendered as "42% · 6/14 tracks · ~4m left".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:16:58 +02:00
Jonathan db8b43b0e8 feat(web): tabbed On-the-Press view + kebab menu for press actions
The press page interleaved active downloads, queued items, and errors in one long
list — hard to see what's actually pressing. Split the non-imported rows into three
tabs (Active / In queue / Errors) with live counts; the default tab follows the work
(Active if anything's running, else Queue, else Errors) until the user picks one.
Move the press-level actions (Pause/Resume, Clear queue, Stop now) off the toolbar
into a vertical "⋮" kebab menu on the right of the header — Clear queue disables when
the queue is empty, Stop now only shows while downloading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:41:11 +02:00
Jonathan 434cd2b22c feat(worker): recover blocklisted Soulseek searches via album/artist-only fallback
The Soulseek server silently drops searches containing certain blocklisted terms
(major-label artists / album titles — Adele, Rihanna, Beyoncé, "Lemonade", …),
returning 0 responses ("TimedOut"). The blocked term poisons the whole query even
though the OTHER field usually isn't blocked and the content is on the network
(e.g. searching "Beyoncé …" → 0, but "I Am… Sasha Fierce" alone → 250 with 136
real Beyoncé folders).

When the primary "{artist} {album}" search returns nothing, retry with each field
alone (normalized to fold accents/punctuation) and keep only results whose file
path still matches the dropped field (_keep_matching), so a same-titled album by a
different artist is never grabbed. Recovers blocklisted albums with a distinctive
title; safely yields nothing when the title is too generic (e.g. Adele "21") rather
than mis-grabbing. Fallback only fires on a 0-response primary, so the happy path is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:06:07 +02:00
Jonathan d7ea29a97a fix(worker): requeue jobs that fail on an unhandled pipeline exception
An unhandled exception in run_pipeline (e.g. a MusicBrainz TLS drop surfacing as
SSL: UNEXPECTED_EOF, after the MB retries are exhausted) left the job stranded in
its mid-pipeline 'matching' state: the except handler logged "pipeline failed" and
rolled back, but never reset the job. Since claim_next only picks 'requested', the
job was orphaned — stuck showing "Searching sources…" on the press until the next
worker restart's reclaim_stuck_jobs happened to sweep it up.

Add requeue_or_fail: on an unhandled failure, requeue to 'requested' for a bounded
retry (transient network blips clear on retry), then fall to 'needs_attention' at
the attempt cap (5) so a persistently-broken job stays visible instead of looping.
Mirrors reclaim_stuck_jobs (requeue) and pipeline._fail (needs_attention), and
resets Request.status to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:40:41 +02:00
Jonathan e8483ee22b fix(worker): match library ownership by rgMbid + retire stale failed attempts
Two related monitor bugs surfaced by owned albums that never leave the press:

1. Ownership was matched on (artist, album) strings, but MusicBrainz canonicalizes
   the artist on import ("Kanye West"->"Ye", "Ne-Yo"->"Ne‐Yo", "The Goo Goo Dolls"
   ->"Goo Goo Dolls"). The strings no longer matched, so reconcile/enqueue/backfill
   never recognized the album as owned — it stayed "wanted" and re-pressed every
   retry interval. Match on the release-group MBID when the library row has one
   (falling back to artist+album for scanned rows without an MBID) across
   _in_library_at_cutoff, fulfill_owned_releases, and reconcile.

2. Each failed attempt left a needs_attention Request behind, so a hard-to-match
   album piled up phantom "needs attention" cards on the press even after a later
   attempt imported it (e.g. Maroon 5 "V": 3 stale cards + 1 success). Retire prior
   failed attempts when a release is fulfilled or re-attempted (_retire_failed_attempts,
   monitor-owned requests only — manual requests have no monitoredReleaseId).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:04:31 +02:00
Jonathan ca5bbfa9da fix(worker): retry transient MusicBrainz failures with backoff
The matching stage makes up to three musicbrainzngs calls per job, none of
which retried. A dropped TLS connection (surfacing as NetworkError,
"<urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING]>") or a 503 rate-limit
(ResponseError) failed the entire pipeline for the job, which then re-queued
and re-ran from scratch — ~36% of jobs churned this way over a 6h window.

Wrap the three MB calls in _with_retry (4 attempts, 1s/2s/4s exponential
backoff), catching only the two transient musicbrainzngs error types so real
bugs still propagate. musicbrainzngs stays lazily imported to keep module
import offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:45:37 +02:00
Jonathan 675d964bbe fix(worker): smarter Soulseek peer selection + stall/progress-aware transfers
Draining the queue via Soulseek was stalling: slow peers (~100KB/s) never finished a
full FLAC album within the fixed 10-min timeout, got cancelled at ~89%, and the
fall-through restarted from scratch on the next peer — while single-song folders named
like the album were also being tried.

- Stall detection: abandon a peer with no byte progress for ~90s (queued/dead) instead
  of waiting the full timeout; a peer that IS progressing keeps its slot up to a 60-min
  backstop, so a slow-but-working transfer can finish.
- Rank search candidates by peer: free upload slot, then speed, then shortest queue.
- Drop candidates with far fewer tracks than the release (single-song folders) up front,
  so no download attempt is wasted on them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:54:14 +02:00
Jonathan 57f97e9838 feat(worker): defer Qobuz upgrades when it's gated instead of re-grabbing same quality
With qualityCutoff=3 (want hi-res), a Soulseek-grabbed CD copy stays below cutoff and
is re-attempted for upgrade. Without a guard, when Qobuz is gated by pacing those
attempts would (a) re-search every owned album each monitor cycle and (b) re-download
a same-quality Soulseek copy that keep-if-better discards — pure churn.

Add an upgrade guard: a >= CD-lossless copy can only be improved by hi-res Qobuz, so
when Qobuz is gated, skip the job WITHOUT searching (pre-search skip); and as a safety
net, skip the download when the best-ranked candidate can't beat the existing copy.
Force jobs always proceed. Extracted the shared keep-existing path into a helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:15:43 +02:00
Jonathan 1fc61a0f5a fix(worker): cancel abandoned slskd transfers + cap download fall-through
Two compounding causes of 'many downloads of one album from different sources' in
slskd: (1) the slskd client raised on timeout/error WITHOUT cancelling the transfer
it enqueued, so as the pipeline fell through to other peers each left a transfer
running; (2) the incomplete-download fall-through was uncapped, grinding through
every candidate of a popular album (~200 Soulseek peers), amplified now that Qobuz
pacing pushes most jobs to Soulseek.

- SlskdClient.download now cancels its enqueued transfers (DELETE) on any abandon.
- The download loop caps at _MAX_DOWNLOAD_ATTEMPTS (6) candidates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:03:05 +02:00
Jonathan 67f374c470 feat: Qobuz pacing budget gate (human-like volume + cadence)
Avoid re-triggering a USER_BLOCKED account by capping Qobuz to a human-looking
volume and cadence. A per-album gate (worker/lyra_worker/qobuz_gate.py) allows
Qobuz only within active hours, under today's cap (a warm-up ramp of a steady
cap), and past a randomized spacing since the last Qobuz download; the gap spreads
the day's budget across active hours with jitter. When the gate is closed the
pipeline drops Qobuz candidates so the job falls through to another source (and
monitored albums upgrade to Qobuz later); a Qobuz-only setup is never gated.
Config keys read live each loop; counters reset at the day boundary via the DB
clock. Tunable in Settings -> Qobuz -> Pacing (new /api/qobuz/pacing route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:45:56 +02:00
Jonathan d47ed12e8e feat(worker): fall through to the next source on an incomplete download
Completeness/duration verification was applied only to the first candidate that
downloaded successfully; if that copy was incomplete or too short the whole job
failed, even when a later-ranked source had a complete copy (e.g. John Mayer 'The
Search for Everything' — Qobuz reliably fails one hi-res track, so it never fell
back to the deluxe edition or Soulseek).

Extract the checks into a pure _download_problem() and run it INSIDE the download
loop: a candidate that downloads but fails verification now falls through to the
next-ranked source, and the job only fails (needs_attention) when every candidate
is incomplete — reporting the last shortfall reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:41:04 +02:00
Jonathan b0eec41823 fix(worker): judge download completeness by the source's edition, not MB's
The completeness + duration guards used MusicBrainz's target.track_count /
total_duration_s, but MB picks an arbitrary releases[0] that is often a
deluxe/expanded edition with more tracks (and runtime) than the standard album a
source legitimately delivers. So complete standard-edition downloads were falsely
rejected — The Script 'No Sound Without Silence' (11 vs MB 12) as 'incomplete
download', Skillet 'Unleashed' (12 vs MB's 20-track deluxe) as 'too short'.

Now completeness is judged against the chosen source's own track count
(winner.track_count): a truncated download (fewer files than the source promised)
still fails, and the duration expectation is scaled to the delivered edition's
size. A separate 'implausibly small vs MB' guard (source has <= half MB's tracks)
still rejects a lone-track 'full album' video. Genuine partials (e.g. John Mayer,
a Qobuz track failing mid-download) still correctly fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:22:01 +02:00
Jonathan 108114ffa3 fix(web): replace stray null byte with space in Recently-Pressed dedupe key
A literal NUL had crept into the `${r.artist} ${r.album}` dedupe key on the
Floor, making git treat queue.tsx as a binary file (unreviewable diffs). Restore
the intended space; behavior is unchanged (the key stays unique per album).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:44:22 +02:00
Jonathan 6451cf3a3c feat(web): Floor press + per-item controls (pause/resume/clear/stop/remove)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:33:44 +02:00
Jonathan 927266d042 fix(mb): rank release groups by credited artist, not title alone
The worker MB resolver picked release groups on (title_ratio, is_album)
and never scored the artist — even though resolve() knows the followed
artist. For a generic title, same-titled release groups by *different*
artists tie on title, and the is_album tiebreak (added in 06e7f91 for
Off the Wall) then actively preferred a foreign Album over the correct
release. Concretely, following Lake Street Dive's "Fun Machine" EP
resolved to an unrelated band "Bastards of Melody"'s same-titled Album,
which then propagated as the downloaded/imported artist.

Make credited-artist similarity the primary sort key, above title and
above is_album. It's a soft signal, never a hard filter, so credited-
name variations (feat., punctuation) still resolve; is_album now only
breaks ties within the same artist+title, preserving the Off the Wall
fix. Verified live: resolve("Lake Street Dive", "Fun Machine") now
returns the 6-track LSD EP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:32:12 +02:00
Jonathan c6945bd89e feat(web): inter-job download delay setting (Settings → Monitor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:24:42 +02:00
Jonathan 3f624c3395 feat(web): DELETE /api/requests/[id] to remove a queued item
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:20:16 +02:00
Jonathan ecec0b6073 test(pause-api): harden "reject in-flight" test with durability assertion
Strengthen test to prove the job's paused state remains unmodified when
pause/unpause is rejected for a downloading job. Tests now seeds paused=true,
attempts paused=false, verifies 400, and fetches from DB to assert paused is
still true — catching regressions that would mutate before the guard check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:18:03 +02:00
Jonathan b78867435a feat(web): per-item pause route for queued jobs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:14:45 +02:00
Jonathan 3d30e7dc8b feat(web): /api/floor pause/resume/clear/stop controls
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:09:45 +02:00
Jonathan 8c95ff735b feat(worker): pause gate, inter-job delay, and force-stop watcher in main loop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:06:40 +02:00
Jonathan bff590a00f feat(worker): claim_next skips per-item paused jobs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:01:38 +02:00
Jonathan 20869bdc13 feat(worker): floor pause/delay/kill-switch helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:57:34 +02:00
Jonathan 9c2495825f feat(db): add Job.paused column for per-item press pause
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:54:19 +02:00
Jonathan 878eac066e docs: implementation plan for Press controls + inter-job delay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:50:54 +02:00
Jonathan 603c62ef87 docs: design spec for Press controls + inter-job delay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:43:09 +02:00
Jonathan 06e7f91897 fix(mb): resolve same-named albums to the Album, not the Single
MusicBrainz search returns release-groups tied on title similarity in an
arbitrary order; `max(groups, key=_ratio)` kept the first, so Michael
Jackson's "Off the Wall" resolved to the 2-track *single* release-group
(Off the Wall / Get on the Floor) instead of the 10-track album. Because
plan_track_names labels files purely by position, that short tracklist got
mapped onto the full album — track 2 became "Get on the Floor" instead of
"Rock with You", and the real "Off the Wall"/"Get on the Floor" files kept
their streamrip names, yielding duplicate titles.

Extract selection into a pure `_best_release_group()` that breaks title-
similarity ties toward primary-type == "Album". Similarity still dominates;
album preference only settles ties. Live-verified: Off the Wall now resolves
to the 10-track album with track 2 = "Rock With You".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:49:26 +02:00
Jonathan 0443f10a3b feat(lastfm): disambiguation picker for same-named MB artists
Last.fm's artist MBID can point at the wrong same-named band (it mapped
"Looking Glass" to an Australian stoner band, not the US pop group), and the
/lastfm rows trusted it verbatim — so opening/following grabbed the wrong
artist and the grabbed album never showed on the artist page. Now open/follow
resolve via MB search: resolveArtistChoice keeps candidates whose name matches
exactly — 1 match is used directly (even over a disagreeing Last.fm MBID), 2+
surface an ArtistPicker showing each MB disambiguation ("70's US pop group,
track 'Brandy'" vs "Australian stoner rock") with MB's top hit flagged
Suggested. Core decision logic unit-tested; no worker/schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:43:54 +02:00
Jonathan a8ea3441fc feat(floor): show "preparing…" bar during the pre-download window
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>
2026-07-15 03:43:54 +02:00
Jonathan ccaaa41fec docs: slim README for self-hosting, move advanced topics to docs/DEPLOYMENT.md
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>
2026-07-15 02:57:45 +02:00
Jonathan b1f6408da6 feat(lastfm): inline Follow / Want buttons on the browse rows
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>
2026-07-15 02:39:13 +02:00
Jonathan 55cf88f66e fix(worker): canonicalize bonus-track filenames past the MB tracklist
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>
2026-07-15 02:37:08 +02:00
Jonathan 0c5e73eb17 fix(worker): parallelize per-source searches in the match phase
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>
2026-07-15 02:37:08 +02:00
Jonathan 0325467aba fix(worker): smooth Qobuz download progress via streamrip byte hook
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>
2026-07-15 02:37:08 +02:00
Jonathan c7bab8e997 docs: handoff plan for 3 post-deploy UX fixes
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>
2026-07-15 02:24:56 +02:00
Jonathan 53e7f0b7f2 build: script to publish web/worker images to the Gitea registry
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>
2026-07-15 01:06:46 +02:00
Jonathan f72d193ef7 chore(discover): raise_for_status on Last.fm fetches
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>
2026-07-15 00:50:35 +02:00
Jonathan 4a768b7122 fix(discover): drop suggestions for non-artists (no core discography)
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>
2026-07-15 00:38:56 +02:00
Jonathan 9915bfbda2 feat(discover): full reset — clear + rebuild suggestions
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>
2026-07-15 00:20:17 +02:00
Jonathan 7e27654b5a chore: remove accidentally-committed screenshot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:12:36 +02:00
Jonathan 288d55269d fix(discover): hide stale singles + replace album set on re-derive
- 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>
2026-07-15 00:10:10 +02:00
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
Jonathan bdbf9d237e feat(web): shared Postgres read-through cache for MusicBrainz (ApiCache)
Reopening an artist (discography), an album (tracklist), or repeating a lookup
re-hit MusicBrainz every time with no cache and no throttle. Adds a generic
Postgres-backed read-through cache keyed on the LOGICAL operation (not the
request URL) so the worker and web app share entries.

- New ApiCache(key, json, fetchedAt) model + migration (generic name so the
  Last.fm browse cache can ride the same table).
- lib/apicache.ts cached(): read-through, TTL from fetchedAt (tunable, no
  migration), serve-stale-on-outage, never caches null.
- Wrap the high-level MB fns with logical keys + TTLs: discography &
  tracklists & artist-name 30d, searchReleaseGroup 7d, searchArtists 1d.
  Search keys normalized (trim+lowercase) to match the worker byte-for-byte.
- album-modal: a session-scoped Map<rgMbid,Track[]> so reopening is instant
  with zero network (complements the DB cache).
- Test setup clears ApiCache between tests. web 147 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:17:51 +02:00
152 changed files with 10007 additions and 692 deletions
+1
View File
@@ -42,3 +42,4 @@ Thumbs.db
*.swp
slskd-downloads/
backups/
.playwright-mcp/
+86 -84
View File
@@ -7,83 +7,54 @@ sources of differing quality: **Qobuz** (lossless/hi-res), **Soulseek** (P2P), a
**ListenBrainz** and **Last.fm** (Spotify's recommendation/related-artists APIs were
retired for new apps, so discovery is MetaBrainz-native and MBID-clean — no Spotify).
## Status
It runs on your own server as a small `docker compose` stack. This README covers getting
it running; see **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** for the full feature tour,
publishing pre-built images, and running behind a VPN.
Built in three slices:
## What it does
1. **Acquisition engine****done**. Resolve a request against MusicBrainz,
match/rank across Qobuz/Soulseek/YouTube, download the best source into an
isolated staging dir, verify completeness, tag (mutagen), and atomically promote
the clean album into `Artist/Album (Year)/## Title.ext`.
2. **Library manager** — ✅ **done**. Watched artists (live MB search + follow),
per-release monitor toggles + auto-monitor-future, a wanted list with retry +
quality-upgrade window, and a worker "monitor" that discovers new releases and
auto-grabs through the acquisition pipeline. Plus a **library scan** that ingests
existing on-disk albums as have + monitored + followed, and a per-artist
studio-only / show-all release-type filter.
3. **Discovery** — ✅ **done**. Recommendation-driven finding of new music via
**ListenBrainz** similar-artists and **Last.fm** `artist.getSimilar` (both
MBID-native), behind a pluggable `SimilaritySource` interface — Last.fm registers
as a second source automatically once its API key is set, and cross-source scores
are summed per candidate. A background sweep (and a "Discover now" button)
aggregates artists similar to the ones you follow into a manual review feed of
suggested **artists** and **albums** at `/discover`; **Follow** and **Want**
reuse the slice-2 follow / wanted flows, **Dismiss** is permanent. Plus a
web-side seed-search box. Every suggestion and seed-search result opens a
read-only **artist preview** (`/discover/artist/[mbid]`) — discography,
per-album tracklists, and "in library" / "monitored" badges — where you can
Follow or Want before committing. No Spotify (its recommendation APIs are dead
for new apps).
- **Acquisition** — resolve a request against MusicBrainz, match/rank across
Qobuz/Soulseek/YouTube, download the best source, verify completeness, tag, and organize
into `Artist/Album (Year)/## Title.ext`.
- **Library manager** — follow artists (live MB search), per-release monitor toggles, a
wanted list with retry + quality-upgrade, a background monitor that auto-grabs new
releases, and a scan that ingests albums already on disk.
- **Discovery** (`/discover`) — recommendation-driven suggestions from ListenBrainz +
Last.fm similar-artists, with Follow / Want / Dismiss and a read-only artist preview.
- **Browse & watch** — Last.fm top artists/albums (`/lastfm`, with inline Follow/Want), a
cover-art **Library** grid (`/library`), and **The Floor** (home): a live queue of
in-flight acquisitions with three-phase progress and a Retry button.
Alongside the three slices:
- **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period),
with a "hide items already in my library" toggle; each artist opens a modal of your
most-played songs and albums, and every album is one click to **Want**.
- **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each
album opening a tracklist modal.
- **The Floor** (home) — a live queue of in-flight acquisitions with three-phase
progress (**Searching → Downloading → Finishing**), a live download percentage, a
per-job "pressing ticket" (source · format · tracks · elapsed), and a **Retry**
button on anything that needs attention.
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted
Fraunces serif for prose vs. system mono for machine data, light/dark aware).
**Operational notes:** the monitor and the discovery sweep are both **off by
default** — set `monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*`
tuning) to activate auto-grab/upgrade, and `discover.enabled=true` (or
Settings → Discovery, + optional `discover.*` tuning) to activate the scheduled
discovery sweep. The `/discover` "Discover now" button and seed-search work with the
sweep off. Credentials (Qobuz, Soulseek, Last.fm) are entered in Settings and persist
across `docker compose up -d --build` rebuilds (don't use `down -v`; secrets are
encrypted at rest with `LYRA_SECRET_KEY`). Tests run against a separate `lyra_test`
database (a guard blocks the live DB). Deferred hardening + the full roadmap live in
the implementation-plan "Notes" sections under
[`docs/superpowers/plans/`](docs/superpowers/plans/).
Full detail on each of these is in [docs/DEPLOYMENT.md → Features in depth](docs/DEPLOYMENT.md#features-in-depth).
## Architecture
The `docker compose` stack is three services on a home server:
The `docker compose` stack is a few services on one host:
- **db** — Postgres, the shared source of truth.
- **db** — Postgres, the shared source of truth (all durable state lives here).
- **web** — the Next.js app: UI + API (search, request, queue, progress, settings).
- **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd
client, mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery),
running the six-stage acquisition pipeline plus the background monitor and discovery
sweeps.
- **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd client,
mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery), running the
acquisition pipeline plus the background monitor and discovery sweeps.
- **db-backup** — a sidecar that `pg_dump`s the database on a schedule (see [Backup &
restore](#backup--restore)).
Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless
Soulseek client) — the worker reaches it via the `slskd.url` + `slskd.api_key`
credentials set in Settings, so run slskd separately rather than as part of this stack.
Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless Soulseek
client) — the worker reaches it via the `slskd.url` + `slskd.api_key` credentials set in
Settings, so run slskd separately rather than as part of this stack.
A shared `/music` volume is the library, with a separate `/staging` volume for
in-progress downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a
network share).
A shared `/music` volume is the library, with a separate `/staging` volume for in-progress
downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a network share).
See [`docs/superpowers/specs/`](docs/superpowers/specs/) for the full design.
## Prerequisites
## Running it
- **Docker** + **Docker Compose**.
- A directory for the music library (set `MUSIC_DIR`).
- Optional, entered in **Settings** after first run: a **Qobuz** account (lossless/hi-res),
a running **slskd** daemon (Soulseek), and a **Last.fm** API key (browse + discovery).
None are required to start — Lyra runs with whatever you configure.
## Quick start
```sh
cp .env.example .env # then edit: set LYRA_SECRET_KEY (openssl rand -base64 32) and MUSIC_DIR
@@ -97,10 +68,52 @@ the placeholder. Once set, keep it stable: changing it makes already-stored cred
undecryptable (you'd re-enter them in Settings). Optionally set `LYRA_PASSWORD` too (see
[Security](#security)).
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes
reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in
**Settings** (they persist across rebuilds), then follow an artist or request an album
to feed The Floor.
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes reach
the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they
persist across rebuilds), then follow an artist or request an album to feed The Floor.
`.env.example` documents every setting (`MUSIC_DIR`, `STAGING_DIR`, `SLSKD_DOWNLOADS_DIR`,
backup knobs). Note in particular: point `SLSKD_DOWNLOADS_DIR` at slskd's own downloads
directory (Lyra must run on the same host as slskd for Soulseek delivery to work), and point
`STAGING_DIR` at a fast local disk if `MUSIC_DIR` is a network share.
## Operational notes
- The **monitor** and the **discovery sweep** are both **off by default** — set
`monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*` tuning) to activate
auto-grab/upgrade, and `discover.enabled=true` (or Settings → Discovery, + optional
`discover.*` tuning) to activate the scheduled discovery sweep. The `/discover` "Discover
now" button and seed-search work with the sweep off.
- **Credentials persist** across `docker compose up -d --build` rebuilds — don't use
`down -v` (it wipes the Postgres volume); secrets are encrypted at rest with
`LYRA_SECRET_KEY`.
- **Tests** run against a separate `lyra_test` database (a guard blocks the live DB).
## Managing the library
Open an album on the **Library** page and use **Delete album** to remove it: the folder is
deleted from disk and the album is dropped and un-monitored (so auto-monitor won't re-grab
it — re-Want it from the artist page if you change your mind). This is why the `web` service
also bind-mounts `MUSIC_DIR` at `/music` (read-write) — deletion is guarded to only ever
remove folders strictly inside the library root.
## Security
Lyra has **no per-user accounts**. Two layers protect it:
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
password for the entire UI and API. A request without a valid session cookie is
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
can browse the library, trigger downloads, and read/overwrite the stored credentials in
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
- **Network.** The shared password is deliberately minimal (single password, bearer
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
alone. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#running-behind-a-vpn-gluetun) for a
Gluetun VPN setup.
## Backup & restore
@@ -132,19 +145,8 @@ docker compose start web worker
is safe. To restore onto a brand-new host, bring up just `db` first
(`docker compose up -d db`), restore, then `up -d --build` the rest.
## Security
## More
Lyra has **no per-user accounts**. Two layers protect it:
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
password for the entire UI and API. A request without a valid session cookie is
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
can browse the library, trigger downloads, and read/overwrite the stored credentials in
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
- **Network.** The shared password is deliberately minimal (single password, bearer
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
alone.
- **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** — full feature tour, publishing pre-built
images to a registry, and running the whole stack behind a Gluetun VPN.
- **[docs/superpowers/specs/](docs/superpowers/specs/)** — the full design specs.
+25
View File
@@ -24,9 +24,26 @@ services:
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
# Optional shared-password gate for the whole UI/API. Unset ⇒ app is open. See README.
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
# Next's standalone server binds to HOSTNAME; default is the container hostname (its own
# IP only), so loopback healthchecks can't reach it. Bind all interfaces instead.
HOSTNAME: 0.0.0.0
# Library root inside the container (matches the /music mount below) — used by the
# library-management delete to locate + guard which folders it may remove.
LYRA_LIBRARY_ROOT: /music
volumes:
# Library management (delete albums, and later manual import) needs the web to read/write
# the library, so mount it the same way the worker does.
- ${MUSIC_DIR:-./music}:/music
ports:
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
- "8770:3000"
healthcheck:
# unauthenticated liveness endpoint; node 22 has global fetch (no curl/wget in the image)
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
db:
condition: service_healthy
@@ -55,6 +72,14 @@ services:
# empty local dir so the mount is always valid; Soulseek delivery only works once
# this points at the real slskd downloads directory.
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
healthcheck:
# the worker touches /tmp/worker.alive every ~15s from a background thread (survives long
# downloads); unhealthy if it hasn't been touched in 90s (process wedged/exited).
test: ["CMD-SHELL", "test $(( $(date +%s) - $(stat -c %Y /tmp/worker.alive 2>/dev/null || echo 0) )) -lt 90"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
db:
condition: service_healthy
+177
View File
@@ -0,0 +1,177 @@
# Lyra — deployment & advanced guide
Topics beyond the [README](../README.md) quick-start: the full feature tour, publishing
pre-built images to a registry, and running the whole stack behind a VPN. For a basic
self-hosted setup you only need the README — start there.
- [Features in depth](#features-in-depth)
- [Publishing pre-built images](#publishing-pre-built-images)
- [Running behind a VPN (Gluetun)](#running-behind-a-vpn-gluetun)
---
## Features in depth
Lyra was built in three slices, all complete:
1. **Acquisition engine.** Resolve a request against MusicBrainz, match/rank across
Qobuz/Soulseek/YouTube, download the best source into an isolated staging dir, verify
completeness, tag (mutagen), and atomically promote the clean album into
`Artist/Album (Year)/## Title.ext`.
2. **Library manager.** Watched artists (live MB search + follow), per-release monitor
toggles + auto-monitor-future, a wanted list with retry + quality-upgrade window, and a
worker "monitor" that discovers new releases and auto-grabs through the acquisition
pipeline. Plus a **library scan** that ingests existing on-disk albums as
have + monitored + followed, and a per-artist studio-only / show-all release-type filter.
3. **Discovery.** Recommendation-driven finding of new music via **ListenBrainz**
similar-artists and **Last.fm** `artist.getSimilar` (both MBID-native), behind a
pluggable `SimilaritySource` interface — Last.fm registers as a second source
automatically once its API key is set, and cross-source scores are summed per candidate.
A background sweep (and a "Discover now" button) aggregates artists similar to the ones
you follow into a manual review feed of suggested **artists** and **albums** at
`/discover`; **Follow** and **Want** reuse the slice-2 follow / wanted flows, **Dismiss**
is permanent. Plus a web-side seed-search box. Every suggestion and seed-search result
opens a read-only **artist preview** (`/discover/artist/[mbid]`) — discography, per-album
tracklists, and "in library" / "monitored" badges — where you can Follow or Want before
committing. No Spotify (its recommendation APIs are dead for new apps).
Alongside the three slices:
- **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period), with a
"hide items already in my library" toggle and inline **Follow** / **Want** buttons on each
row; each artist also opens a modal of your most-played songs and albums.
- **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each album
opening a tracklist modal.
- **The Floor** (home) — a live queue of in-flight acquisitions with three-phase progress
(**Searching → Downloading → Finishing**), a live download percentage, a per-job "pressing
ticket" (source · format · tracks · elapsed), and a **Retry** button on anything that needs
attention.
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted
Fraunces serif for prose vs. system mono for machine data, light/dark aware).
Deferred hardening and the full roadmap live in the implementation-plan "Notes" sections under
[`docs/superpowers/plans/`](superpowers/plans/); the full design specs are under
[`docs/superpowers/specs/`](superpowers/specs/).
---
## Publishing pre-built images
> **Maintainer release process** — this is how the project maintainer publishes images to a
> private registry. If you're self-hosting your own copy, ignore this and just build from
> source with `docker compose up -d --build` (see the README). You'd only need this if you
> run your own registry and want servers to **pull** rather than build.
The `web` and `worker` images can be published to a container registry and consumed by a
compose file that pulls instead of builds. The maintainer publishes to a Gitea container
registry consumed by a compose file in the
[`vm-download`](https://git.jger.nl/Jonathan/vm-download) ops repo (`docker/lyra/`).
Publish a new release from this repo (amd64):
```sh
docker login <your-registry> # once — registry user + a token with package:write
./scripts/build-push.sh # builds + pushes lyra-web / lyra-worker (:latest and :<sha>)
```
`scripts/build-push.sh` defaults the registry namespace to `git.jger.nl/jonathan`; override it
with `LYRA_REGISTRY=<your-registry>/<namespace>`.
Then on the server:
```sh
cd /opt/git/vm-download && git pull
cd docker/lyra && docker compose pull && docker compose up -d
```
`db` (Postgres) and `db-backup` use the stock `postgres:17` image, so only the two custom
images are built here.
---
## Running behind a VPN (Gluetun)
Lyra runs fine with its traffic routed through a [Gluetun](https://github.com/qdm12/gluetun)
VPN container — useful for hiding the download traffic (Soulseek / Qobuz / YouTube). Nothing
in Lyra is VPN-incompatible; the only thing that changes is container networking, because a
service using `network_mode: "service:gluetun"` gives up its own network stack and shares
Gluetun's. Two consequences:
- Containers that share Gluetun's namespace reach each other over **`localhost`**, not by
Docker service name — so `DATABASE_URL` changes from `@db:5432` to **`@localhost:5432`**.
- **Published ports move to the `gluetun` container** (the individual services can no longer
publish their own).
The simplest, most consistent setup is to put the **whole stack** behind Gluetun. Sketch
(merge into your existing Gluetun compose; adjust the VPN provider block to yours):
```yaml
services:
gluetun:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
environment:
# ... your VPN provider / credentials ...
FIREWALL_INPUT_PORTS: "3000" # let your browser reach the web UI
FIREWALL_OUTBOUND_SUBNETS: "192.168.0.0/16" # your LAN — so the worker can reach slskd
ports:
- "8770:3000" # Lyra web (published on gluetun, not on `web`)
# - "5432:5432" # only if you want DB access from the LAN
web:
build: ./web
network_mode: "service:gluetun" # no `ports:` / `networks:` here
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra # `db` -> `localhost`
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
HOSTNAME: 0.0.0.0
LYRA_LIBRARY_ROOT: /music
volumes:
- ${MUSIC_DIR:-./music}:/music
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
worker:
build: ./worker
network_mode: "service:gluetun"
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
STAGING_ROOT: /staging
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
volumes:
- ${MUSIC_DIR:-./music}:/music
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
db:
image: postgres:17
network_mode: "service:gluetun" # shares the namespace so `localhost:5432` resolves
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
```
(Give `db-backup` the same `network_mode` and `PGHOST: localhost`.) The `HOSTNAME=0.0.0.0`
and the built-in healthchecks keep working, since everything is in one namespace.
Caveats worth knowing:
- **Qobuz is geo-sensitive** — its catalog/availability varies by country, so pick a VPN
exit in a region where your Qobuz account works, or some downloads will fail to match.
- The worker's `net.ipv6.conf.all.disable_ipv6` sysctl **can't apply behind Gluetun** (a
shared netns can't set sysctls) — harmless, because Gluetun blocks IPv6 anyway, which is
exactly what that sysctl worked around (Qobuz's CDN resolving to an unroutable IPv6).
- MusicBrainz rate-limits per IP; a shared VPN exit is marginally more likely to hit the
1 req/s ceiling, but the built-in response cache makes that a non-issue in normal use.
- Bind mounts (`/music`, `/staging`, `/slskd-downloads`), the Postgres volume, and backups
are filesystem, not network — **completely unaffected** by the VPN.
@@ -0,0 +1,247 @@
# Discover Unified Rows + Newest/Most-Popular Albums — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Collapse the Discover Artists|Albums tabs into one artist-centric row that shows each similar artist's newest AND most-popular album inline (album-click → tracklist), with the artist name linking to the full page.
**Architecture:** Worker derives two albums per surfaced artist (newest = existing; most-popular = Last.fm `artist.getTopAlbums` matched into the browsed MB discography), tagging each `DiscoverySuggestion.albumReason`. The `/api/discover` route groups pending suggestions by artist into rows carrying `albums[]` + `seeds[]`. The client renders one row per artist with album thumbnails.
**Tech Stack:** Python 3 + psycopg (worker), Next.js 15 App Router + Prisma + vitest (web), Playwright (verify).
## Global Constraints
- Popularity source is Last.fm `artist.getTopAlbums`; **graceful fallback to newest-only** when no `lastfm.api_key` or no match. No new required config.
- `albumReason` values are exactly `"newest"`, `"popular"`, `"newest,popular"`; badge labels `Newest` / `Most played` / both; `null` → no badge.
- Never break the sweep: any Last.fm/popularity error → treat as no popular pick.
- Deploy is `docker compose up -d --build web worker` (never `down -v`). Tests: worker `lyra_test`, web `lyra_test`.
- Reverts the D2 tab bar (intentional).
---
### Task 1: `albumReason` column + migration
**Files:**
- Modify: `web/prisma/schema.prisma` (DiscoverySuggestion model)
- Create: `web/prisma/migrations/<ts>_add_discovery_album_reason/migration.sql` (via prisma)
- [ ] **Step 1:** Add `albumReason String?` to `model DiscoverySuggestion` with a comment (nullable, backward compatible; null → no badge).
- [ ] **Step 2:** `cd web && DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate dev --name add_discovery_album_reason`. Expected: "Applying migration … add_discovery_album_reason".
- [ ] **Step 3:** Commit (`feat(discover): add DiscoverySuggestion.albumReason`).
---
### Task 2: worker Last.fm album-popularity helper
**Files:**
- Create: `worker/lyra_worker/similarity/_lastfm_albums.py`
- Modify: `worker/lyra_worker/registry.py` (add `build_album_popularity`)
- Test: `worker/tests/test_lastfm_albums.py`
**Interfaces:**
- Produces: `LastfmAlbumPopularity(api_key, base_url=_BASE, timeout=15.0)` with
`top_albums(conn, artist_name) -> list[[name:str, mbid:str|None, playcount:int]]`
(playcount desc; cached in ApiCache key `lfm-artist-top-albums:{norm}`, 12h). Errors → `[]`.
- Produces: `build_album_popularity(config, dsn) -> LastfmAlbumPopularity | None`
(None when `lastfm.api_key` unset).
- [ ] **Step 1: failing test** — parse a canned `artist.gettopalbums` JSON body into
`[[name, mbid, playcount], …]` ordered by playcount desc; a `dict` (single album) is
wrapped; missing mbid → None; a body with `error``[]`. Inject the parse via a helper
`_parse_top_albums(data)` so no network is needed. Also assert `build_album_popularity({}, None) is None`.
- [ ] **Step 2:** run → FAIL (module missing).
- [ ] **Step 3: implement.**
```python
# _lastfm_albums.py
import requests
from lyra_worker.apicache import cached_api
_BASE = "https://ws.audioscrobbler.com/2.0/"
def _parse_top_albums(data) -> list[list]:
if isinstance(data.get("error"), (int, float)):
return []
rows = data.get("topalbums", {}).get("album", [])
if isinstance(rows, dict):
rows = [rows]
out = []
for r in rows:
name = r.get("name") or ""
if not name:
continue
out.append([name, r.get("mbid") or None, int(r.get("playcount") or 0)])
out.sort(key=lambda a: a[2], reverse=True)
return out
class LastfmAlbumPopularity:
def __init__(self, api_key, base_url=_BASE, timeout: float = 15.0, limit: int = 25):
self._key, self._base, self._timeout, self._limit = api_key, base_url, timeout, limit
def _fetch(self, artist_name: str) -> list[list]:
try:
res = requests.get(self._base, params={
"method": "artist.gettopalbums", "artist": artist_name,
"api_key": self._key, "format": "json", "limit": str(self._limit),
"autocorrect": "1",
}, timeout=self._timeout)
return _parse_top_albums(res.json())
except Exception:
return []
def top_albums(self, conn, artist_name: str) -> list[list]:
norm = (artist_name or "").strip().lower()
if not norm:
return []
return cached_api(conn, f"lfm-artist-top-albums:{norm}", 43200,
lambda: self._fetch(artist_name))
```
```python
# registry.py — add
from lyra_worker.similarity._lastfm_albums import LastfmAlbumPopularity
def build_album_popularity(config: dict, dsn: str | None = None):
key = config.get("lastfm.api_key")
return LastfmAlbumPopularity(api_key=key) if key else None
```
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** commit (`feat(discover): Last.fm album-popularity helper`).
---
### Task 3: `_derive_albums` picks newest + most-popular
**Files:**
- Modify: `worker/lyra_worker/discovery.py` (`_derive_albums`, `_upsert_album`, `run_discovery`)
- Test: `worker/tests/test_discovery_albums.py`
**Interfaces:**
- Consumes: `popularity.top_albums(conn, name)` from Task 2 (or None).
- Produces: `_derive_albums(conn, browser, surfaced, cfg, popularity=None)`; `run_discovery(conn, sources, browser, cfg, popularity=None)`; `_upsert_album(conn, artist, rg, reason)`.
- [ ] **Step 1: failing tests** (inject a fake popularity — no live Last.fm):
- newest + most-popular → two album rows, `albumReason` `"newest"` / `"popular"`.
- popular pick == newest rg → one row, `albumReason == "newest,popular"`.
- `popularity=None` → newest-only, `albumReason == "newest"`.
- popular match resolves by title when Last.fm mbid absent; skips owned/non-qualifying and falls to next-highest playcount; no match → newest-only.
```python
class FakePopularity:
def __init__(self, data): self._d = data # {artist_name: [[name, mbid, plays], …]}
def top_albums(self, conn, name): return self._d.get(name, [])
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3: implement.** In `_derive_albums`, after building `core` (qualifying un-owned, newest-first):
```python
def _norm(s): return (s or "").strip().lower()
# newest picks (existing)
picks = {} # rg_mbid -> reason
for rg in core[: cfg.albums_per_artist]:
picks[rg.rg_mbid] = "newest"
# most-popular pick via Last.fm, matched into `core`
if popularity is not None and core:
by_mbid = {g.rg_mbid: g for g in core}
by_title = {_norm(g.title): g for g in core}
for name, mbid, _plays in popularity.top_albums(conn, artist["name"]):
g = (by_mbid.get(mbid) if mbid else None) or by_title.get(_norm(name))
if g is None:
continue
picks[g.rg_mbid] = "newest,popular" if picks.get(g.rg_mbid) == "newest" else "popular"
break
for rg in core:
if rg.rg_mbid in picks:
albums += _upsert_album(conn, artist, rg, picks[rg.rg_mbid])
have.add(rg.rg_mbid)
```
Add `reason` param to `_upsert_album` → include `"albumReason"` in the INSERT column list +
`ON CONFLICT DO UPDATE SET … "albumReason" = EXCLUDED."albumReason"`. Thread `popularity`
through `run_discovery``_derive_albums`.
- [ ] **Step 4:** run → PASS; then full worker suite green.
- [ ] **Step 5:** commit (`feat(discover): derive newest + most-popular album per artist`).
---
### Task 4: wire popularity into the worker loop
**Files:**
- Modify: `worker/lyra_worker/main.py` (`_run_discovery` builds + passes popularity)
- Test: existing `test_discovery_trigger.py` still green (popularity defaults None there).
- [ ] **Step 1:** In `main.py`, import `build_album_popularity`; in `_run_discovery` build it from `get_config(conn)` and pass to `run_discovery(conn, sources, browser, cfg, popularity=pop)`.
- [ ] **Step 2:** run worker suite → PASS.
- [ ] **Step 3:** commit (`feat(discover): supply Last.fm popularity to the sweep`).
---
### Task 5: `/api/discover` groups by artist
**Files:**
- Modify: `web/src/app/api/discover/route.ts`
- Test: `web/src/app/api/discover/route.test.ts`
**Interfaces:**
- Produces: `GET` returns `{ rows: [{ id, artistMbid, artistName, score, seedCount, sources, seeds, albums: [{ id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, albumReason }] }], search? }`. Rows ordered by score desc.
- [ ] **Step 1: failing test** — seed pending artist + its two album suggestions (`albumReason` newest/popular) sharing `artistMbid`; assert one row with `albums.length === 2`, ordered; seeds present; an album whose artist has no pending artist-suggestion still yields a row.
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3: implement.** Fetch all pending suggestions (as today) + the seeds map (as today). Group by `artistMbid`: artist-kind rows seed the row header (id/score/seedCount/sources); album-kind rows push into `albums[]` (include `albumReason`). For an `artistMbid` with only album rows, synthesize a header from the album's `artistName`/`artistMbid` (no `id`, score = max album score). Sort rows by score desc; sort each `albums[]` by `firstReleaseDate` desc. Return `{ rows }`.
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** commit (`feat(discover): group suggestions into artist rows`).
---
### Task 6: dismiss cascade
**Files:**
- Modify: `web/src/app/api/discover/[id]/route.ts`
- Test: `web/src/app/api/discover/[id]/route.test.ts`
- [ ] **Step 1: failing test** — dismissing an artist suggestion also marks its pending album suggestions (same `artistMbid`) dismissed; a dismissed album is not returned by `/api/discover`.
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3: implement.** In the dismiss branch, after dismissing the artist suggestion, `updateMany({ where: { kind: "album", artistMbid, status: "pending" }, data: { status: "dismissed" } })`. Follow/Want unchanged.
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** commit (`fix(discover): dismissing an artist also dismisses its albums`).
---
### Task 7: unified-row client (drop tabs + ArtistModal)
**Files:**
- Modify: `web/src/app/discover/discover-client.tsx`
- Modify: `web/src/app/design.css` (row + thumb strip + badge styles)
- [ ] **Step 1:** Replace `Feed`/`tab` state with `rows` from `/api/discover` (`{ rows }`). Remove the tab bar, `ArtistModal` import + `artistModal` state + its JSX + the `isFullAlbum`/albums-tab code.
- [ ] **Step 2:** Render one `<li class="list-row disco-row">` per row:
- artist block: `<a href={`/discover/artist/${row.artistMbid}`}>{row.artistName}</a>`, `score`, `similarTo(row.seeds)` (keep existing helper).
- album strip: `row.albums.map` → a `<button class="disco-thumb" onClick={() => setAlbumModal(album with artistMbid=row.artistMbid)}>`: `<CoverArt>` + title + badge (`badgeFor(albumReason)`) + a `Want` button (`act(album.id, "want")`).
- row actions: `Follow` (`act(row.id, "follow")` when `row.id`, else follow-by-mbid via `POST /api/artists`), `Dismiss` (`act(row.id, "dismiss")`, hidden when no `row.id`).
- `badgeFor(r)`: `newest``Newest`, `popular``Most played`, `newest,popular``Newest · Most played`, null→none.
- [ ] **Step 3:** keep `AlbumModal` (thumb click) + `find-similar` search block (its result artist links become `<a href>` to the full page, no modal). Keep Discover-now/freshness header.
- [ ] **Step 4:** CSS: `.disco-row` flex (artist block | thumb strip | actions), `.disco-thumb` (small cover + title + `.badge`), wraps under artist on narrow widths.
- [ ] **Step 5:** `npx tsc --noEmit` + `npm test` + `npm run build` → all green.
- [ ] **Step 6:** commit (`feat(discover): unified artist rows with inline album thumbnails`).
---
### Task 8: deploy + live verify
- [ ] **Step 1:** `docker compose up -d --build web worker`; confirm migration applied + all healthy.
- [ ] **Step 2:** Playwright on `/discover`: rows render; artist name navigates to `/discover/artist/[mbid]`; album thumbnail opens `AlbumModal` with a tracklist; badges show.
- [ ] **Step 3:** Trigger a library **Scan** (populates artistMbid) then **Discover now**; confirm `albumReason` populates (`Most played` badges appear) once a fresh sweep runs. (If a full sweep is impractical to force cheaply, rely on the injected-data Playwright check + unit tests, and note it.)
- [ ] **Step 4:** Update `MEMORY.md` / project-state with the shipped feature + commit range.
## Self-Review
- **Spec coverage:** Part A → Tasks 24; Part B → Task 5; Part C dismiss → Task 6, client/modal → Task 7; schema → Task 1; tests throughout; live verify → Task 8. ✓
- **Placeholders:** none — code shown for each implementing step. ✓
- **Type consistency:** `top_albums(conn, name) -> list[[name, mbid|None, plays]]` used identically in Tasks 23; `_upsert_album(…, reason)` and `_derive_albums(…, popularity=None)` consistent; route emits `rows[]` consumed by Task 7. ✓
@@ -0,0 +1,96 @@
# Post-deploy UX fixes (3 items) — handoff plan
> **Context:** Lyra is DEPLOYED + LIVE on the media VM (ssh alias `download` = jonathan@10.10.0.103,
> hostname `servarr`, x86_64). Pre-built images from `git.jger.nl/jonathan/lyra-{web,worker}`, whole
> stack behind its own gluetun VPN, deploy repo `Jonathan/vm-download` → `docker/lyra/`. Library was
> wiped to a blank slate; user rebuilds via Last.fm. Two test presses succeeded end-to-end (John Mayer
> Continuum q3 hi-res, Room for Squares q2) — pipeline is solid. These 3 are UX polish the user hit
> in real use. See MEMORY.md → project-state.md "VM DEPLOYMENT" for full deploy details.
**Workflow for EACH fix:** implement in `worker/` → run worker tests (`cd worker && source .venv/bin/activate
&& python -m pytest`) → **on the DEV box** `./scripts/build-push.sh` (needs `docker login git.jger.nl`
already done this session; a fresh session may need re-login — a **real TTY**, not the `!`-prefix) →
on VM `ssh download 'cd /opt/git/vm-download/docker/lyra && docker compose pull && docker compose up -d worker'`
→ verify with a test press (queue via SQL, see below) and watch `Job.downloadProgress`/on-disk result.
Commit per fix; branch `feat/post-deploy-ux`, ff-merge to main, push.
**Queue a test press on the VM** (mimics the Wanted "Search now"): pick a `MonitoredRelease` id from
`SELECT id,"artistName",album FROM "MonitoredRelease" WHERE monitored AND NOT ignored AND state<>'fulfilled'`,
then insert a Request+Job (use a `/tmp/*.sql` file + `docker cp` + `psql -f` to avoid ssh quoting hell):
`INSERT INTO "Request"(id,artist,album,"monitoredReleaseId",status,"createdAt") SELECT gen_random_uuid()::text,"artistName",album,id,'pending',now() FROM "MonitoredRelease" WHERE id='<mrid>' RETURNING id` → capture into a Job insert `(id,"requestId",state,"currentStage",attempts,"downloadProgress","createdAt","updatedAt") VALUES (gen_random_uuid()::text,rid,'requested','intake',0,0,now(),now())`.
---
## Fix 1 — Smooth Qobuz download progress (byte-level)
**Problem:** the Floor progress bar sits at 0% for the whole Qobuz download then jumps to 100%.
Qobuz/streamrip's per-track byte callback is NOT hooked, so `Job.downloadProgress` comes only from the
file-count poller (`pipeline._poll_download_progress`) — and streamrip downloads tracks concurrently, so
0 files are "complete" until they all land at once. yt-dlp + slskd already report bytes via `on_progress`
(smooth); only Qobuz doesn't.
**Files:** `worker/lyra_worker/adapters/_streamrip.py` (the Qobuz download), `worker/lyra_worker/pipeline.py`
(threads `on_progress``_make_on_progress``Job.downloadProgress`; poller is the fallback when the
per-attempt `reports` Event is set).
**Approach:** hook streamrip's progress. streamrip drives progress via a callback/console; investigate its
API (it uses a `rip.utils`/`Downloadable` byte-progress hook or a `tqdm` callback). Aggregate downloaded
bytes / total bytes summed across the album's concurrent tracks → a single fraction → call the pipeline's
`on_progress(frac)` (which sets `reports` and writes throttled `Job.downloadProgress`). Keep the file-count
poller as the fallback (adapters that don't report). Match the throttle already in `_make_on_progress`
(≥1% move or every 0.5s).
**Test:** hard to unit-test streamrip internals — add a unit test that a fake byte-callback aggregates to a
monotonic 0→1 fraction, plus **live-verify** with a test press: `Job.downloadProgress` climbs mid-download
instead of 0→100. (Known caveat if streamrip exposes no usable byte hook: fall back to a smoother
file-count cadence, or accept the jump — document the finding.)
## Fix 2 — Parallelize the source searches (Soulseek adds ~2 min)
**Problem:** every acquisition spends up to ~2 min in the Soulseek search. `SlskdClient.search_album` polls
`_SEARCH_POLLS=40 × _POLL_SECONDS=3s = 120s` (early-exits on `isComplete`, but slskd searches genuinely run
long). The pipeline searches adapters SEQUENTIALLY (`for adapter in adapters: adapter.search(target)`), so
Soulseek's 2 min is added on top of Qobuz's ~instant search.
**Files:** `worker/lyra_worker/pipeline.py` (the match phase, `for adapter in adapters` loop);
optionally `worker/lyra_worker/adapters/_slskd.py` (`_SEARCH_POLLS`).
**Approach:** run the per-adapter searches **concurrently** (e.g. `ThreadPoolExecutor`, one thread per
adapter), collect all candidates when the last returns → total match wall-clock = slowest single search,
not the sum. So Qobuz returns instantly and only Soulseek's ~2 min gates. Keep the existing per-adapter
try/except (a failing source contributes nothing). **Thread-safety caveat:** streamrip uses a module-level
persistent asyncio loop (`_streamrip._run`) — verify concurrent Qobuz search from a worker thread is safe
(it runs coroutines on that shared loop; may need a lock or to keep Qobuz on the calling thread while
Soulseek/YouTube go to threads). Optionally also drop `_SEARCH_POLLS` to ~20 (60s) with the early-exit.
**Test:** a pipeline test asserting all adapters' candidates are collected (order-independent) when searched
concurrently; existing match/ranking tests stay green. Live-verify a press: match phase finishes in ~the
Soulseek time, not Soulseek+Qobuz.
## Fix 3 — Canonicalize bonus-track filenames on import
**Problem:** tracks BEYOND the MusicBrainz tracklist keep streamrip's raw name. Real example: Room for
Squares imported 12 clean `## Title.flac` + one `13. John Mayer - St. Patrick's Day (Album Version).flac`
(MB lists 12; Qobuz had a 13th bonus). The main tracklist gets `## Title` but extras don't.
**Files:** `worker/lyra_worker/library.py` (`import_album` — how it names/moves audio into
`Artist/Album (Year)/## Title.ext`), and/or the tagger `worker/lyra_worker/_mutagen.py`.
**Approach:** find where files are renamed to `## Title.ext`. For a track with no MB position/title (an
extra), derive a clean name from its own embedded tags (track number + title) or by stripping the
`^\d+\.?\s*(Artist\s*-\s*)?` prefix from the streamrip filename → `## Title.ext`. Ensure the position keeps
counting past the MB list (13, 14, …) and the on-disk `trackNames` capture stays consistent.
**Test:** unit-test the filename-canonicalizer on inputs like `13. John Mayer - St. Patrick's Day (Album
Version).flac``13 St. Patrick's Day (Album Version).flac`; live-verify by re-pressing an album with a
bonus track (or `docker compose exec worker` a rename dry-run). NOTE: the already-imported Room for Squares
track 13 can be fixed by re-pressing (force upgrade) or a one-off rename — mention to the user.
---
## Self-review / notes
- All three are worker-only → only the `worker` image rebuilds/redeploys (`docker compose up -d worker`).
- Don't disrupt the live stack: the VM is in use; deploy the worker, verify one press, done.
- If context/time is tight, do them in priority order: **2 (search speed)** and **3 (filenames)** are
self-contained and low-risk; **1 (streamrip progress)** is the deepest (streamrip internals) — timebox it
and fall back to documenting if streamrip has no clean byte hook.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,156 @@
# Discover redesign: unified artist rows + newest/most-popular albums
**Date:** 2026-07-14
**Status:** Approved (design), pending implementation plan
## Problem
`/discover` splits suggested artists and suggested albums into two tabs, but album
suggestions are not independent — each one is just "a notable album by a suggested similar
artist" (derived in `_derive_albums` from the surfaced artists). The two-tab split fragments a
single idea. Two further gaps:
- Only the **newest** un-owned album is surfaced per similar artist; the album the artist is
best known for (their **most popular**) is invisible unless it happens to be newest.
- Seeing an album's tracklist requires an extra modal hop, and the artist modal on Discover is
a lightweight duplicate of the richer full artist page.
## Goals
1. Keep album suggestions artist-mediated (no album-level similarity data exists in our
stack), but surface **two** albums per similar artist: **newest** and **most popular**.
2. Collapse the Artists|Albums tabs into **one unified list** — one row per suggested artist,
carrying that artist's album thumbnails inline.
3. On Discover, replace the artist modal with a direct link to the full artist page; keep
album-click → the existing `AlbumModal` (cover + tracklist + Want).
Non-goals: album-to-album similarity (no data source); collection-completion suggestions;
changing the Last.fm page's artist modal (it keeps its personal most-played view).
## Data-source decision: popularity
- **Chosen:** Last.fm `artist.getTopAlbums` — returns an artist's albums ranked by playcount,
each with a name and (usually) an MBID. The user already has Last.fm configured; no new
config. Confirmed live: the method exists (error 10 "invalid key" with a bogus key, not
error 3 "unknown method").
- **Rejected:** ListenBrainz `/1/popularity/release-group` — MBID-native and cleaner in
principle, but now requires a ListenBrainz auth token (anti-scraper), which is new config
friction. MusicBrainz exposes no popularity signal at all.
- **Fallback:** without Last.fm creds, or when no top album matches a qualifying un-owned
release group, the artist yields **newest-only** — byte-for-byte today's behavior.
## Design
### Part A — Album derivation (worker)
`_derive_albums(conn, browser, surfaced, cfg, popularity=None)` in
`worker/lyra_worker/discovery.py`.
For each surfaced artist:
1. Browse the artist's release groups from MB (already done) → filter to qualifying,
un-owned albums (`_album_kind_ok` + not in `_existing_rg_mbids`) — unchanged.
2. **Newest:** the newest `cfg.albums_per_artist` (default 1) by `first_release_date` desc —
unchanged, tagged `reason="newest"`.
3. **Most popular:** if `popularity` is available, fetch the artist's Last.fm top albums, and
pick the highest-playcount album that maps to a qualifying un-owned release group in the
browsed set — match by MBID first (when Last.fm supplies one that is in the browsed set),
else by normalized title (`stripEditionQualifiers`-equivalent, lowercased/trimmed). Tag
`reason="popular"`.
4. If the popular pick is the same release group as a newest pick, do not insert a second
row — the existing row's reason becomes `"newest,popular"`.
5. Upsert each as a `DiscoverySuggestion` (kind `album`) as today, additionally writing
`albumReason`.
**Popularity helper (injected, testable — mirrors `#16`'s `measure_duration`).** An object
with `top_albums(artist_name) -> list[(name, mbid_or_None, playcount)]`, ordered by playcount
desc. Built only when `lastfm.api_key` is set (worker decrypts via `crypto.py`); the fetch is
wrapped in `cached_api` (`ApiCache`, ~12h TTL, key `lfm-artist-top-albums:{norm}`). Errors are
swallowed → treated as "no popular pick" (newest-only). `run_discovery` constructs it from
config and passes it to `_derive_albums`; tests inject a fake or `None`.
**Cost:** ≤1 Last.fm call per surfaced artist per sweep, cached; sweeps are infrequent and
chunked. Only surfaced artists (post `min_score`) are queried.
### Part B — `/api/discover` groups suggestions by artist
`web/src/app/api/discover/route.ts` returns **rows** instead of two flat arrays. Build rows
from the union of pending artist-suggestions and the distinct artists of pending
album-suggestions (so an album is never orphaned), keyed by `artistMbid`:
```
rows: [{
id, // the artist suggestion id (for Follow/Dismiss), when present
artistMbid, artistName,
score, seedCount, sources,
seeds: string[], // "why suggested" (existing DiscoverySeedContribution join)
albums: [{
id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate,
albumReason, // "newest" | "popular" | "newest,popular"
}]
}]
```
Rows are ordered by `score` desc (artist suggestion score; album-only rows use the album's
score). The `find-similar` search flow is unchanged (separate endpoint, no albums).
### Part C — dismiss cascade
`POST /api/discover/[id]` (dismiss): when the target is an **artist** suggestion, also mark
its pending **album** suggestions (same `artistMbid`) dismissed, so the unified row disappears
cleanly rather than leaving stray albums. Follow/Want are unchanged.
### Part D — `/discover` client (unified rows, no tabs, no modal)
`web/src/app/discover/discover-client.tsx`:
- Remove the Artists|Albums tab bar and the `tab` state; render one `<ul>` of artist rows.
- Remove `ArtistModal` import + usage entirely. The artist **name links** to
`/discover/artist/{artistMbid}` (an `<a href>`; matches the existing full-page route).
- Each row renders its `albums[]` as small `CoverArt` thumbnails, each with:
- a **badge** from `albumReason`: `"newest"``Newest`, `"popular"``Most played`,
`"newest,popular"`→both; `null`→no badge;
- a **Want** action (`act(album.id, "want")`);
- **click → `AlbumModal`** (kept) with the album's `rgMbid`/title/artist → cover + tracklist
+ Want.
- Row actions stay: **Follow** / **Dismiss** (on `row.id`, the artist suggestion). If a row
has no artist suggestion id (album-only, e.g. artist dismissed earlier), Follow resolves by
MBID via `POST /api/artists`; Dismiss is hidden for that (degenerate) case.
- `find-similar` results keep their existing rendering, minus `ArtistModal` — their artist
links also go to the full page (consistency; `ArtistModal` is being removed from this file).
- New CSS for the row layout (artist block + horizontal album-thumb strip + actions),
responsive: the thumb strip wraps/stacks under the artist on narrow viewports.
### Schema / migration
`DiscoverySuggestion.albumReason String?` — nullable, backward compatible. Existing album
rows read as `null` (client treats null as no badge) until the next sweep re-derives them.
Migration `add_discovery_album_reason`.
## Testing
**Worker (`test_discovery_albums.py`):**
- newest + most-popular produce two rows with correct `albumReason`;
- dedupe when newest == popular → one row, `reason="newest,popular"`;
- no popularity helper → newest-only (unchanged), `reason="newest"`;
- popular pick skips owned / non-qualifying release groups and falls to the next-highest
playcount; no match → newest-only;
- injected fake popularity (no live Last.fm).
**Web:**
- `/api/discover` groups albums under their artist row; row carries `albums[]` + `seeds[]` +
`albumReason`; album-only artist still yields a row.
- dismiss cascade: dismissing an artist marks its album suggestions dismissed.
- existing route tests updated to the grouped shape.
**Live verify (Playwright):** unified rows render; artist name navigates to the full page;
album thumbnail opens `AlbumModal` with a tracklist; badges show; a fresh "Discover now" sweep
populates `albumReason` (needs a re-scan first so owned-artist MBIDs exist — pre-existing
caveat from `#18`).
## Risks / notes
- Last.fm top-album title↔MB release-group matching can miss (edition wording, localized
titles); acceptable — a miss just means newest-only for that artist. Normalized-title
matching + MBID-first keeps misses rare.
- Adds a Last.fm dependency to the album step of the sweep; fully optional + cached, and the
artist step already uses Last.fm as a similarity source.
- Reverts the D2 tab bar shipped earlier the same day; intentional per this design.
@@ -0,0 +1,179 @@
# Press controls + inter-job delay — design
Date: 2026-07-15
## Problem
The monitor can enqueue a large backlog of acquisition jobs at once. The worker drains
them serially with **no pacing** (only a 2s idle poll when the queue is empty) and offers
**no runtime control** over the queue — the only per-job action on the Floor is Retry (on
`needs_attention`). The user wants:
1. A **configurable delay between downloads** to spread sustained Qobuz load over time.
2. **Per-item controls** on the Floor ("the Press"): pause and remove a queued item.
3. **Whole-press controls**: pause/resume, clear the queue, and a force-stop of the
currently-downloading album.
## Constraints (from the existing code)
- The worker is a **single, strictly-serial process** (`worker/lyra_worker/main.py:256-298`).
One job runs to completion in a blocking `run_pipeline` call before the loop does anything
else.
- **Config is read fresh every loop iteration** (`main.py:262`, `get_config`), so any Config
change takes effect within ~2s with no restart.
- An **in-flight download cannot be cleanly interrupted**`run_pipeline` and the adapter
download are synchronous/blocking with only an `on_progress` callback, no cancel token. The
only interruption is process death, after which `reclaim_stuck_jobs` (`claim.py:8-24`)
requeues the in-flight job at startup and `clear_staging_root` (`main.py:233`) wipes the
partial download.
- `claim_next` (`claim.py:27-57`) claims the oldest `state='requested'` job.
- Deleting a `Request` cascades to its `Job` and `Candidate` rows (`schema.prisma:51,66`); the
linked `MonitoredRelease` is `SetNull`, not cascaded.
- `enqueue_due` (`monitor.py:115-147`) re-enqueues a monitored release once it has no live
(non-terminal) job and its `lastSearchedAt` is older than `retryIntervalHours` (default 6).
So a removed/cleared monitored item reappears later on the normal cadence unless `ignored`.
- The web container runs `prisma migrate deploy` on startup (`web/docker-entrypoint.sh`), so a
new migration auto-applies on deploy.
## Decisions (confirmed with the user)
- **Active item:** add a global **force-kill** ("Stop now") that aborts the in-flight download
via worker process-restart. Per-item controls act only on queued items.
- **Remove / Clear semantics:** **cancel-for-now**. Manual one-off requests are gone for good;
monitored/wanted releases stay wanted and may reappear on the monitor's normal cadence.
Permanent skip remains the existing per-release **Ignore**.
- **Pause scope:** **downloads only**. Pausing halts claiming/downloading; the monitor keeps
finding and enqueueing releases (they pile up in the queue) and Resume drains them.
## Data model
- **New column** `Job.paused Boolean @default(false)` — a Prisma migration
(`add_job_paused`). Auto-applies on deploy.
- **Config keys** (key/value `Config` rows, no migration):
- `floor.paused``"true"`/`"false"`; global press pause.
- `floor.jobDelaySeconds` — integer string, default `"0"`; delay between downloads.
- `floor.killRequested``"true"`/`"false"`; one-shot force-kill signal, cleared by the
worker on consumption.
## Worker changes
`worker/lyra_worker/main.py` and `worker/lyra_worker/claim.py`.
1. **Inter-job delay.** After `finish_job` (`main.py:297`), read `floor.jobDelaySeconds` from
the already-fresh `config` and sleep that many seconds before the next loop iteration.
Sleep in small (~2s) chunks so the loop stays responsive; the force-kill watcher runs on a
separate thread and works regardless. Parse with the existing `_int`-style guard (invalid →
0). A value of 0 preserves today's back-to-back behavior.
2. **Global pause.** Immediately before `claim_next` (`main.py:286`), if `floor.paused` is
truthy (`_TRUE` idiom), skip claiming: `time.sleep(IDLE_SLEEP); continue`. Because the loop
is serial, any in-flight job has already finished before this check — pause stops the
*next* download. Monitor sweep / scan / discovery (above the claim in the loop body)
continue to run, so the queue keeps filling while paused.
3. **Per-item pause.** `claim_next` `WHERE` gains `AND NOT "paused"`. A paused queued job is
skipped by the claim but remains `state='requested'`, so it is not "lost" and resumes
claiming as soon as `paused` is cleared. `reclaim_stuck_jobs` is unaffected (it only touches
in-flight states).
4. **Force-kill watcher.** A daemon thread started in `run_forever` (modeled on
`_liveness_heartbeat`, `main.py:216-226`) polls `floor.killRequested` on its own DB
connection every ~1.5s. When set, it clears the flag (commit) and calls `os._exit(0)`. The
container restart policy brings the worker back; startup `clear_staging_root` +
`reclaim_stuck_jobs` wipe the partial download and requeue the aborted job. The "Stop now"
action also sets `floor.paused=true`, so the worker returns **idle** rather than instantly
re-downloading the aborted album; the user resumes when ready.
## Infrastructure (ops repo)
The prod worker's Docker restart policy is currently **`no`** (`docker inspect`
`RestartPolicy=no`), so process death — from force-kill *or any crash* — leaves it down. The
force-kill design requires `restart: unless-stopped` on the `worker` service in the ops repo
(`/opt/git/vm-download/docker/lyra/compose.yaml`). The in-repo `docker-compose.yml` already has
this (`docker-compose.yml:53`); this change brings prod in line and is good hygiene
independent of this feature. This is a one-line change in a separate repo and must be applied
before the force-kill feature is usable in prod.
## API (web) — follows existing `retry` / `library DELETE` patterns
- **`GET /api/floor`** → `{ paused: boolean, jobDelaySeconds: number }` (reads the Config
rows). Used by the Floor to render the paused banner and control states.
- **`POST /api/floor`** with `{ action }`:
- `"pause"` / `"resume"` → upsert `floor.paused`.
- `"clear"` → delete every `Request` whose `Job.state = 'requested'` (queued, not yet
claimed). Cascade removes the Jobs and Candidates. The in-flight and terminal jobs are
untouched. Returns the number cleared.
- `"stop"` → upsert `floor.paused='true'` and `floor.killRequested='true'`.
- **`POST /api/requests/[id]/pause`** with `{ paused: boolean }` → set `Job.paused`. Guard:
the job must be `state='requested'` (else `400`, matching the existing retry route), since
pausing an in-flight job has no effect.
- **`DELETE /api/requests/[id]`** → delete the `Request` (cascade Job + Candidates). Guard:
only when `Job.state = 'requested'`, so the active download is never touched (that is the
job of `Stop now`). Mirrors `DELETE /api/library/[id]`.
## Web UI
`web/src/app/queue.tsx` and `web/src/app/_ui/job-row.tsx`; styling via existing `.btn`
variants in `design.css`.
- **Press header** (above the queue list):
- `Pause the press` / `Resume` toggle (from `GET /api/floor`).
- `Clear queue` — confirm dialog showing the queued count; calls `POST /api/floor
{action:"clear"}`.
- `Stop now ⚠` — rendered only when a job is actively downloading (derivable from the
existing `/api/requests` data: any job in `matching`/`matched`/`downloading`/`tagging`).
Confirm dialog ("aborts the current download and pauses the press"); calls `POST /api/floor
{action:"stop"}`.
- A "Press paused — N queued" banner/chip when `paused`.
- **Per-item controls** (queued rows only — `job.state === 'requested'`), placed in the row
`note` slot as `.btn sm ghost`:
- `Pause` / `Resume` → `POST /api/requests/[id]/pause`.
- `Remove` → `DELETE /api/requests/[id]`.
- A per-item paused row shows a subtle "paused" chip and the `Resume` button.
- Active-download and `needs_attention` rows keep their current rendering (Retry unchanged).
- Polling cadence stays 2s; `GET /api/floor` is fetched alongside the existing three calls in
`refresh()`.
## Settings
Add a numeric field **"Delay between downloads (seconds)"** (default 0, min 0) to the settings
form (`web/src/app/settings/settings-form.tsx`), persisted to `floor.jobDelaySeconds` via the
existing settings config-write path (the same mechanism used for the other numeric/monitor
settings). Include short helptext explaining it paces sustained Qobuz load.
## Testing
- **Worker** (pytest, `worker/tests/`):
- `claim_next` skips `paused=true` queued jobs and still claims the next unpaused one.
- Global pause: the loop skips claiming when `floor.paused` is set (unit-test the claim-gate
helper).
- Delay: the delay value is read and applied (unit-test the parse/gate helper; keep the
sleep injectable/mockable).
- Kill watcher: when `floor.killRequested` is set, the watcher clears the flag and triggers
exit (mock `os._exit`, assert flag cleared + exit called).
- **Web** (route tests, mirroring `requests/[id]/retry/route.test.ts` and
`library/bulk/route.test.ts`):
- `POST /api/floor` for each action; `clear` deletes only `requested` jobs; `stop` sets both
flags.
- `POST /api/requests/[id]/pause` toggles `Job.paused`; rejects non-`requested`.
- `DELETE /api/requests/[id]` removes a queued Request; refuses an in-flight/terminal job.
- `GET /api/floor` returns the Config-derived state.
## Edge cases
- **Remove/Clear of monitored items** reappear later by design (cancel-for-now). No extra
handling; the per-release Ignore is the permanent path.
- **Kill with nothing downloading** still restarts the worker (brief blip). Minimized by only
showing `Stop now` while a download is active.
- **Delay during pause:** the delay sleeps only *between* jobs; pause is checked before each
claim, so a paused press never starts a delayed job.
- **Race:** clicking per-item Pause/Remove on a job that was just claimed — the guards
(`state='requested'`) reject it; the UI only renders those buttons on queued rows, and the
2s refresh reconciles.
## Out of scope
- Resuming a partially-downloaded album (no download-resume support; force-killed jobs
re-download from scratch).
- Reordering the queue / per-item priority.
- Any change to the per-release Ignore or the wanted list UI.
@@ -0,0 +1,83 @@
# Qobuz pacing ("budget gate") — design
Date: 2026-07-15
## Problem
Bulk hi-res downloading got the Qobuz account blocked (`403 USER_BLOCKED`). We need to keep
using Qobuz for hi-res but at a volume and cadence that looks like a human listener, so the
account isn't flagged again — while a large wanted-list still drains promptly via other sources.
## Approach: a per-job Qobuz eligibility gate
One gate decides, **per album**, whether Qobuz is an eligible source *right now*. If the gate is
closed, Qobuz candidates are dropped before ranking, so the worker falls through to the best
non-Qobuz source (Soulseek/YouTube) and keeps moving — no idling. Monitored albums grabbed that
way are upgraded to Qobuz hi-res later, automatically, via the existing quality-upgrade window,
once Qobuz has budget again.
**Qobuz is eligible for a job only if ALL hold:**
1. **Active hours** — current hour ∈ [activeStart, activeEnd) (a daytime window; start < end).
2. **Under today's cap** — Qobuz downloads counted today < today's cap.
3. **Spacing elapsed** — now ≥ the randomized "next allowed" time set after the last Qobuz
download.
After each successful Qobuz download the worker bumps today's counter and sets the next-allowed
time. The counter resets at the day boundary (tracked by a stored date).
**Today's cap = warm-up ramp of the steady cap**, by account age (days since `warmupStartDate`):
| Account age (days) | Cap |
|---|---|
| 02 | min(5, steady) |
| 36 | min(10, steady) |
| 713 | min(20, steady) |
| 14+ | steady |
If `warmupStartDate` is unset, no ramp (steady cap applies).
**Spacing / jitter (combined):** the gap to the next Qobuz download is randomized around
`activeWindowSeconds / todayCap` (so the day's budget spreads evenly across active hours), ±30%,
with a hard floor (≥ 60 s). This *is* the jitter — never a fixed metronome — and it prevents
front-loading the whole daily cap into the first hour.
## Configuration (Config keys; editable in Settings → Qobuz → Pacing)
Settings:
- `qobuz.pacing.enabled` (default `true`)
- `qobuz.pacing.activeStartHour` (default `8`)
- `qobuz.pacing.activeEndHour` (default `23`)
- `qobuz.pacing.dailyCap` (steady cap, default `40`)
- `qobuz.pacing.warmupStartDate` (ISO date; set to the new account's start day)
Worker-managed state (not user-edited):
- `qobuz.pacing.countDay` (date the counter is for)
- `qobuz.pacing.countToday` (int)
- `qobuz.pacing.nextAllowedAt` (epoch seconds)
Current hour / day come from the **DB clock** (`now()`), avoiding container-timezone ambiguity.
Config is read fresh each worker loop, so changes take effect with no restart.
## Worker integration
- New module `worker/lyra_worker/qobuz_gate.py`: a `QobuzPacing` config dataclass
(`from_config`), a pure `today_cap(...)`, a pure `is_allowed(...)` decision, and thin
DB helpers `gate_open(conn, now)` (reads state + decides) and `record_download(conn, pacing,
now)` (bumps counter + sets next-allowed).
- `pipeline.py`: after searching sources, if `gate_open` is false AND at least one non-Qobuz
candidate exists, drop Qobuz candidates before ranking (fall-through). If Qobuz is the *only*
source available, keep it (a Qobuz-only setup isn't gated — holding would strand the album).
- After a successful download where `winner.source == "qobuz"`, call `record_download`.
## Testing
Pure-logic unit tests for `is_allowed` (each gate condition), `today_cap` (ramp boundaries), and
the spacing computation; pipeline tests that a gated Qobuz candidate is dropped in favour of a
non-Qobuz source, that an open gate keeps Qobuz, and that a successful Qobuz download records the
counter/next-allowed.
## Out of scope
- Holding/parking an album for later Qobuz when Qobuz is the only source (fails/last-source as
today). Multi-source setups fall through, which is the common case.
- Timezone-aware active-hours windows that wrap past midnight (start < end only).
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Build the Lyra web + worker images and push them to the Gitea container registry, so the VM
# can pull pre-built images instead of building from source.
#
# Usage (from the Lyra repo root):
# docker login git.jger.nl # once — Gitea username + a token with package:write
# ./scripts/build-push.sh # builds + pushes :latest and :<short-sha>
#
# Override the registry namespace with LYRA_REGISTRY (default git.jger.nl/jonathan).
set -euo pipefail
REGISTRY="${LYRA_REGISTRY:-git.jger.nl/jonathan}"
here="$(cd "$(dirname "$0")/.." && pwd)"
sha="$(git -C "$here" rev-parse --short HEAD)"
for svc in web worker; do
img="$REGISTRY/lyra-$svc"
echo "==> building $img ($sha)"
docker build -t "$img:latest" -t "$img:$sha" "$here/$svc"
done
for svc in web worker; do
img="$REGISTRY/lyra-$svc"
echo "==> pushing $img:latest and $img:$sha"
docker push "$img:latest"
docker push "$img:$sha"
done
echo "Done. Pulled on the VM with: docker compose pull && docker compose up -d"
+31 -2
View File
@@ -7,8 +7,11 @@
"": {
"name": "lyra-web",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "6.1.0",
"@types/busboy": "^1.5.4",
"busboy": "^1.6.0",
"next": "15.5.20",
"react": "19.0.0",
"react-dom": "19.0.0"
@@ -1457,6 +1460,15 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/busboy": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1468,7 +1480,6 @@
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
@@ -1643,6 +1654,17 @@
"node": ">=12"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -2157,6 +2179,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -2248,7 +2278,6 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
+5 -3
View File
@@ -12,17 +12,19 @@
"db:dev": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "6.1.0",
"@types/busboy": "^1.5.4",
"busboy": "^1.6.0",
"next": "15.5.20",
"react": "19.0.0",
"react-dom": "19.0.0",
"@prisma/client": "6.1.0"
"react-dom": "19.0.0"
},
"devDependencies": {
"typescript": "5.7.2",
"@types/node": "22.10.2",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"prisma": "6.1.0",
"typescript": "5.7.2",
"vitest": "2.1.8"
}
}
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "ApiCache" (
"key" TEXT NOT NULL,
"json" JSONB NOT NULL,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiCache_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE INDEX "ApiCache_fetchedAt_idx" ON "ApiCache"("fetchedAt");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LibraryItem" ADD COLUMN "trackNames" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LibraryItem" ADD COLUMN "rgMbid" TEXT;
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "UnmatchedAlbum" (
"id" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"album" TEXT NOT NULL,
"path" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"scanId" TEXT NOT NULL,
"scannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UnmatchedAlbum_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UnmatchedAlbum_path_key" ON "UnmatchedAlbum"("path");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MonitoredRelease" ADD COLUMN "ignored" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Request" ADD COLUMN "force" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LibraryItem" ADD COLUMN "artistMbid" TEXT;
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "DiscoverySeed" (
"mbid" TEXT NOT NULL,
"lastDiscoveredAt" TIMESTAMP(3),
CONSTRAINT "DiscoverySeed_pkey" PRIMARY KEY ("mbid")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DiscoverySuggestion" ADD COLUMN "albumReason" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- Estimated seconds remaining for the active download (measured throughput). Nullable.
ALTER TABLE "Job" ADD COLUMN "downloadEtaSeconds" INTEGER;
+63
View File
@@ -35,6 +35,10 @@ model Request {
album String
status RequestStatus @default(pending)
createdAt DateTime @default(now())
// Force re-acquisition: skip the "already in library" dedupe so an owned album is
// re-downloaded on demand. The import step still keeps the copy only if it's higher
// quality, so a forced upgrade never downgrades. Set by the Library "Replace / upgrade".
force Boolean @default(false)
job Job?
libraryItem LibraryItem?
@@ -54,6 +58,10 @@ model Job {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
downloadProgress Float @default(0)
// Estimated seconds until the current download finishes, from measured throughput (slskd byte
// transfer rate). Null when unknown (no speed sample yet, or a source that doesn't report bytes).
downloadEtaSeconds Int?
paused Boolean @default(false)
candidates Candidate[]
}
@@ -82,6 +90,18 @@ model LibraryItem {
format String
qualityClass Int
importedAt DateTime @default(now())
// MusicBrainz release-group MBID when known (resolved at manual import) — gives the album
// cover art without depending on a matching MonitoredRelease row.
rgMbid String?
// MusicBrainz ARTIST MBID when known (captured at import + scan). Own-state ("in library")
// prefers this over the fragile exact artist-name match; null rows fall back to name-match
// (re-scan to populate).
artistMbid String?
// The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the
// Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches)
// instead of MusicBrainz's canonical listing. Empty for items imported before this existed
// (re-scan to populate).
trackNames String[] @default([])
@@unique([artist, album])
}
@@ -118,6 +138,10 @@ model MonitoredRelease {
secondaryTypes String[]
firstReleaseDate String?
monitored Boolean @default(false)
// User marked this release "do not monitor" — the monitor skips it (never enqueued as
// wanted/upgrade) and it drops off the Wanted list, independent of `monitored`, so
// auto-monitor-future can't silently re-grab it. Reversible from the discography page.
ignored Boolean @default(false)
state MonitoredReleaseState @default(wanted)
currentQualityClass Int?
firstGrabbedAt DateTime?
@@ -151,6 +175,10 @@ model DiscoverySuggestion {
score Float
seedCount Int
sources String[]
// Why this album is suggested (kind='album' only): "newest" (the artist's newest un-owned
// release), "popular" (their most-played per Last.fm), or "newest,popular" when they're the
// same release. Null for artist suggestions / rows from before this column existed.
albumReason String?
status SuggestionStatus @default(pending)
dedupeKey String @unique
createdAt DateTime @default(now())
@@ -171,6 +199,14 @@ model DiscoverySeedContribution {
@@index([seedMbid])
}
// Throttle registry for discovery seeds that are NOT WatchedArtist rows — i.e. artists you own
// albums by (LibraryItem.artistMbid) but don't follow. Mirrors WatchedArtist.lastDiscoveredAt so
// each library-derived seed is swept once per interval, not every sweep.
model DiscoverySeed {
mbid String @id
lastDiscoveredAt DateTime?
}
model ScanWorkItem {
id String @id @default(cuid())
scanId String
@@ -182,3 +218,30 @@ model ScanWorkItem {
@@unique([scanId, path])
@@index([scanId, done, artist, album])
}
// Album folders on disk that the library scan could not resolve to a MusicBrainz release
// (no match, or an unreadable/corrupt file). Surfaced on /library so the user can fix the
// metadata or add them manually instead of them being silently skipped. Keyed by path; a
// row is removed when the album later matches, and stale rows (a prior scan's, or a folder
// since removed) are pruned when a scan completes.
model UnmatchedAlbum {
id String @id @default(cuid())
artist String
album String
path String @unique
reason String
scanId String
scannedAt DateTime @default(now())
}
// Generic read-through cache for external API responses shared by web + worker (keyed on
// the LOGICAL operation, not the request URL, so both processes share entries). TTL is
// applied at read time from fetchedAt in code (no expiresAt column → tunable, no migration).
// Named generically so the Last.fm browse cache can ride the same table.
model ApiCache {
key String @id
json Json
fetchedAt DateTime @default(now())
@@index([fetchedAt])
}
+101 -5
View File
@@ -3,9 +3,14 @@
import { useEffect, useState, type ReactNode } from "react";
import { Modal } from "./modal";
import { CoverArt } from "./cover-art";
import { toast } from "./toast";
type Track = { position: number; title: string; lengthMs: number | null };
// Session-scoped, per-release-group tracklist cache: reopening the same album modal is
// instant with zero network. Complements the server-side ApiCache (which spans sessions).
const trackCache = new Map<string, Track[]>();
function fmt(ms: number | null): string {
if (ms == null) return "";
const s = Math.round(ms / 1000);
@@ -18,8 +23,22 @@ export type AlbumInfo = {
artist: string;
year?: string | null;
type?: string | null;
/** When present, the modal shows a Follow button + links the artist name to their page. */
artistMbid?: string | null;
/** Real on-disk audio filenames (Library items). When present, the modal shows THESE —
* revealing incomplete downloads / edition mismatches — instead of MusicBrainz's listing. */
trackNames?: string[] | null;
};
/** Parse on-disk "## Title.ext" filenames into display tracks (sorted order = position). */
function parseOnDisk(names: string[]): Track[] {
return names.map((name, i) => {
const stem = name.replace(/\.[^.]+$/, ""); // drop extension
const title = stem.replace(/^\s*\d+(?:[-.\s]+\d+)?[\s._-]+/, "").trim() || stem; // drop leading NN / N-NN
return { position: i + 1, title, lengthMs: null };
});
}
/** Reusable album modal: cover art + tracklist (fetched from MB by release-group MBID) with
* slots for a status chip and context-specific action buttons. */
export function AlbumModal({
@@ -36,18 +55,88 @@ export function AlbumModal({
actions?: ReactNode;
}) {
const [tracks, setTracks] = useState<Track[] | "loading" | "error">("loading");
const onDisk = (album.trackNames?.length ?? 0) > 0;
// null = unknown/loading; only meaningful when album.artistMbid is set.
const [followed, setFollowed] = useState<boolean | null>(null);
useEffect(() => {
if (!open || !album.artistMbid) {
setFollowed(false);
return;
}
let cancelled = false;
setFollowed(null);
fetch(`/api/artists?mbid=${encodeURIComponent(album.artistMbid)}`)
.then((r) => (r.ok ? r.json() : { followed: false }))
.then((d) => !cancelled && setFollowed(!!d.followed))
.catch(() => !cancelled && setFollowed(false));
return () => {
cancelled = true;
};
}, [open, album.artistMbid]);
async function follow() {
if (!album.artistMbid) return;
try {
const res = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: album.artistMbid, name: album.artist }),
});
if (res.ok || res.status === 409) {
setFollowed(true);
toast(`Following ${album.artist}`);
} else {
toast(`Couldn't follow ${album.artist}`);
}
} catch {
toast(`Couldn't follow ${album.artist}`);
}
}
const titleNode: ReactNode = album.artistMbid ? (
<span className="modal-artist">
<a href={`/discover/artist/${album.artistMbid}`}>{album.artist}</a>
<button
type="button"
className="btn sm ghost"
onClick={follow}
disabled={followed !== false}
aria-label={followed ? "following" : "follow artist"}
>
{followed === null ? "…" : followed ? "Following" : "Follow"}
</button>
</span>
) : (
album.artist
);
useEffect(() => {
if (!open) return;
if (!album.rgMbid) {
// Prefer the REAL on-disk tracks (Library items) — no network, and reveals mismatches.
if (album.trackNames?.length) {
setTracks(parseOnDisk(album.trackNames));
return;
}
const rgMbid = album.rgMbid;
if (!rgMbid) {
setTracks("error");
return;
}
const hit = trackCache.get(rgMbid);
if (hit) {
setTracks(hit);
return;
}
setTracks("loading");
let cancelled = false;
fetch(`/api/mb/release-groups/${album.rgMbid}/tracks`)
fetch(`/api/mb/release-groups/${rgMbid}/tracks`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => !cancelled && setTracks(d.tracks))
.then((d) => {
if (cancelled) return;
trackCache.set(rgMbid, d.tracks);
setTracks(d.tracks);
})
.catch(() => !cancelled && setTracks("error"));
return () => {
cancelled = true;
@@ -55,7 +144,7 @@ export function AlbumModal({
}, [open, album.rgMbid]);
return (
<Modal open={open} onClose={onClose} title={album.artist} wide>
<Modal open={open} onClose={onClose} title={titleNode} wide>
<div className="album-modal-head">
<CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" />
<div>
@@ -74,8 +163,15 @@ export function AlbumModal({
</div>
</div>
<div className="album-modal-tracks">
{Array.isArray(tracks) ? (
<p className="tracks-source">
{onDisk ? `On disk · ${tracks.length} tracks` : "MusicBrainz tracklist"}
</p>
) : null}
{tracks === "loading" ? <p className="muted-note">Loading tracks</p> : null}
{tracks === "error" ? <p className="muted-note">No tracklist available.</p> : null}
{tracks === "error" ? (
<p className="muted-note">No tracklist available.</p>
) : null}
{Array.isArray(tracks) ? (
<ol className="tracks">
{tracks.map((t) => (
+4
View File
@@ -102,6 +102,8 @@ export function ArtistModal({
if (res.ok || res.status === 409) {
setFollowed(true);
toast(`Following ${name}`);
} else {
toast(`Couldn't follow ${name}`);
}
}
@@ -122,6 +124,8 @@ export function ArtistModal({
if (res.ok) {
setWanted((s) => new Set(s).add(r.rgMbid));
toast(`Added ${r.album}`);
} else {
toast(`Couldn't add ${r.album}`);
}
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { Modal } from "./modal";
import type { ArtistHit } from "@/lib/artist-resolve";
/** Disambiguation picker: several MusicBrainz artists share a name (e.g. two bands both called
* "Looking Glass"), so let the user choose the right one by its MB disambiguation text instead
* of silently trusting Last.fm's — sometimes wrong — MBID. Candidates arrive in MB relevance
* order, so the first is flagged "Suggested". */
export function ArtistPicker({
open,
name,
candidates,
onPick,
onClose,
}: {
open: boolean;
name: string;
candidates: ArtistHit[];
onPick: (mbid: string, name: string) => void;
onClose: () => void;
}) {
return (
<Modal open={open} onClose={onClose} title="Which artist?">
<p className="muted-note">
More than one artist is named {name}. Pick the right one.
</p>
<ul className="list">
{candidates.map((c, i) => (
<li key={c.mbid} className="list-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => onPick(c.mbid, c.name)}>
{c.name}
</button>
</div>
<div className="rmeta">
<span>{c.disambiguation || "no description on MusicBrainz"}</span>
</div>
</div>
<div className="actions">
{i === 0 ? <span className="chip working">Suggested</span> : null}
</div>
</li>
))}
</ul>
</Modal>
);
}
+4 -4
View File
@@ -2,9 +2,9 @@
import { useState } from "react";
/** Album art from the Cover Art Archive (keyed by release-group MBID), loaded straight from
* their CDN by the browser — no backend, no MB API rate limit. Falls back to a placeholder
* when there's no MBID or no art. */
/** Album art keyed by release-group MBID, served through /api/cover (which caches it on disk +
* sends a long Cache-Control, so it isn't re-fetched from the Cover Art Archive on every
* Library / Recently-Pressed render). Falls back to a placeholder when there's no MBID or art. */
export function CoverArt({
rgMbid,
alt,
@@ -29,7 +29,7 @@ export function CoverArt({
// eslint-disable-next-line @next/next/no-img-element
<img
className={cls}
src={`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`}
src={`/api/cover/${rgMbid}?size=${size}`}
alt={alt}
loading="lazy"
onError={() => setFailed(true)}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useState, type ReactNode } from "react";
import { Modal } from "./modal";
/** A small "?" affordance that opens its help content in a popup, keeping settings pages
* uncluttered. */
export function HelpButton({ title, children }: { title: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
className="help-btn"
aria-label={`Help: ${title}`}
onClick={() => setOpen(true)}
>
?
</button>
<Modal open={open} onClose={() => setOpen(false)} title={title}>
<div className="help-body">{children}</div>
</Modal>
</>
);
}
+3
View File
@@ -14,6 +14,7 @@ export function JobRow({
album,
kind,
label,
marker,
meta,
note,
bar,
@@ -22,6 +23,7 @@ export function JobRow({
album: string;
kind: Kind;
label: string;
marker?: string;
meta?: ReactNode;
note?: ReactNode;
bar?: ReactNode;
@@ -31,6 +33,7 @@ export function JobRow({
<div className="stripe" />
<div>
<h3 className="title">
{marker ? <span className="nextup">{marker}</span> : null}
{album} <span className="artist">· {artist}</span>
</h3>
{meta ? <div className="meta">{meta}</div> : null}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/** A vertical "⋮" trigger that opens a small popup menu. Closes on outside click, Escape, or any
* click inside the popup (so menu items don't each need to manage it). Children are the items —
* use <button className="menu-item">…</button>. */
export function KebabMenu({ label = "Actions", children }: { label?: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className="menu" ref={ref}>
<button
type="button"
className="menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
onClick={() => setOpen((o) => !o)}
>
</button>
{open ? (
<div className="menu-pop" role="menu" onClick={() => setOpen(false)}>
{children}
</div>
) : null}
</div>
);
}
+3 -1
View File
@@ -1,4 +1,6 @@
export function SectionHeader({ title, note }: { title: string; note?: string }) {
import type { ReactNode } from "react";
export function SectionHeader({ title, note }: { title: string; note?: ReactNode }) {
return (
<div className="dept">
<h2>{title}</h2>
+16 -1
View File
@@ -1,5 +1,20 @@
import { describe, it, expect } from "vitest";
import { describeJob, timeAgo } from "./status";
import { describeJob, etaLabel, timeAgo } from "./status";
describe("etaLabel", () => {
it("formats seconds, minutes, and hours", () => {
expect(etaLabel(45)).toBe("~45s left");
expect(etaLabel(240)).toBe("~4m left");
expect(etaLabel(3900)).toBe("~1h 5m left");
});
it("returns null for missing or non-positive input", () => {
expect(etaLabel(null)).toBeNull();
expect(etaLabel(undefined)).toBeNull();
expect(etaLabel(0)).toBeNull();
expect(etaLabel(-5)).toBeNull();
});
});
describe("describeJob", () => {
it("maps pipeline states to literal labels + severity kind", () => {
+10
View File
@@ -27,6 +27,16 @@ export function timeAgo(value: string | Date | null | undefined, now: number): s
return `${Math.floor(h / 24)}d`;
}
/** Compact download ETA like "~4m left", "~45s left", "~1h 5m left". Null/≤0 → no label. */
export function etaLabel(seconds: number | null | undefined): string | null {
if (seconds == null || seconds <= 0) return null;
if (seconds < 60) return `~${seconds}s left`;
const m = Math.round(seconds / 60);
if (m < 60) return `~${m}m left`;
const h = Math.floor(m / 60);
return `~${h}h ${m % 60}m left`;
}
export function describeJob(state: string, stage: string): { label: string; kind: Kind } {
void stage; // stage no longer drives the label; kept for call-site compatibility
return (
+1
View File
@@ -27,6 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
secondaryTypes: r.secondaryTypes,
firstReleaseDate: r.firstReleaseDate,
monitored: r.monitored,
ignored: r.ignored,
state: r.state,
currentQualityClass: r.currentQualityClass,
})),
+37 -3
View File
@@ -8,6 +8,10 @@ import { GET, POST } from "./route";
const mockBrowse = vi.mocked(browseReleaseGroups);
afterEach(() => mockBrowse.mockReset());
function listReq(qs = "") {
return new Request(`http://localhost/api/artists${qs}`);
}
function postReq(body: unknown) {
return new Request("http://localhost/api/artists", {
method: "POST",
@@ -52,7 +56,7 @@ describe("artists collection API", () => {
// mark the one release as monitored + have, to exercise the counts
await prisma.monitoredRelease.updateMany({ where: { artistMbid: "a1" }, data: { monitored: true, currentQualityClass: 2 } });
const res = await GET();
const res = await GET(listReq());
expect(res.status).toBe(200);
const a = (await res.json()).artists[0];
expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 });
@@ -72,11 +76,41 @@ describe("artists collection API", () => {
},
});
const before = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
const before = (await (await GET(listReq())).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
expect(before).toMatchObject({ showAllTypes: false, releaseCount: 1, monitoredCount: 1, haveCount: 1 });
await prisma.watchedArtist.update({ where: { mbid: "a2" }, data: { showAllTypes: true } });
const after = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
const after = (await (await GET(listReq())).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 });
});
it("GET lists owned artists that are not followed (case-insensitive)", async () => {
// one owned + followed, one owned + not followed, one owned matching a followed name by case
await prisma.watchedArtist.create({ data: { mbid: "wf", name: "Followed Artist" } });
for (const [artist, album] of [["Followed Artist", "A"], ["Unfollowed Artist", "B"], ["followed artist", "C"]] as const) {
await prisma.libraryItem.create({
data: { artist, album, path: `/${album}`, source: "scan", format: "FLAC", qualityClass: 2 },
});
}
const body = await (await GET(listReq())).json();
expect(body.ownedNotFollowed).toContain("Unfollowed Artist");
expect(body.ownedNotFollowed).not.toContain("Followed Artist");
expect(body.ownedNotFollowed).not.toContain("followed artist"); // case-insensitive match
});
it("GET matches owned artists to the watched roster by MBID despite a name mismatch", async () => {
// followed under the canonical MB name, but the library row's name differs (feat./locale)
await prisma.watchedArtist.create({ data: { mbid: "mb-beyonce", name: "Beyoncé" } });
await prisma.libraryItem.create({
data: { artist: "Beyonce", album: "Lemonade", artistMbid: "mb-beyonce", path: "/l", source: "scan", format: "FLAC", qualityClass: 2 },
});
const body = await (await GET(listReq())).json();
expect(body.ownedNotFollowed).not.toContain("Beyonce"); // matched by MBID → not "unfollowed"
});
it("GET ?mbid= returns the follow state for a single artist", async () => {
await prisma.watchedArtist.create({ data: { mbid: "followed-1", name: "Followed" } });
expect((await (await GET(listReq("?mbid=followed-1"))).json()).followed).toBe(true);
expect((await (await GET(listReq("?mbid=nope"))).json()).followed).toBe(false);
});
});
+35 -8
View File
@@ -2,15 +2,40 @@ import { prisma } from "@/lib/db";
import { browseReleaseGroups } from "@/lib/musicbrainz";
import { isCoreRelease } from "@/lib/release-filter";
export async function GET() {
const artists = await prisma.watchedArtist.findMany({
orderBy: { createdAt: "desc" },
include: {
releases: {
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
export async function GET(request: Request) {
// Lightweight follow-state check for a single artist (used by the album modal's Follow
// button) — avoids fetching the full watched list + discography.
const mbid = new URL(request.url).searchParams.get("mbid");
if (mbid) {
const followed = !!(await prisma.watchedArtist.findUnique({ where: { mbid } }));
return Response.json({ followed });
}
const [artists, libraryArtists] = await Promise.all([
prisma.watchedArtist.findMany({
orderBy: { createdAt: "desc" },
include: {
releases: {
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
},
},
},
});
}),
prisma.libraryItem.findMany({ select: { artist: true, artistMbid: true } }),
]);
// Artists you own music by (LibraryItem) but don't follow yet. Match on the MusicBrainz
// ARTIST MBID first (robust to name punctuation/locale/feat. variants), falling back to a
// case-insensitive name match for library rows that predate the artistMbid column.
const followedMbids = new Set(artists.map((a) => a.mbid));
const followedNames = new Set(artists.map((a) => a.name.trim().toLowerCase()));
const isFollowed = (r: { artist: string; artistMbid: string | null }) =>
(r.artistMbid && followedMbids.has(r.artistMbid)) || followedNames.has(r.artist.trim().toLowerCase());
const ownedNotFollowed = [
...new Set(
libraryArtists.filter((r) => r.artist.trim() && !isFollowed(r)).map((r) => r.artist),
),
].sort((a, b) => a.localeCompare(b));
return Response.json({
artists: artists.map((a) => {
const visible = a.showAllTypes ? a.releases : a.releases.filter(isCoreRelease);
@@ -18,6 +43,7 @@ export async function GET() {
id: a.id,
mbid: a.mbid,
name: a.name,
createdAt: a.createdAt,
autoMonitorFuture: a.autoMonitorFuture,
showAllTypes: a.showAllTypes,
releaseCount: visible.length,
@@ -25,6 +51,7 @@ export async function GET() {
haveCount: visible.filter((r) => r.currentQualityClass !== null).length,
};
}),
ownedNotFollowed,
});
}
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { GET } from "./route";
let cache: string;
const original = process.env.LYRA_COVER_CACHE;
const RG = "735bcee6-6be6-3957-af15-d21ba2bd355b";
beforeEach(() => {
cache = mkdtempSync(join(tmpdir(), "lyra-cov-"));
process.env.LYRA_COVER_CACHE = cache;
});
afterEach(() => {
vi.unstubAllGlobals();
if (original === undefined) delete process.env.LYRA_COVER_CACHE;
else process.env.LYRA_COVER_CACHE = original;
rmSync(cache, { recursive: true, force: true });
});
const req = (rg: string) => new Request(`http://x/api/cover/${rg}?size=250`);
const params = (rg: string) => ({ params: Promise.resolve({ rgMbid: rg }) });
describe("GET /api/cover/[rgMbid]", () => {
it("400s a non-uuid", async () => {
expect((await GET(req("not-a-uuid"), params("not-a-uuid"))).status).toBe(400);
});
it("fetches from CAA once, then serves from the disk cache", async () => {
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const r1 = await GET(req(RG), params(RG));
expect(r1.status).toBe(200);
expect(r1.headers.get("cache-control")).toContain("max-age");
expect(existsSync(join(cache, `${RG}-250.jpg`))).toBe(true);
const r2 = await GET(req(RG), params(RG));
expect(r2.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1); // second served from cache
});
it("caches a miss (404) so it isn't re-fetched", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 404 }));
vi.stubGlobal("fetch", fetchMock);
expect((await GET(req(RG), params(RG))).status).toBe(404);
expect((await GET(req(RG), params(RG))).status).toBe(404);
expect(fetchMock).toHaveBeenCalledTimes(1); // negative cache hit on the second
});
});
+45
View File
@@ -0,0 +1,45 @@
import { existsSync } from "node:fs";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
// Cover art proxy + cache. The browser used to hit the Cover Art Archive CDN directly on every
// Library / Recently-Pressed render; now it hits this, which caches the image on disk and serves
// it with a long Cache-Control so the browser caches it too. A negative marker caps re-fetching
// covers that don't exist.
const CACHEABLE = "public, max-age=2592000"; // 30 days
const NEG_CACHE = "public, max-age=86400"; // re-check a missing cover daily
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(request: Request, { params }: { params: Promise<{ rgMbid: string }> }) {
const { rgMbid } = await params;
if (!UUID.test(rgMbid)) return new Response(null, { status: 400 });
const size = new URL(request.url).searchParams.get("size") === "500" ? "500" : "250";
const CACHE = process.env.LYRA_COVER_CACHE ?? "/tmp/lyra-covers";
const key = `${rgMbid}-${size}`;
const file = join(CACHE, `${key}.jpg`);
const miss = join(CACHE, `${key}.miss`);
try {
if (existsSync(file)) {
return new Response(await readFile(file), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE },
});
}
if (existsSync(miss)) {
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
}
const res = await fetch(`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`, {
headers: { "User-Agent": "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )" },
});
await mkdir(CACHE, { recursive: true });
if (!res.ok) {
await writeFile(miss, "").catch(() => {});
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
}
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(file, buf).catch(() => {});
return new Response(buf, { headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE } });
} catch {
return new Response(null, { status: 502 });
}
}
@@ -63,6 +63,20 @@ describe("discover action API", () => {
expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed");
});
it("dismissing an artist also dismisses its pending album suggestions", async () => {
const a = await prisma.discoverySuggestion.create({
data: { kind: "artist", artistMbid: "shared", artistName: "Cand", score: 1, seedCount: 1,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:shared:" },
});
const alb = await prisma.discoverySuggestion.create({
data: { kind: "album", artistMbid: "shared", artistName: "Cand", rgMbid: "rgS", album: "Rec",
albumReason: "newest", score: 1, seedCount: 1, sources: ["listenbrainz"],
secondaryTypes: [], dedupeKey: "album:shared:rgS" },
});
await post(a.id, { action: "dismiss" });
expect((await prisma.discoverySuggestion.findUnique({ where: { id: alb.id } }))!.status).toBe("dismissed");
});
it("want upserts a monitored release from the suggestion and marks wanted", async () => {
const s = await album("rgW");
const res = await post(s.id, { action: "want" });
+8
View File
@@ -19,6 +19,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
if (action === "dismiss") {
await prisma.discoverySuggestion.update({ where: { id }, data: { status: "dismissed" } });
// Dismissing an artist also dismisses its album suggestions, so the whole unified row
// disappears cleanly instead of leaving the artist's albums stranded.
if (s.kind === "artist") {
await prisma.discoverySuggestion.updateMany({
where: { kind: "album", artistMbid: s.artistMbid, status: "pending" },
data: { status: "dismissed" },
});
}
return Response.json({ id, status: "dismissed" });
}
+2
View File
@@ -5,6 +5,8 @@ const DEFAULTS: Record<string, string> = {
"discover.intervalHours": "168",
"discover.similarPerSeed": "20",
"discover.albumsPerArtist": "1",
"discover.albumsOnly": "true",
"discover.seedFromLibrary": "true",
"discover.minScore": "0",
"discover.chunkSize": "5",
"discover.listenBrainzUrl": "",
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST } from "./route";
describe("POST /api/discover/reset", () => {
it("sets the reset + requested flags so the worker rebuilds on the next sweep", async () => {
const res = await POST();
expect(res.status).toBe(202);
expect((await res.json()).reset).toBe(true);
const cfg = Object.fromEntries(
(await prisma.config.findMany({ where: { key: { in: ["discover.resetRequested", "discover.requested"] } } }))
.map((c) => [c.key, c.value]),
);
expect(cfg["discover.resetRequested"]).toBe("true");
expect(cfg["discover.requested"]).toBe("true");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { prisma } from "@/lib/db";
// POST /api/discover/reset — request a full rebuild: the worker clears the pending
// suggestions, contributions, and seed throttles at the start of the next sweep, then
// regenerates everything with the current logic. Kicks a sweep immediately.
export async function POST() {
await Promise.all(
["discover.resetRequested", "discover.requested"].map((key) =>
prisma.config.upsert({
where: { key },
create: { key, value: "true", secret: false },
update: { value: "true" },
}),
),
);
return Response.json({ reset: true }, { status: 202 });
}
+47 -8
View File
@@ -3,22 +3,61 @@ import { prisma } from "@/lib/db";
import { GET } from "./route";
describe("discover feed API", () => {
it("returns pending artists and albums split, highest score first, excluding non-pending", async () => {
it("groups pending suggestions into artist rows with their albums, highest score first", async () => {
await prisma.discoverySuggestion.createMany({
data: [
{ kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" },
{ kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" },
{ kind: "album", artistMbid: "a3", artistName: "Band", rgMbid: "rg1", album: "Rec",
score: 0.5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a3:rg1" },
{ kind: "artist", artistMbid: "a4", artistName: "Gone", score: 5, seedCount: 1,
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a4:" },
{ kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-new", album: "Latest",
firstReleaseDate: "2022-01-01", albumReason: "newest", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-new" },
{ kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-hit", album: "Big Hit",
firstReleaseDate: "2018-01-01", albumReason: "popular", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-hit" },
{ kind: "artist", artistMbid: "a3", artistName: "Gone", score: 5, seedCount: 1,
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a3:" },
],
});
const body = await (await GET()).json();
expect(body.artists.map((a: any) => a.artistName)).toEqual(["High", "Low"]); // dismissed excluded
expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]);
expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] });
expect(body.rows.map((r: { artistName: string }) => r.artistName)).toEqual(["High", "Low"]); // dismissed excluded, score desc
const high = body.rows[0];
expect(high).toMatchObject({ id: expect.any(String), artistMbid: "a2", seedCount: 2 });
// albums sorted newest-first, each carrying its reason
expect(high.albums.map((a: { album: string; albumReason: string }) => [a.album, a.albumReason]))
.toEqual([["Latest", "newest"], ["Big Hit", "popular"]]);
expect(body.rows[1].albums).toEqual([]); // "Low" has no album suggestions
});
it("yields a row for an album whose artist has no pending artist suggestion", async () => {
await prisma.discoverySuggestion.create({
data: { kind: "album", artistMbid: "orphan", artistName: "Orphan Band", rgMbid: "rgX",
album: "Stray", albumReason: "newest", score: 0.4, seedCount: 1, sources: ["lastfm"],
secondaryTypes: [], dedupeKey: "album:orphan:rgX" },
});
const body = await (await GET()).json();
expect(body.rows).toHaveLength(1);
expect(body.rows[0]).toMatchObject({ id: null, artistName: "Orphan Band" });
expect(body.rows[0].albums[0].album).toBe("Stray");
});
it("annotates rows with the followed-artist seeds that surfaced them", async () => {
await prisma.watchedArtist.createMany({
data: [{ mbid: "seed-am", name: "Arctic Monkeys" }, { mbid: "seed-cp", name: "Coldplay" }],
});
await prisma.discoverySuggestion.create({
data: { kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" },
});
await prisma.discoverySeedContribution.createMany({
data: [
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-am", score: 0.6, sources: ["listenbrainz"] },
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-cp", score: 0.3, sources: ["listenbrainz"] },
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "gone", score: 0.9, sources: ["listenbrainz"] },
],
});
const body = await (await GET()).json();
expect(body.rows[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // score desc, unfollowed dropped
});
});
+91 -17
View File
@@ -1,25 +1,99 @@
import { prisma } from "@/lib/db";
// One Discover row = a suggested artist plus that artist's suggested albums (newest /
// most-played), since album suggestions are always derived from a surfaced artist. Pending
// suggestions are grouped by artistMbid so the UI shows a single artist-centric row.
type Row = {
id: string | null; // the artist suggestion id (Follow/Dismiss); null if only albums exist
artistMbid: string;
artistName: string;
score: number;
seedCount: number;
sources: string[];
seeds: string[];
albums: {
id: string;
rgMbid: string | null;
album: string | null;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
albumReason: string | null;
}[];
};
export async function GET() {
const rows = await prisma.discoverySuggestion.findMany({
where: { status: "pending" },
orderBy: [{ score: "desc" }, { createdAt: "asc" }],
});
const map = (r: (typeof rows)[number]) => ({
id: r.id,
artistMbid: r.artistMbid,
artistName: r.artistName,
rgMbid: r.rgMbid,
album: r.album,
primaryType: r.primaryType,
secondaryTypes: r.secondaryTypes,
firstReleaseDate: r.firstReleaseDate,
score: r.score,
seedCount: r.seedCount,
sources: r.sources,
});
return Response.json({
artists: rows.filter((r) => r.kind === "artist").map(map),
albums: rows.filter((r) => r.kind === "album").map(map),
});
// "Why suggested": the followed artists (seeds) that surfaced each candidate, from the
// per-seed contributions joined to the current watched roster (an unfollowed seed drops out).
const candidateMbids = [...new Set(rows.map((r) => r.artistMbid))];
const contribs = candidateMbids.length
? await prisma.discoverySeedContribution.findMany({
where: { candidateMbid: { in: candidateMbids } },
orderBy: { score: "desc" },
})
: [];
const seedNames = new Map(
(await prisma.watchedArtist.findMany({ select: { mbid: true, name: true } })).map((w) => [w.mbid, w.name]),
);
const seedsByCandidate = new Map<string, string[]>();
for (const c of contribs) {
const name = seedNames.get(c.seedMbid);
if (!name) continue;
const list = seedsByCandidate.get(c.candidateMbid) ?? [];
if (!list.includes(name)) list.push(name);
seedsByCandidate.set(c.candidateMbid, list);
}
// Group by artist. An artist suggestion sets the row header; album suggestions fill albums[].
// An album whose artist has no pending artist suggestion still yields a header (never orphaned).
const byArtist = new Map<string, Row>();
const ensure = (r: (typeof rows)[number]): Row => {
let row = byArtist.get(r.artistMbid);
if (!row) {
row = {
id: null,
artistMbid: r.artistMbid,
artistName: r.artistName,
score: r.score,
seedCount: r.seedCount,
sources: r.sources,
seeds: seedsByCandidate.get(r.artistMbid) ?? [],
albums: [],
};
byArtist.set(r.artistMbid, row);
}
return row;
};
for (const r of rows) {
const row = ensure(r);
if (r.kind === "artist") {
row.id = r.id;
row.score = r.score;
row.seedCount = r.seedCount;
row.sources = r.sources;
} else {
row.albums.push({
id: r.id,
rgMbid: r.rgMbid,
album: r.album,
primaryType: r.primaryType,
secondaryTypes: r.secondaryTypes,
firstReleaseDate: r.firstReleaseDate,
albumReason: r.albumReason,
});
row.score = Math.max(row.score, r.score); // album-only row scores by its best album
}
}
for (const row of byArtist.values()) {
row.albums.sort((a, b) => (b.firstReleaseDate ?? "").localeCompare(a.firstReleaseDate ?? ""));
}
const out = [...byArtist.values()].sort((a, b) => b.score - a.score);
return Response.json({ rows: out });
}
+12 -2
View File
@@ -12,10 +12,20 @@ describe("discover run API", () => {
const body = await (await GET()).json();
expect(body.requested).toBe(true);
expect(body.result).toBeNull();
expect(body.inProgress).toBe(false);
expect(body.lastRunAt).toBeNull();
});
it("GET returns discover.result when the worker has written it", async () => {
it("GET returns discover.result + lastRunAt when the worker has written it", async () => {
await prisma.config.create({ data: { key: "discover.result", value: "artists 3, albums 1" } });
expect((await (await GET()).json()).result).toBe("artists 3, albums 1");
const body = await (await GET()).json();
expect(body.result).toBe("artists 3, albums 1");
expect(body.lastRunAt).not.toBeNull();
expect(Number.isNaN(Date.parse(body.lastRunAt))).toBe(false); // a real ISO timestamp
});
it("GET reports inProgress from discover.inProgress", async () => {
await prisma.config.create({ data: { key: "discover.inProgress", value: "true" } });
expect((await (await GET()).json()).inProgress).toBe(true);
});
});
+8 -1
View File
@@ -10,12 +10,19 @@ export async function POST(_request: Request) {
}
export async function GET() {
const [requested, result] = await Promise.all([
const [requested, inProgress, result] = await Promise.all([
prisma.config.findUnique({ where: { key: "discover.requested" } }),
prisma.config.findUnique({ where: { key: "discover.inProgress" } }),
prisma.config.findUnique({ where: { key: "discover.result" } }),
]);
return Response.json({
// a sweep is either queued (requested) or actively running (inProgress) — the UI treats
// both as "running" so "Discovering…" persists for the whole sweep, not just until the
// worker claims the request.
requested: requested?.value === "true",
inProgress: inProgress?.value === "true",
result: result?.value ?? null,
// when the last completed sweep wrote its result — the "last discovered N ago" signal.
lastRunAt: result?.updatedAt?.toISOString() ?? null,
});
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
import { prisma } from "@/lib/db";
function post(action: string) {
return new Request("http://localhost/api/floor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
}
async function flag(key: string) {
return (await prisma.config.findUnique({ where: { key } }))?.value ?? null;
}
async function queuedRequest(album: string, state = "requested") {
return prisma.request.create({
data: { artist: "A", album, status: "pending", job: { create: { state: state as never } } },
include: { job: true },
});
}
describe("floor API", () => {
it("GET reports paused=false by default", async () => {
const res = await GET();
expect(await res.json()).toEqual({ paused: false });
});
it("pause and resume toggle floor.paused", async () => {
await POST(post("pause"));
expect(await flag("floor.paused")).toBe("true");
const paused = await (await GET()).json();
expect(paused).toEqual({ paused: true });
await POST(post("resume"));
expect(await flag("floor.paused")).toBe("false");
});
it("stop sets both paused and killRequested", async () => {
const res = await POST(post("stop"));
expect(res.status).toBe(200);
expect(await flag("floor.paused")).toBe("true");
expect(await flag("floor.killRequested")).toBe("true");
});
it("clear deletes only queued (requested) requests", async () => {
const queued = await queuedRequest("Queued");
const active = await queuedRequest("Downloading", "downloading");
const res = await POST(post("clear"));
const body = await res.json();
expect(body.cleared).toBe(1);
expect(await prisma.request.findUnique({ where: { id: queued.id } })).toBeNull();
expect(await prisma.request.findUnique({ where: { id: active.id } })).not.toBeNull();
});
it("rejects an unknown action", async () => {
const res = await POST(post("nope"));
expect(res.status).toBe(400);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { prisma } from "@/lib/db";
async function getFlag(key: string): Promise<boolean> {
const row = await prisma.config.findUnique({ where: { key } });
return row?.value === "true";
}
async function setFlag(key: string, value: boolean): Promise<void> {
const v = value ? "true" : "false";
await prisma.config.upsert({
where: { key },
create: { key, value: v, secret: false },
update: { value: v },
});
}
export async function GET() {
return Response.json({ paused: await getFlag("floor.paused") });
}
export async function POST(request: Request) {
let body: { action?: string };
try {
body = (await request.json()) as { action?: string };
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
switch (body.action) {
case "pause":
await setFlag("floor.paused", true);
return Response.json({ ok: true, paused: true });
case "resume":
await setFlag("floor.paused", false);
return Response.json({ ok: true, paused: false });
case "stop":
await setFlag("floor.paused", true);
await setFlag("floor.killRequested", true);
return Response.json({ ok: true, paused: true, stopped: true });
case "clear": {
const { count } = await prisma.request.deleteMany({
where: { job: { is: { state: "requested" } } },
});
return Response.json({ ok: true, cleared: count });
}
default:
return Response.json({ error: "unknown action" }, { status: 400 });
}
}
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from "vitest";
import { GET } from "./route";
describe("GET /api/health", () => {
it("returns 200 ok", async () => {
const res = GET();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});
});
+5
View File
@@ -0,0 +1,5 @@
// Unauthenticated liveness endpoint for the docker-compose healthcheck. Excluded from the
// auth gate in middleware.ts.
export function GET() {
return Response.json({ ok: true });
}
+32
View File
@@ -0,0 +1,32 @@
import { prisma } from "@/lib/db";
import { decryptSecret } from "@/lib/crypto";
import type { LfmPeriod } from "@/lib/lastfm";
// Shared by the three /api/lastfm routes (was copy-pasted three times). Route-only module
// (underscore prefix ⇒ not a route), so importing prisma here never reaches a client bundle.
export const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
export const LIMIT = 50;
export async function getLastfmCreds(): Promise<{ username: string; apiKey: string } | null> {
const rows = await prisma.config.findMany({
where: { key: { in: ["lastfm.username", "lastfm.api_key"] } },
});
const byKey = new Map(rows.map((r) => [r.key, r]));
const username = byKey.get("lastfm.username")?.value ?? "";
const keyRow = byKey.get("lastfm.api_key");
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
if (!username || !apiKey) return null;
return { username, apiKey };
}
/** The `period` query param, validated to a supported LfmPeriod (default "overall"). */
export function parsePeriod(url: URL): LfmPeriod {
const p = url.searchParams.get("period") ?? "overall";
return (PERIODS as string[]).includes(p) ? (p as LfmPeriod) : "overall";
}
/** Whether a manual Refresh (cache bypass) was requested. */
export function wantsRefresh(url: URL): boolean {
return url.searchParams.get("refresh") === "1";
}
+12 -18
View File
@@ -1,30 +1,24 @@
import { prisma } from "@/lib/db";
import { decryptSecret } from "@/lib/crypto";
import { artistPlays } from "@/lib/lastfm";
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
const byKey = new Map(rows.map((r) => [r.key, r]));
const username = byKey.get("lastfm.username")?.value ?? "";
const keyRow = byKey.get("lastfm.api_key");
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
if (!username || !apiKey) return null;
return { username, apiKey };
}
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
import { getLastfmCreds, wantsRefresh } from "../_creds";
export async function GET(request: Request) {
const creds = await getCreds();
const creds = await getLastfmCreds();
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
const artist = new URL(request.url).searchParams.get("artist")?.trim();
const url = new URL(request.url);
const artist = url.searchParams.get("artist")?.trim();
if (!artist) return Response.json({ error: "artist is required" }, { status: 400 });
let plays;
try {
plays = await artistPlays(creds.username, artist, creds.apiKey);
const plays = await cached(
`lfm-artist-plays:${normKey(creds.username)}:${normKey(artist)}`,
12 * CACHE_TTL.HOUR,
() => artistPlays(creds.username, artist, creds.apiKey),
{ force: wantsRefresh(url) },
);
return Response.json(plays);
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
}
return Response.json(plays);
}
+12 -19
View File
@@ -1,36 +1,29 @@
import { prisma } from "@/lib/db";
import { decryptSecret } from "@/lib/crypto";
import { topAlbums, type LfmPeriod } from "@/lib/lastfm";
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
const LIMIT = 50;
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
const byKey = new Map(rows.map((r) => [r.key, r]));
const username = byKey.get("lastfm.username")?.value ?? "";
const keyRow = byKey.get("lastfm.api_key");
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
if (!username || !apiKey) return null;
return { username, apiKey };
}
import { topAlbums } from "@/lib/lastfm";
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
function albumKey(artist: string, album: string): string {
return `${artist} ${album}`.trim().toLowerCase();
}
export async function GET(request: Request) {
const creds = await getCreds();
const creds = await getLastfmCreds();
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
const url = new URL(request.url);
const periodParam = url.searchParams.get("period") ?? "overall";
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
const period = parsePeriod(url);
const page = Number(url.searchParams.get("page")) || 1;
// Cache ONLY the raw Last.fm payload; own-state (owned/wanted) is annotated live below.
let result;
try {
result = await topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT });
result = await cached(
`lfm-top-albums:${normKey(creds.username)}:${period}:${page}`,
12 * CACHE_TTL.HOUR,
() => topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT }),
{ force: wantsRefresh(url) },
);
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
}
@@ -64,6 +64,23 @@ describe("GET /api/lastfm/top-artists", () => {
expect(body.artists[0]).toMatchObject({ followed: true });
});
it("caches the raw payload (2nd load doesn't re-hit Last.fm) but re-annotates own-state live", async () => {
await seedConfig();
mockLfmArtists([{ name: "Cached Band", playcount: "9", mbid: "mb-cache" }]);
await (await GET(req())).json(); // first load → Last.fm hit + cached
expect(global.fetch).toHaveBeenCalledTimes(1);
// follow the artist AFTER the payload was cached
await prisma.watchedArtist.create({ data: { mbid: "mb-cache", name: "Cached Band" } });
const body = await (await GET(req())).json();
expect(global.fetch).toHaveBeenCalledTimes(1); // served from cache, no 2nd Last.fm call
expect(body.artists[0]).toMatchObject({ followed: true }); // own-state annotated live
// ?refresh=1 bypasses the cache and re-hits Last.fm
await (await GET(req("?refresh=1"))).json();
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("passes period/page query params through and returns 502 when Last.fm throws", async () => {
await seedConfig();
vi.spyOn(global, "fetch").mockResolvedValue(
+13 -19
View File
@@ -1,32 +1,26 @@
import { prisma } from "@/lib/db";
import { decryptSecret } from "@/lib/crypto";
import { topArtists, type LfmPeriod } from "@/lib/lastfm";
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
const LIMIT = 50;
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
const byKey = new Map(rows.map((r) => [r.key, r]));
const username = byKey.get("lastfm.username")?.value ?? "";
const keyRow = byKey.get("lastfm.api_key");
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
if (!username || !apiKey) return null;
return { username, apiKey };
}
import { topArtists } from "@/lib/lastfm";
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
export async function GET(request: Request) {
const creds = await getCreds();
const creds = await getLastfmCreds();
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
const url = new URL(request.url);
const periodParam = url.searchParams.get("period") ?? "overall";
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
const period = parsePeriod(url);
const page = Number(url.searchParams.get("page")) || 1;
// Cache ONLY the raw Last.fm payload; own-state (followed/owned) is annotated live below so
// a follow made after the payload was cached still shows immediately.
let result;
try {
result = await topArtists(creds.username, creds.apiKey, { period, page, limit: LIMIT });
result = await cached(
`lfm-top-artists:${normKey(creds.username)}:${period}:${page}`,
12 * CACHE_TTL.HOUR,
() => topArtists(creds.username, creds.apiKey, { period, page, limit: LIMIT }),
{ force: wantsRefresh(url) },
);
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import { DELETE, PATCH } from "./route";
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
import { searchReleaseGroup } from "@/lib/musicbrainz";
const mockSearch = vi.mocked(searchReleaseGroup);
let root: string;
const original = process.env.LYRA_LIBRARY_ROOT;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lyra-lib-"));
process.env.LYRA_LIBRARY_ROOT = root;
});
afterEach(() => {
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
else process.env.LYRA_LIBRARY_ROOT = original;
rmSync(root, { recursive: true, force: true });
});
function req(id: string) {
return { params: Promise.resolve({ id }) };
}
describe("DELETE /api/library/[id]", () => {
it("deletes the folder, the record, and stops monitoring the release", async () => {
const dir = join(root, "Artist", "Album (2020)");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "01 Track.flac"), "audio");
const item = await prisma.libraryItem.create({
data: { artist: "Artist", album: "Album", path: dir, source: "scan", format: "FLAC", qualityClass: 3 },
});
const wa = await prisma.watchedArtist.create({ data: { mbid: "a1", name: "Artist" } });
await prisma.monitoredRelease.create({
data: { watchedArtistId: wa.id, artistMbid: "a1", artistName: "Artist", rgMbid: "rg1",
album: "Album", secondaryTypes: [], monitored: true, state: "fulfilled", currentQualityClass: 3 },
});
const res = await DELETE(new Request("http://x"), req(item.id));
expect(res.status).toBe(200);
expect(existsSync(dir)).toBe(false); // files gone
expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).toBeNull();
const mr = await prisma.monitoredRelease.findFirst({ where: { rgMbid: "rg1" } });
expect(mr).toMatchObject({ monitored: false, currentQualityClass: null }); // won't be re-grabbed
});
it("404s for an unknown id", async () => {
expect((await DELETE(new Request("http://x"), req("nope"))).status).toBe(404);
});
it("refuses to delete a path outside the library root", async () => {
const item = await prisma.libraryItem.create({
data: { artist: "X", album: "Y", path: "/etc", source: "scan", format: "FLAC", qualityClass: 1 },
});
const res = await DELETE(new Request("http://x"), req(item.id));
expect(res.status).toBe(400);
// the record is preserved when the path guard trips
expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).not.toBeNull();
});
});
function patchReq(body: unknown) {
return new Request("http://x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}
describe("PATCH /api/library/[id]", () => {
beforeEach(() => mockSearch.mockReset());
it("renames the folder, updates the record, and re-resolves rgMbid on an artist/album change", async () => {
const dir = join(root, "Old Artist", "Old Album (2019)");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "01 Track.flac"), "audio");
const item = await prisma.libraryItem.create({
data: { artist: "Old Artist", album: "Old Album", path: dir, source: "scan", format: "FLAC", qualityClass: 3, rgMbid: "old-rg" },
});
mockSearch.mockResolvedValue({ rgMbid: "new-rg", title: "New Album", artistMbid: "x", artistName: "New Artist" } as never);
const res = await PATCH(patchReq({ artist: "New Artist", album: "New Album", year: "2020" }), req(item.id));
expect(res.status).toBe(200);
const moved = join(root, "New Artist", "New Album (2020)");
expect(existsSync(moved)).toBe(true);
expect(existsSync(dir)).toBe(false);
const row = (await prisma.libraryItem.findUnique({ where: { id: item.id } }))!;
expect(row).toMatchObject({ artist: "New Artist", album: "New Album", path: moved, rgMbid: "new-rg" });
});
it("changing only the year renames without calling MusicBrainz", async () => {
const dir = join(root, "A", "B (2000)");
mkdirSync(dir, { recursive: true });
const item = await prisma.libraryItem.create({
data: { artist: "A", album: "B", path: dir, source: "scan", format: "FLAC", qualityClass: 2, rgMbid: "keep" },
});
const res = await PATCH(patchReq({ year: "2001" }), req(item.id));
expect(res.status).toBe(200);
expect(mockSearch).not.toHaveBeenCalled();
expect(existsSync(join(root, "A", "B (2001)"))).toBe(true);
expect((await prisma.libraryItem.findUnique({ where: { id: item.id } }))!.rgMbid).toBe("keep");
});
it("409s when the target folder already exists", async () => {
mkdirSync(join(root, "A", "B (2000)"), { recursive: true });
mkdirSync(join(root, "A", "C (2000)"), { recursive: true });
const item = await prisma.libraryItem.create({
data: { artist: "A", album: "B", path: join(root, "A", "B (2000)"), source: "scan", format: "FLAC", qualityClass: 2 },
});
const res = await PATCH(patchReq({ album: "C" }), req(item.id));
expect(res.status).toBe(409);
});
it("validates input and 404s an unknown id", async () => {
const item = await prisma.libraryItem.create({
data: { artist: "A", album: "B", path: join(root, "A", "B"), source: "scan", format: "FLAC", qualityClass: 1 },
});
expect((await PATCH(patchReq({ year: "20" }), req(item.id))).status).toBe(400); // bad year
expect((await PATCH(patchReq({ artist: "" }), req(item.id))).status).toBe(400); // empty artist
expect((await PATCH(patchReq({ album: "Z" }), req("nope"))).status).toBe(404);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { existsSync } from "node:fs";
import { mkdir, rename, rmdir } from "node:fs/promises";
import { dirname, resolve, sep } from "node:path";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { albumDir, yearFromPath } from "@/lib/library-path";
import { searchReleaseGroup } from "@/lib/musicbrainz";
import { removeLibraryItem } from "@/lib/library-ops";
function insideRoot(root: string, target: string): boolean {
return target !== root && target.startsWith(root + sep);
}
// PATCH /api/library/[id] — fix an owned album's metadata: rename its folder to the canonical
// Artist/Album (Year) scheme on disk, update the LibraryItem, and re-resolve the MusicBrainz
// release-group (for cover art) when the artist/album changed. Does NOT rewrite the audio
// files' embedded tags — that's a worker (mutagen) job; Lyra reads from the folder + DB.
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const { artist, album, year } = (body ?? {}) as { artist?: unknown; album?: unknown; year?: unknown };
const strOrUndef = (v: unknown) => (v === undefined ? undefined : typeof v === "string" ? v.trim() : null);
const nextArtist = strOrUndef(artist);
const nextAlbum = strOrUndef(album);
const nextYear = strOrUndef(year); // "" clears the year; a 4-digit string sets it
if (nextArtist === null || nextAlbum === null || nextYear === null) {
return Response.json({ error: "artist, album, year must be strings" }, { status: 400 });
}
if (nextArtist === undefined && nextAlbum === undefined && nextYear === undefined) {
return Response.json({ error: "nothing to update" }, { status: 400 });
}
if (nextArtist === "" || nextAlbum === "") {
return Response.json({ error: "artist and album cannot be empty" }, { status: 400 });
}
if (nextYear && !/^\d{4}$/.test(nextYear)) {
return Response.json({ error: "year must be a 4-digit year or blank" }, { status: 400 });
}
const item = await prisma.libraryItem.findUnique({ where: { id } });
if (!item) return Response.json({ error: "not found" }, { status: 404 });
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
const oldPath = resolve(item.path);
if (!insideRoot(root, oldPath)) {
return Response.json({ error: "refusing to touch a path outside the library" }, { status: 400 });
}
const finalArtist = nextArtist ?? item.artist;
const finalAlbum = nextAlbum ?? item.album;
// year isn't stored on LibraryItem; it lives only in the folder name. Keep the current
// folder's year unless the caller passed one (empty string clears it).
const finalYear = nextYear === undefined ? (yearFromPath(item.path) ?? undefined) : nextYear || undefined;
const newPath = albumDir(root, finalArtist, finalAlbum, finalYear);
if (newPath !== oldPath) {
if (!insideRoot(root, newPath)) {
return Response.json({ error: "refusing to write outside the library" }, { status: 400 });
}
if (existsSync(newPath)) {
return Response.json({ error: "a folder for that artist/album/year already exists" }, { status: 409 });
}
try {
await mkdir(dirname(newPath), { recursive: true });
await rename(oldPath, newPath);
const oldParent = dirname(oldPath);
if (oldParent !== root && oldParent !== dirname(newPath)) await rmdir(oldParent).catch(() => {});
} catch {
return Response.json({ error: "failed to rename the album folder" }, { status: 500 });
}
}
// Re-resolve the release-group when the artist/album changed (year alone doesn't change it).
// Best-effort: on an MB outage, keep the existing rgMbid rather than dropping the cover art.
let rgMbid = item.rgMbid;
if (nextArtist !== undefined || nextAlbum !== undefined) {
try {
rgMbid = (await searchReleaseGroup(finalArtist, finalAlbum))?.rgMbid ?? null;
} catch {
/* keep existing rgMbid */
}
}
try {
const updated = await prisma.libraryItem.update({
where: { id },
data: { artist: finalArtist, album: finalAlbum, path: newPath, rgMbid },
});
return Response.json({ id: updated.id, artist: updated.artist, album: updated.album, path: updated.path, rgMbid: updated.rgMbid });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
// moved the folder already; the (artist, album) row collides with another library item
return Response.json({ error: "another library item already has that artist/album" }, { status: 409 });
}
throw e;
}
}
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const r = await removeLibraryItem(id);
return r.ok ? Response.json({ ok: true }) : Response.json({ error: r.error }, { status: r.status });
}
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST } from "./route";
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
async function owned(artist: string, album: string, rgMbid: string | null) {
return prisma.libraryItem.create({
data: { artist, album, path: "/m", source: "scan", format: "FLAC", qualityClass: 2, rgMbid },
});
}
describe("POST /api/library/[id]/upgrade", () => {
it("enqueues a forced re-acquire request against the matching release", async () => {
await prisma.monitoredRelease.create({
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-25", album: "25", secondaryTypes: [],
monitored: true, state: "fulfilled", currentQualityClass: 2 },
});
const item = await owned("Adele", "25", "rg-25");
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
expect(res.status).toBe(202);
expect((await res.json()).enqueued).toBe(true);
const reqRow = await prisma.request.findFirst({ where: { album: "25" }, include: { job: true } });
expect(reqRow?.force).toBe(true);
expect(reqRow?.monitoredReleaseId).toBeTruthy();
expect(reqRow?.job).toBeTruthy();
});
it("400s when there is no matching release", async () => {
const item = await owned("Nobody", "Untracked", null);
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
expect(res.status).toBe(400);
});
it("does not stack a second job while one is in flight", async () => {
const mr = await prisma.monitoredRelease.create({
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-21", album: "21", secondaryTypes: [], monitored: true, state: "wanted" },
});
const item = await owned("Adele", "21", "rg-21");
await prisma.request.create({
data: { artist: "Adele", album: "21", monitoredReleaseId: mr.id, job: { create: { state: "downloading" } } },
});
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
expect(res.status).toBe(202);
expect((await res.json()).enqueued).toBe(false);
});
it("404s an unknown library item", async () => {
const res = await POST(new Request("http://x", { method: "POST" }), ctx("nope"));
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
// POST /api/library/[id]/upgrade — force a re-acquire of an owned album: enqueue a job that
// skips the "already in library" dedupe (Request.force). The pipeline downloads the best
// available copy and the import step keeps it only if it's higher quality, so this can upgrade
// but never downgrades. Requires a matching MonitoredRelease (scanned/monitored albums have
// one) to carry the acquisition context.
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const item = await prisma.libraryItem.findUnique({ where: { id } });
if (!item) return Response.json({ error: "not found" }, { status: 404 });
// Prefer the release-group match; fall back to an artist+album match.
const release =
(item.rgMbid ? await prisma.monitoredRelease.findUnique({ where: { rgMbid: item.rgMbid } }) : null) ??
(await prisma.monitoredRelease.findFirst({ where: { artistName: item.artist, album: item.album } }));
if (!release) {
return Response.json(
{ error: "no release info for this album — follow the artist or re-scan to enable upgrades" },
{ status: 400 },
);
}
// Don't stack a second job on a release that's already in flight.
const activeJob = await prisma.job.findFirst({
where: { request: { monitoredReleaseId: release.id }, state: { notIn: ["imported", "needs_attention"] } },
});
if (activeJob) return Response.json({ enqueued: false }, { status: 202 });
await prisma.request.create({
data: {
artist: release.artistName,
album: release.album,
monitoredReleaseId: release.id,
force: true,
job: { create: {} },
},
});
await prisma.monitoredRelease.update({ where: { id: release.id }, data: { lastSearchedAt: new Date() } });
return Response.json({ enqueued: true }, { status: 202 });
}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import { POST } from "./route";
let root: string;
const original = process.env.LYRA_LIBRARY_ROOT;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lyra-bulk-"));
process.env.LYRA_LIBRARY_ROOT = root;
});
afterEach(() => {
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
else process.env.LYRA_LIBRARY_ROOT = original;
rmSync(root, { recursive: true, force: true });
});
function post(body: unknown) {
return POST(new Request("http://x", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }));
}
describe("POST /api/library/bulk", () => {
it("deletes several albums and their folders", async () => {
const ids: string[] = [];
for (const [artist, album] of [["A", "One"], ["B", "Two"]]) {
const dir = join(root, artist, album);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "01.flac"), "x");
ids.push((await prisma.libraryItem.create({ data: { artist, album, path: dir, source: "scan", format: "FLAC", qualityClass: 2 } })).id);
}
const res = await post({ action: "delete", ids });
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ action: "delete", ok: 2, failed: [] });
expect(await prisma.libraryItem.count()).toBe(0);
expect(existsSync(join(root, "A"))).toBe(false);
});
it("ignores the matching releases for the selected albums", async () => {
const mr = await prisma.monitoredRelease.create({
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-21", album: "21", secondaryTypes: [], monitored: true, state: "wanted" },
});
const li = await prisma.libraryItem.create({ data: { artist: "Adele", album: "21", path: join(root, "Adele", "21"), source: "scan", format: "FLAC", qualityClass: 2, rgMbid: "rg-21" } });
const res = await post({ action: "ignore", ids: [li.id] });
expect((await res.json()).ok).toBe(1);
expect((await prisma.monitoredRelease.findUnique({ where: { id: mr.id } }))!.ignored).toBe(true);
});
it("rejects a bad action or empty ids", async () => {
expect((await post({ action: "nuke", ids: ["x"] })).status).toBe(400);
expect((await post({ action: "delete", ids: [] })).status).toBe(400);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { removeLibraryItem, ignoreLibraryItem, type OpResult } from "@/lib/library-ops";
// POST /api/library/bulk — apply an action to several owned albums at once.
// Body: { action: "delete" | "ignore", ids: string[] }. Returns per-id success counts.
const ACTIONS: Record<string, (id: string) => Promise<OpResult>> = {
delete: removeLibraryItem,
ignore: ignoreLibraryItem,
};
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const { action, ids } = (body ?? {}) as { action?: unknown; ids?: unknown };
if (typeof action !== "string" || !(action in ACTIONS)) {
return Response.json({ error: "action must be 'delete' or 'ignore'" }, { status: 400 });
}
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((x) => typeof x === "string")) {
return Response.json({ error: "ids must be a non-empty string array" }, { status: 400 });
}
const op = ACTIONS[action];
// Run sequentially — deletes touch the filesystem and prune shared artist folders; serial
// keeps that predictable and avoids hammering the mount.
let ok = 0;
const failed: string[] = [];
for (const id of ids as string[]) {
const r = await op(id);
if (r.ok) ok++;
else failed.push(id);
}
return Response.json({ action, ok, failed });
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { GET } from "./route";
async function li(artist: string, album: string, rgMbid: string | null, path = "/m") {
return prisma.libraryItem.create({
data: { artist, album, path, source: "scan", format: "FLAC", qualityClass: 2, rgMbid },
});
}
describe("library duplicates API", () => {
it("groups items sharing a release-group", async () => {
await li("Adele", "21", "rg-21");
await li("Adele", "21 (Deluxe)", "rg-21"); // same rg → duplicate
await li("Adele", "25", "rg-25"); // unrelated
const { duplicates } = await (await GET()).json();
expect(duplicates).toHaveLength(1);
expect(duplicates[0].map((d: { album: string }) => d.album).sort()).toEqual(["21", "21 (Deluxe)"]);
});
it("groups items with the same edition-normalized title even without a shared rgMbid", async () => {
await li("Nirvana", "Nevermind", null);
await li("Nirvana", "Nevermind (Deluxe Edition)", "rg-x");
const { duplicates } = await (await GET()).json();
expect(duplicates).toHaveLength(1);
expect(duplicates[0]).toHaveLength(2);
});
it("returns nothing when there are no duplicates", async () => {
await li("A", "One", "rg-1");
await li("B", "Two", "rg-2");
const { duplicates } = await (await GET()).json();
expect(duplicates).toEqual([]);
});
});
@@ -0,0 +1,61 @@
import { prisma } from "@/lib/db";
import { stripEditionQualifiers } from "@/lib/musicbrainz";
import { yearFromPath } from "@/lib/library-path";
// GET /api/library/duplicates — groups of library items that look like the same album held
// more than once: sharing a MusicBrainz release-group, or the same artist + edition-normalized
// title (e.g. "Album" vs "Album (Deluxe Edition)"). The (artist, album) unique constraint
// rules out exact dupes, so these are the near-dupes worth surfacing.
function normTitleKey(artist: string, album: string): string {
return `${artist.trim().toLowerCase()}|${stripEditionQualifiers(album).trim().toLowerCase()}`;
}
export async function GET() {
const items = await prisma.libraryItem.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] });
// Union-find: connect items that share a non-null rgMbid or the same normalized title key.
const parent = new Map<string, string>();
const find = (x: string): string => {
let r = x;
while (parent.get(r) !== r) r = parent.get(r)!;
while (parent.get(x) !== r) { const n = parent.get(x)!; parent.set(x, r); x = n; }
return r;
};
const union = (a: string, b: string) => { parent.set(find(a), find(b)); };
for (const it of items) parent.set(it.id, it.id);
const byRg = new Map<string, string>();
const byTitle = new Map<string, string>();
for (const it of items) {
if (it.rgMbid) {
const seen = byRg.get(it.rgMbid);
if (seen) union(seen, it.id); else byRg.set(it.rgMbid, it.id);
}
const tk = normTitleKey(it.artist, it.album);
const seenT = byTitle.get(tk);
if (seenT) union(seenT, it.id); else byTitle.set(tk, it.id);
}
const groups = new Map<string, typeof items>();
for (const it of items) {
const root = find(it.id);
(groups.get(root) ?? groups.set(root, []).get(root)!).push(it);
}
const duplicates = [...groups.values()]
.filter((g) => g.length > 1)
.map((g) =>
g.map((it) => ({
id: it.id,
artist: it.artist,
album: it.album,
format: it.format,
qualityClass: it.qualityClass,
rgMbid: it.rgMbid,
year: yearFromPath(it.path),
path: it.path,
})),
);
return Response.json({ duplicates });
}
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn(async () => null) }));
import { mkdtempSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import { POST } from "./route";
let root: string;
const original = process.env.LYRA_LIBRARY_ROOT;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lyra-imp-"));
process.env.LYRA_LIBRARY_ROOT = root;
});
afterEach(() => {
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
else process.env.LYRA_LIBRARY_ROOT = original;
rmSync(root, { recursive: true, force: true });
});
function req(fields: Record<string, string>, files: [string, string][]) {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
for (const [name, content] of files) fd.append("files", new File([content], name));
return new Request("http://x", { method: "POST", body: fd });
}
describe("POST /api/library/import", () => {
it("writes the album to disk and records a LibraryItem with on-disk tracks", async () => {
const res = await POST(req(
{ artist: "Portishead", album: "Dummy", year: "1994" },
[["02 Sour Times.flac", "b"], ["01 Mysterons.flac", "a"], ["cover.jpg", "img"]],
));
expect(res.status).toBe(201);
expect((await res.json()).tracks).toBe(2);
const dir = join(root, "Portishead", "Dummy (1994)");
expect(readdirSync(dir).sort()).toEqual(["01 Mysterons.flac", "02 Sour Times.flac", "cover.jpg"]);
expect(readFileSync(join(dir, "01 Mysterons.flac"), "utf8")).toBe("a");
expect(existsSync(dir + ".importing")).toBe(false); // temp dir renamed away
const item = await prisma.libraryItem.findFirst({ where: { artist: "Portishead", album: "Dummy" } });
expect(item).toMatchObject({ source: "manual", format: "FLAC", qualityClass: 2 });
expect(item!.trackNames).toEqual(["01 Mysterons.flac", "02 Sour Times.flac"]); // sorted
});
it("400s without artist/album or without audio", async () => {
expect((await POST(req({ album: "X" }, [["01.flac", "a"]]))).status).toBe(400);
expect((await POST(req({ artist: "A", album: "X" }, [["notes.txt", "a"]]))).status).toBe(400);
});
it("409s when the album is already in the library", async () => {
await prisma.libraryItem.create({
data: { artist: "A", album: "X", path: join(root, "A", "X"), source: "scan", format: "FLAC", qualityClass: 2 },
});
expect((await POST(req({ artist: "A", album: "X" }, [["01.flac", "a"]]))).status).toBe(409);
});
it("sanitizes filesystem-illegal characters in the folder name", async () => {
const res = await POST(req({ artist: "AC/DC", album: 'Back in "Black"' }, [["01.flac", "a"]]));
expect(res.status).toBe(201);
expect(existsSync(join(root, "AC_DC", 'Back in _Black_'))).toBe(true);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { createWriteStream, existsSync } from "node:fs";
import { mkdir, rename, rm } from "node:fs/promises";
import { resolve, join, dirname, extname } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { randomUUID } from "node:crypto";
import Busboy from "busboy";
import { prisma } from "@/lib/db";
import { isAuthed } from "@/lib/auth";
import { searchReleaseGroup } from "@/lib/musicbrainz";
import { safeName, albumDir } from "@/lib/library-path";
// Manually import an album (a ripped CD / files already on the PC): upload the audio (+ an
// optional cover) with artist/album/year, and Lyra writes it into the library. The web
// container bind-mounts /music, so it can write directly. A later library Scan MB-resolves it
// (cover art, canonical types) and re-probes quality.
//
// The body is STREAMED to disk with busboy rather than buffered via request.formData(), which
// caps out around ~15MB — real FLAC albums are hundreds of MB.
const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]);
const LOSSLESS_EXT = new Set([".flac", ".wav"]);
export async function POST(request: Request) {
// This route is excluded from the auth middleware (to avoid the 10MB body cap), so it must
// authenticate itself.
if (!(await isAuthed(request))) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
const ct = request.headers.get("content-type") ?? "";
if (!ct.includes("multipart/form-data") || !request.body) {
return Response.json({ error: "expected multipart form data" }, { status: 400 });
}
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
// stage under a hidden dir on the library filesystem so the final move is an atomic rename
// (the scan skips dot-dirs). Cleaned up on any failure.
const staging = join(root, `.import-${randomUUID()}`);
await mkdir(staging, { recursive: true });
const fields: Record<string, string> = {};
const audioNames: string[] = [];
let hasCover = false;
try {
await new Promise<void>((res, rej) => {
const bb = Busboy({ headers: { "content-type": ct } });
const writes: Promise<void>[] = [];
bb.on("field", (name, val) => {
fields[name] = val;
});
bb.on("file", (_name, stream, info) => {
const ext = extname(info.filename).toLowerCase();
if (AUDIO_EXT.has(ext)) {
const safe = safeName(info.filename);
audioNames.push(safe);
writes.push(pipeline(stream, createWriteStream(join(staging, safe))));
} else if (/\.(jpe?g|png)$/i.test(info.filename) && !hasCover) {
hasCover = true;
writes.push(pipeline(stream, createWriteStream(join(staging, "cover.jpg"))));
} else {
stream.resume(); // discard anything that isn't audio or a cover
}
});
bb.on("error", rej);
bb.on("close", () => Promise.all(writes).then(() => res(), rej));
Readable.fromWeb(request.body as import("node:stream/web").ReadableStream).pipe(bb);
});
} catch {
await rm(staging, { recursive: true, force: true });
return Response.json({ error: "failed to write the upload to disk" }, { status: 500 });
}
const artist = (fields.artist ?? "").trim();
const album = (fields.album ?? "").trim();
const year = (fields.year ?? "").trim();
const audio = audioNames.sort((a, b) => a.localeCompare(b));
const bad =
!artist || !album
? "artist and album are required"
: audio.length === 0
? "no audio files provided"
: year && !/^\d{4}$/.test(year)
? "year must be 4 digits"
: null;
if (bad) {
await rm(staging, { recursive: true, force: true });
return Response.json({ error: bad }, { status: 400 });
}
const finalDir = albumDir(root, artist, album, year || undefined);
if (existsSync(finalDir) || (await prisma.libraryItem.findFirst({ where: { artist, album } }))) {
await rm(staging, { recursive: true, force: true });
return Response.json({ error: "this album is already in the library" }, { status: 409 });
}
await mkdir(dirname(finalDir), { recursive: true });
await rename(staging, finalDir);
// Resolve the release group so the album gets cover art immediately (best-effort — a
// no-match just leaves rgMbid null; a later Scan can still match it). Uses the shared MB cache.
let rgMbid: string | null = null;
try {
rgMbid = (await searchReleaseGroup(artist, album))?.rgMbid ?? null;
} catch {
/* MB unavailable — carry on without cover art */
}
const firstExt = extname(audio[0]).toLowerCase();
await prisma.libraryItem.create({
data: {
artist,
album,
path: finalDir,
source: "manual",
format: firstExt.replace(".", "").toUpperCase(),
// rough class from the extension (2 = CD-lossless, 1 = lossy); a later scan re-probes and
// can upgrade a hi-res file to class 3.
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
rgMbid,
trackNames: audio,
},
});
return Response.json({ ok: true, tracks: audio.length }, { status: 201 });
}
@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import { GET } from "./route";
let root: string;
const original = process.env.LYRA_LIBRARY_ROOT;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lyra-integ-"));
process.env.LYRA_LIBRARY_ROOT = root;
});
afterEach(() => {
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
else process.env.LYRA_LIBRARY_ROOT = original;
rmSync(root, { recursive: true, force: true });
});
async function item(artist: string, album: string, dir: string, trackNames: string[] = []) {
return prisma.libraryItem.create({
data: { artist, album, path: dir, source: "scan", format: "FLAC", qualityClass: 2, trackNames },
});
}
describe("library integrity API", () => {
it("flags a healthy album with no issues", async () => {
const dir = join(root, "A", "Good (2020)");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "01 One.flac"), "xxxx");
writeFileSync(join(dir, "02 Two.flac"), "yyyy");
await item("A", "Good", dir, ["01 One.flac", "02 Two.flac"]);
const { checked, issues } = await (await GET()).json();
expect(checked).toBe(1);
expect(issues).toEqual([]);
});
it("flags a missing folder, empty files, and a track-count drift", async () => {
await item("Gone", "Missing", join(root, "Gone", "Missing"));
const zdir = join(root, "Z", "Zero (2021)");
mkdirSync(zdir, { recursive: true });
writeFileSync(join(zdir, "01 A.flac"), ""); // 0 bytes → truncated
writeFileSync(join(zdir, "02 B.flac"), "ok");
await item("Z", "Zero", zdir, ["01 A.flac", "02 B.flac", "03 C.flac"]); // expected 3, on disk 2
const { issues } = await (await GET()).json();
const byAlbum = Object.fromEntries(issues.map((i: { album: string; issues: string[] }) => [i.album, i.issues]));
expect(byAlbum["Missing"]).toEqual(["folder is missing on disk"]);
expect(byAlbum["Zero"]).toContain("1 empty/unreadable file");
expect(byAlbum["Zero"]).toContain("2 of 3 tracks on disk");
});
it("flags a folder with no audio", async () => {
const dir = join(root, "N", "NoAudio");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "cover.jpg"), "img");
await item("N", "NoAudio", dir);
const { issues } = await (await GET()).json();
expect(issues[0].issues).toEqual(["no audio files in the folder"]);
});
});
@@ -0,0 +1,51 @@
import { readdir, stat } from "node:fs/promises";
import { join, resolve, sep, extname } from "node:path";
import { prisma } from "@/lib/db";
// GET /api/library/integrity — on-demand disk check of every owned album folder (via the
// /music mount). Flags folders that are missing, hold no audio, contain zero-byte (truncated)
// files, or whose on-disk audio count drifted from what was captured at import/scan. Cheap
// (metadata only: readdir + stat), so it runs only when the user asks.
const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]);
export async function GET() {
const items = await prisma.libraryItem.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] });
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
const results = await Promise.all(
items.map(async (li) => {
const dir = resolve(li.path);
const issues: string[] = [];
if (dir !== root && !dir.startsWith(root + sep)) {
return { id: li.id, artist: li.artist, album: li.album, issues: ["path is outside the library root"] };
}
let names: string[];
try {
names = await readdir(dir);
} catch {
return { id: li.id, artist: li.artist, album: li.album, issues: ["folder is missing on disk"] };
}
const audio = names.filter((n) => AUDIO_EXT.has(extname(n).toLowerCase()));
if (audio.length === 0) {
issues.push("no audio files in the folder");
} else {
let empty = 0;
for (const n of audio) {
try {
if ((await stat(join(dir, n))).size === 0) empty++;
} catch {
empty++; // unreadable counts as broken
}
}
if (empty > 0) issues.push(`${empty} empty/unreadable file${empty === 1 ? "" : "s"}`);
const expected = li.trackNames.length;
if (expected > 0 && audio.length !== expected) {
issues.push(`${audio.length} of ${expected} tracks on disk`);
}
}
return { id: li.id, artist: li.artist, album: li.album, issues };
}),
);
return Response.json({ checked: items.length, issues: results.filter((r) => r.issues.length > 0) });
}
Binary file not shown.
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { GET, DELETE } from "./route";
describe("library unmatched API", () => {
it("lists unmatched albums ordered by artist/album", async () => {
await prisma.unmatchedAlbum.createMany({
data: [
{ artist: "Zed", album: "Late", path: "/music/Zed/Late", reason: "no MusicBrainz match", scanId: "s1" },
{ artist: "Ann", album: "Early", path: "/music/Ann/Early", reason: "unreadable: boom", scanId: "s1" },
],
});
const res = await GET();
expect(res.status).toBe(200);
const { unmatched } = await res.json();
expect(unmatched.map((u: { artist: string }) => u.artist)).toEqual(["Ann", "Zed"]);
expect(unmatched[0]).toMatchObject({ album: "Early", reason: "unreadable: boom" });
});
it("dismisses one entry by id", async () => {
const row = await prisma.unmatchedAlbum.create({
data: { artist: "X", album: "Y", path: "/music/X/Y", reason: "no MusicBrainz match", scanId: "s1" },
});
const res = await DELETE(new Request(`http://t/api/library/unmatched?id=${row.id}`, { method: "DELETE" }));
expect(res.status).toBe(200);
expect(await prisma.unmatchedAlbum.count()).toBe(0);
});
it("404s dismissing an unknown id", async () => {
const res = await DELETE(new Request("http://t/api/library/unmatched?id=nope", { method: "DELETE" }));
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,27 @@
import { prisma } from "@/lib/db";
// GET /api/library/unmatched — album folders the scan couldn't resolve to a MusicBrainz
// release (no match, or an unreadable file). Surfaced on /library so they aren't silently lost.
export async function GET() {
const rows = await prisma.unmatchedAlbum.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] });
return Response.json({
unmatched: rows.map((r) => ({
id: r.id,
artist: r.artist,
album: r.album,
path: r.path,
reason: r.reason,
scannedAt: r.scannedAt,
})),
});
}
// DELETE /api/library/unmatched?id= — dismiss one entry (the user has handled it, or doesn't
// care). Does not touch files on disk; a later re-scan re-surfaces it if still unresolved.
export async function DELETE(request: Request) {
const id = new URL(request.url).searchParams.get("id");
if (!id) return Response.json({ error: "id is required" }, { status: 400 });
const deleted = await prisma.unmatchedAlbum.deleteMany({ where: { id } });
if (deleted.count === 0) return Response.json({ error: "not found" }, { status: 404 });
return Response.json({ ok: true });
}
@@ -34,3 +34,23 @@ describe("monitor config API", () => {
expect(await prisma.config.count()).toBe(before);
});
});
describe("monitor config — floor.jobDelaySeconds", () => {
it("defaults jobDelaySeconds to 0 and round-trips a new value", async () => {
const before = await (await GET()).json();
expect(before["floor.jobDelaySeconds"]).toBe("0");
await PATCH(
new Request("http://localhost/api/monitor/config", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ "floor.jobDelaySeconds": "45" }),
}),
);
const stored = await prisma.config.findUnique({ where: { key: "floor.jobDelaySeconds" } });
expect(stored?.value).toBe("45");
const after = await (await GET()).json();
expect(after["floor.jobDelaySeconds"]).toBe("45");
});
});
+1
View File
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
"monitor.retryIntervalHours": "6",
"monitor.qualityCutoff": "2",
"monitor.upgradeWindowDays": "14",
"floor.jobDelaySeconds": "0",
};
export async function GET() {
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { GET, PATCH } from "./route";
import { prisma } from "@/lib/db";
function patch(body: Record<string, string>) {
return new Request("http://localhost/api/qobuz/pacing", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("qobuz pacing config API", () => {
it("returns defaults when nothing is stored", async () => {
const cfg = await (await GET()).json();
expect(cfg["qobuz.pacing.enabled"]).toBe("true");
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("8");
expect(cfg["qobuz.pacing.activeEndHour"]).toBe("23");
expect(cfg["qobuz.pacing.dailyCap"]).toBe("40");
expect(cfg["qobuz.pacing.warmupStartDate"]).toBe("");
});
it("round-trips allow-listed settings and persists them", async () => {
await PATCH(patch({ "qobuz.pacing.dailyCap": "25", "qobuz.pacing.activeStartHour": "9" }));
const stored = await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } });
expect(stored?.value).toBe("25");
const cfg = await (await GET()).json();
expect(cfg["qobuz.pacing.dailyCap"]).toBe("25");
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("9");
});
it("ignores keys not in the allow-list (e.g. worker-managed counters)", async () => {
await PATCH(patch({ "qobuz.pacing.countToday": "999", "qobuz.pacing.dailyCap": "30" }));
expect(await prisma.config.findUnique({ where: { key: "qobuz.pacing.countToday" } })).toBeNull();
expect((await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } }))?.value).toBe("30");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
// Qobuz pacing ("budget gate") settings. The worker reads these Config keys live; state keys
// (countDay/countToday/nextAllowedAt) are worker-managed and intentionally NOT editable here.
const DEFAULTS: Record<string, string> = {
"qobuz.pacing.enabled": "true",
"qobuz.pacing.activeStartHour": "8",
"qobuz.pacing.activeEndHour": "23",
"qobuz.pacing.dailyCap": "40",
"qobuz.pacing.warmupStartDate": "",
};
export async function GET() {
const rows = await prisma.config.findMany({ where: { key: { in: Object.keys(DEFAULTS) } } });
const stored = Object.fromEntries(rows.map((r) => [r.key, r.value]));
return Response.json(
Object.fromEntries(Object.entries(DEFAULTS).map(([k, d]) => [k, stored[k] ?? d])),
);
}
export async function PATCH(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter(([k]) =>
Object.prototype.hasOwnProperty.call(DEFAULTS, k),
);
for (const [key, value] of entries) {
await prisma.config.upsert({
where: { key },
create: { key, value: String(value), secret: false },
update: { value: String(value) },
});
}
return Response.json({ updated: entries.length });
}
+11 -2
View File
@@ -22,9 +22,18 @@ describe("PATCH /api/releases/[id]", () => {
expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.monitored).toBe(true);
});
it("400s a non-boolean and 404s an unknown release", async () => {
it("toggles ignored independently of monitored", async () => {
const r = await seedRelease(true);
const res = await PATCH(patchReq({ ignored: true }), ctx(r.id));
expect(res.status).toBe(200);
const row = (await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!;
expect(row.ignored).toBe(true);
expect(row.monitored).toBe(true); // ignore doesn't clear monitored
});
it("400s when neither monitored nor ignored is a boolean, 404s an unknown release", async () => {
const r = await seedRelease();
expect((await PATCH(patchReq({ monitored: 1 }), ctx(r.id))).status).toBe(400);
expect((await PATCH(patchReq({ monitored: true }), ctx("nope"))).status).toBe(404);
expect((await PATCH(patchReq({ ignored: true }), ctx("nope"))).status).toBe(404);
});
});
+8 -5
View File
@@ -9,13 +9,16 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const { monitored } = (body ?? {}) as { monitored?: unknown };
if (typeof monitored !== "boolean") {
return Response.json({ error: "monitored (boolean) is required" }, { status: 400 });
const { monitored, ignored } = (body ?? {}) as { monitored?: unknown; ignored?: unknown };
if (typeof monitored !== "boolean" && typeof ignored !== "boolean") {
return Response.json({ error: "monitored or ignored (boolean) is required" }, { status: 400 });
}
const data: { monitored?: boolean; ignored?: boolean } = {};
if (typeof monitored === "boolean") data.monitored = monitored;
if (typeof ignored === "boolean") data.ignored = ignored;
try {
const updated = await prisma.monitoredRelease.update({ where: { id }, data: { monitored } });
return Response.json({ id: updated.id, monitored: updated.monitored });
const updated = await prisma.monitoredRelease.update({ where: { id }, data });
return Response.json({ id: updated.id, monitored: updated.monitored, ignored: updated.ignored });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
return Response.json({ error: "not found" }, { status: 404 });
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { POST } from "./route";
import { prisma } from "@/lib/db";
import type { Prisma } from "@prisma/client";
function req(id: string, paused: boolean) {
return new Request(`http://localhost/api/requests/${id}/pause`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ paused }),
});
}
async function seed(jobData: Prisma.JobCreateWithoutRequestInput) {
return prisma.request.create({
data: { artist: "A", album: "B", status: "pending", job: { create: jobData } },
include: { job: true },
});
}
describe("per-item pause API", () => {
it("pauses a queued job", async () => {
const created = await seed({ state: "requested" });
const res = await POST(req(created.id, true), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(200);
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.paused).toBe(true);
});
it("resumes a paused job", async () => {
const created = await seed({ state: "requested", paused: true });
await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) });
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.paused).toBe(false);
});
it("rejects pausing an in-flight job", async () => {
const created = await seed({ state: "downloading", paused: true });
const res = await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(400);
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.paused).toBe(true);
});
it("404s for a missing request", async () => {
const res = await POST(req("nope", true), { params: Promise.resolve({ id: "nope" }) });
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,26 @@
import { prisma } from "@/lib/db";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let body: { paused?: unknown };
try {
body = (await request.json()) as { paused?: unknown };
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
if (typeof body.paused !== "boolean") {
return Response.json({ error: "paused must be a boolean" }, { status: 400 });
}
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
if (!req || !req.job) {
return Response.json({ error: "not found" }, { status: 404 });
}
if (req.job.state !== "requested") {
return Response.json({ error: "only queued jobs can be paused" }, { status: 400 });
}
await prisma.job.update({ where: { id: req.job.id }, data: { paused: body.paused } });
return Response.json({ ok: true, paused: body.paused });
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { DELETE } from "./route";
import { prisma } from "@/lib/db";
import type { Prisma } from "@prisma/client";
function del(id: string) {
return new Request(`http://localhost/api/requests/${id}`, { method: "DELETE" });
}
async function seed(jobData: Prisma.JobCreateWithoutRequestInput) {
return prisma.request.create({
data: { artist: "A", album: "B", status: "pending", job: { create: jobData } },
include: { job: true },
});
}
describe("remove request API", () => {
it("deletes a queued request and cascades its job", async () => {
const created = await seed({ state: "requested" });
const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(200);
expect(await prisma.request.findUnique({ where: { id: created.id } })).toBeNull();
expect(await prisma.job.findUnique({ where: { id: created.job!.id } })).toBeNull();
});
it("refuses to remove an in-flight job", async () => {
const created = await seed({ state: "downloading" });
const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(400);
expect(await prisma.request.findUnique({ where: { id: created.id } })).not.toBeNull();
});
it("404s for a missing request", async () => {
const res = await DELETE(del("nope"), { params: Promise.resolve({ id: "nope" }) });
expect(res.status).toBe(404);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { prisma } from "@/lib/db";
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
if (!req) {
return Response.json({ error: "not found" }, { status: 404 });
}
if (req.job && req.job.state !== "requested") {
return Response.json({ error: "only queued items can be removed" }, { status: 400 });
}
await prisma.request.delete({ where: { id } }); // cascades Job + Candidates
return Response.json({ ok: true });
}
+2
View File
@@ -64,6 +64,8 @@ export async function GET() {
claimedAt: r.job.claimedAt,
updatedAt: r.job.updatedAt,
downloadProgress: r.job.downloadProgress,
downloadEtaSeconds: r.job.downloadEtaSeconds,
paused: r.job.paused,
candidateCount: cands.length,
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
};
+2 -1
View File
@@ -4,13 +4,14 @@ import { searchReleaseGroup } from "@/lib/musicbrainz";
export async function GET() {
const items = await prisma.monitoredRelease.findMany({
where: { monitored: true, state: { not: "fulfilled" } },
where: { monitored: true, ignored: false, state: { not: "fulfilled" } },
orderBy: [{ lastSearchedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],
});
return Response.json({
wanted: items.map((r) => ({
id: r.id,
artistName: r.artistName,
artistMbid: r.artistMbid,
album: r.album,
state: r.state,
currentQualityClass: r.currentQualityClass,
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { PageHead } from "../../_ui/page-head";
import { CoverArt } from "../../_ui/cover-art";
import { AlbumModal } from "../../_ui/album-modal";
import { toast } from "../../_ui/toast";
import { categoryOf, TAB_ORDER, type Category } from "../../_ui/categories";
type Release = {
@@ -14,12 +15,14 @@ type Release = {
secondaryTypes: string[];
firstReleaseDate: string | null;
monitored: boolean;
ignored: boolean;
state: string;
currentQualityClass: number | null;
};
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
function badge(r: Release): { label: string; cls: string } {
if (r.ignored) return { label: "Ignored", cls: "chip" };
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
return { label: "Dormant", cls: "chip" };
@@ -39,15 +42,26 @@ export function DiscographyClient({ id }: { id: string }) {
}, [id]);
async function toggleMonitor(r: Release) {
await fetch(`/api/releases/${r.id}`, {
const res = await fetch(`/api/releases/${r.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ monitored: !r.monitored }),
});
}).catch(() => null);
if (!res?.ok) toast(`Couldn't update ${r.album}`);
refresh();
}
async function searchNow(r: Release) {
await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
const res = await fetch(`/api/releases/${r.id}/search`, { method: "POST" }).catch(() => null);
toast(res?.ok ? `Searching for ${r.album}` : `Couldn't search for ${r.album}`);
refresh();
}
async function toggleIgnore(r: Release) {
const res = await fetch(`/api/releases/${r.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ignored: !r.ignored }),
}).catch(() => null);
if (!res?.ok) toast(`Couldn't update ${r.album}`);
refresh();
}
@@ -105,12 +119,15 @@ export function DiscographyClient({ id }: { id: string }) {
<div className="actions">
<span className={b.cls}>{b.label}</span>
<label className="toggle">
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} />
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} disabled={r.ignored} />
monitor
</label>
<button className="btn sm ghost" onClick={() => searchNow(r)}>
<button className="btn sm ghost" onClick={() => searchNow(r)} disabled={r.ignored}>
Search now
</button>
<button className="btn sm ghost" onClick={() => toggleIgnore(r)}>
{r.ignored ? "Un-ignore" : "Ignore"}
</button>
</div>
</li>
);
@@ -127,6 +144,7 @@ export function DiscographyClient({ id }: { id: string }) {
artist: detail.name,
year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null,
type: openAlbum.primaryType,
artistMbid: detail.mbid,
}}
status={<span className={badge(openAlbum).cls}>{badge(openAlbum).label}</span>}
actions={
+87 -6
View File
@@ -10,6 +10,7 @@ type Artist = {
id: string;
mbid: string;
name: string;
createdAt: string;
autoMonitorFuture: boolean;
releaseCount: number;
monitoredCount: number;
@@ -17,9 +18,21 @@ type Artist = {
};
type Hit = { mbid: string; name: string; disambiguation: string };
type SortKey = "followed" | "name" | "have" | "monitored" | "releases";
const SORTS: { key: SortKey; label: string; cmp: (a: Artist, b: Artist) => number }[] = [
{ key: "followed", label: "Recently followed", cmp: (a, b) => b.createdAt.localeCompare(a.createdAt) },
{ key: "name", label: "Name AZ", cmp: (a, b) => a.name.localeCompare(b.name) },
{ key: "have", label: "Most in library", cmp: (a, b) => b.haveCount - a.haveCount || a.name.localeCompare(b.name) },
{ key: "monitored", label: "Most monitored", cmp: (a, b) => b.monitoredCount - a.monitoredCount || a.name.localeCompare(b.name) },
{ key: "releases", label: "Most releases", cmp: (a, b) => b.releaseCount - a.releaseCount || a.name.localeCompare(b.name) },
];
export function ArtistsClient() {
const [artists, setArtists] = useState<Artist[]>([]);
const [ownedNotFollowed, setOwnedNotFollowed] = useState<string[]>([]);
const [justFollowed, setJustFollowed] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState("");
const [sort, setSort] = useState<SortKey>("followed");
const [showAdd, setShowAdd] = useState(false);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
@@ -28,7 +41,9 @@ export function ArtistsClient() {
async function refresh() {
const res = await fetch("/api/artists");
setArtists((await res.json()).artists);
const data = await res.json();
setArtists(data.artists);
setOwnedNotFollowed(data.ownedNotFollowed ?? []);
}
useEffect(() => {
refresh();
@@ -67,25 +82,56 @@ export function ArtistsClient() {
refresh(); // keep the modal open so several can be added; the hit flips to "Following"
}
// Follow an owned-but-not-followed artist: resolve their name → MBID (the library only
// stores names), then follow with the canonical MB name. Mirrors the Last.fm/discover flow.
async function followOwned(name: string) {
const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(name)}`);
const hit = res.ok ? (await res.json()).artists?.[0] : null;
if (!hit?.mbid) {
toast(`No MusicBrainz match for ${name}`);
return;
}
const r = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
});
if (r.ok || r.status === 409) {
setJustFollowed((s) => new Set(s).add(name)); // hide it immediately, even if names differ
toast(`Following ${hit.name}`);
refresh();
} else {
toast(`Couldn't follow ${name}`);
}
}
async function toggleAuto(a: Artist) {
await fetch(`/api/artists/${a.id}`, {
const res = await fetch(`/api/artists/${a.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ autoMonitorFuture: !a.autoMonitorFuture }),
});
}).catch(() => null);
if (!res?.ok) toast(`Couldn't update ${a.name}`);
refresh();
}
async function unfollow(a: Artist) {
await fetch(`/api/artists/${a.id}`, { method: "DELETE" });
const res = await fetch(`/api/artists/${a.id}`, { method: "DELETE" }).catch(() => null);
if (!res?.ok) toast(`Couldn't unfollow ${a.name}`);
refresh();
}
const followedMbids = new Set(artists.map((a) => a.mbid));
const shown = useMemo(() => {
const n = filter.trim().toLowerCase();
return n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
}, [artists, filter]);
const filtered = n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp;
return [...filtered].sort(cmp);
}, [artists, filter, sort]);
const ownedShown = useMemo(
() => ownedNotFollowed.filter((name) => !justFollowed.has(name)),
[ownedNotFollowed, justFollowed],
);
return (
<div>
@@ -104,6 +150,14 @@ export function ArtistsClient() {
onChange={(e) => setFilter(e.target.value)}
/>
</label>
<label className="field">
<span>Sort</span>
<select aria-label="sort artists" value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
</label>
</div>
<SectionHeader
@@ -148,6 +202,33 @@ export function ArtistsClient() {
</ul>
)}
{ownedShown.length > 0 ? (
<>
<SectionHeader
title="In library, not followed"
note={`${ownedShown.length}`}
/>
<p className="muted-note">
Artists you already own music by but arent watching. Follow to monitor them for new
releases and upgrades.
</p>
<ul className="list">
{ownedShown.map((name) => (
<li key={name} className="list-row">
<div className="main">
<div className="rtitle">{name}</div>
</div>
<div className="actions">
<button className="btn sm" onClick={() => followOwned(name)}>
Follow
</button>
</div>
</li>
))}
</ul>
</>
) : null}
{showAdd ? (
<Modal open onClose={closeAdd} title="Add artist">
<form className="request-form" style={{ margin: "0 0 6px" }} onSubmit={search}>
+92 -2
View File
@@ -149,6 +149,9 @@ nav.contents .sep { flex: 1; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span {
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--graphite);
/* keep the label row a consistent height + hold an inline Clear affordance on one line,
so a set-secret field lines up with a plain sibling field (align-items:end below). */
display: flex; align-items: center; gap: 8px; min-height: 1.5em;
}
.field input, .field select {
font-family: var(--mono); font-size: 0.9rem; padding: 8px 2px; background: transparent;
@@ -194,6 +197,21 @@ nav.contents .sep { flex: 1; }
color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0;
}
.sign-out:hover { color: var(--accent); }
.modal-artist { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.modal-artist a { color: inherit; text-decoration: none; border-bottom: 1.5px solid var(--rule-2); }
.modal-artist a:hover { border-bottom-color: var(--accent); color: var(--accent); }
.tracks-source {
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--graphite); margin: 0 0 10px;
}
.import-form { display: flex; flex-direction: column; gap: 6px; }
.dropzone {
border: 1.5px dashed var(--rule-2); padding: 28px 20px; text-align: center; cursor: pointer;
font-family: var(--mono); font-size: 0.8rem; color: var(--graphite); margin-bottom: 8px;
}
.dropzone:hover, .dropzone.over { border-color: var(--accent); color: var(--accent); }
.import-picks { text-align: center; font-size: 0.78rem; color: var(--graphite); margin: 6px 0 10px; }
.import-picks .linkish { font-size: inherit; }
/* ── Phase 2: page headers, list rows, tools ──────────── */
.page-head { margin: 6px 0 26px; }
@@ -227,6 +245,9 @@ nav.contents .sep { flex: 1; }
.btn.ghost { border-color: var(--rule-2); color: var(--graphite); }
.btn.ghost:hover { background: transparent; border-color: var(--ink); color: var(--ink); }
.press-controls { display: flex; gap: 0.5rem; align-items: center; margin: 0 0 1rem; flex-wrap: wrap; }
.item-controls { display: inline-flex; gap: 0.5rem; align-items: center; }
.toggle {
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--graphite); display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
@@ -249,11 +270,11 @@ nav.contents .sep { flex: 1; }
.tracks li .tname { color: var(--ink); }
/* ── Phase 3: settings ────────────────────────────────── */
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; }
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; align-items: end; }
.field.wide { grid-column: 1 / -1; }
.subhead {
font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--graphite); margin: 26px 0 2px;
color: var(--graphite); margin: 26px 0 2px; display: flex; align-items: center; gap: 10px;
}
.field .set { color: var(--accent); }
.info-panel {
@@ -266,7 +287,43 @@ nav.contents .sep { flex: 1; }
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent);
}
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; }
/* Discover unified row: artist block | compact album list (stacked) | actions.
Each album is a small horizontal item (cover + title/badge on one line) so two stack
vertically without making the row taller than the artist block. */
.disco-row { align-items: center; flex-wrap: wrap; gap: 14px 20px; }
.disco-row .main { flex: 1 1 200px; }
.disco-albums { display: flex; flex-direction: column; gap: 6px; flex: 0 1 300px; min-width: 220px; }
.disco-album { display: flex; align-items: center; gap: 8px; }
.disco-thumb { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
.disco-thumb .da-cover { width: 30px; height: 30px; flex-shrink: 0; }
.da-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.15; }
.disco-album .da-title { font-size: 12px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.disco-album .badge { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); }
.disco-album .btn.sm.want { padding: 1px 8px; flex-shrink: 0; }
.sel-tools { display: inline-flex; align-items: center; gap: 10px; }
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
.album-card { position: relative; }
.album-card.selected { outline: 2px solid var(--accent); outline-offset: 2px; }
.sel-check { position: absolute; top: 6px; left: 6px; z-index: 1; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: var(--accent); color: var(--paper); font-weight: 700; border: 1px solid var(--rule); }
.album-card:not(.selected) .sel-check { background: var(--paper-2); color: transparent; }
.settings-section { margin-bottom: 30px; }
.help { font-size: 0.8rem; color: var(--graphite); line-height: 1.5; margin: 4px 0 14px; max-width: 62ch; }
.help b { color: var(--ink); font-weight: 600; }
.help code { font-family: var(--mono); font-size: 0.85em; }
/* compact "?" trigger that opens help in a popup */
.help-btn {
font-family: var(--mono); font-size: 0.72rem; line-height: 1; width: 20px; height: 20px;
border: 1.5px solid var(--rule-2); border-radius: 50%; background: transparent; color: var(--graphite);
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; padding: 0;
}
.help-btn:hover { border-color: var(--accent); color: var(--accent); }
.help-body { font-size: 0.9rem; color: var(--ink); line-height: 1.6; }
.help-body b { font-weight: 600; }
.help-body code { font-family: var(--mono); font-size: 0.85em; }
.info-panel + .info-panel { margin-top: 14px; }
/* ── Modal ────────────────────────────────────────────── */
.modal-backdrop {
@@ -329,6 +386,39 @@ nav.contents .sep { flex: 1; }
.tab:hover { color: var(--ink); }
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
.tab .n { color: var(--rule-2); margin-left: 6px; }
.tab.has-errors .n { color: var(--alert); }
/* "Next up" badge on the first queued row (the item the worker claims next) */
.nextup {
display: inline-block; vertical-align: middle; margin-right: 9px; transform: translateY(-2px);
font-family: var(--mono); font-size: 0.56rem; letter-spacing: 0.13em; text-transform: uppercase;
color: var(--accent-ink); background: var(--accent); padding: 2px 7px; border-radius: 4px;
}
/* ── Kebab menu (⋮ popup for press actions) ───────────── */
.dept.has-actions { align-items: center; }
.dept.has-actions .fill { transform: none; }
.menu { position: relative; display: inline-flex; }
.menu-trigger {
font-size: 1.15rem; line-height: 1; color: var(--graphite); background: transparent;
border: 1px solid var(--rule); border-radius: 6px; width: 32px; height: 30px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.menu-trigger:hover { color: var(--ink); border-color: var(--rule-2); }
.menu-pop {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 190px;
background: var(--paper-2); border: 1px solid var(--rule-2); border-radius: 9px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); padding: 5px;
display: flex; flex-direction: column; gap: 1px;
}
.menu-item {
font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase;
text-align: left; background: transparent; border: 0; color: var(--ink);
padding: 10px 11px; border-radius: 6px; cursor: pointer; white-space: nowrap;
}
.menu-item:hover { background: var(--rule); }
.menu-item:disabled { color: var(--rule-2); cursor: default; background: transparent; }
.menu-item.danger { color: var(--alert); }
/* ── Discography row (cover thumb + click-to-modal) ───── */
.disco-open {
@@ -55,6 +55,8 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
if (res.ok || res.status === 409) {
setFollowed(true);
toast(`Following ${name}`);
} else {
toast(`Couldn't follow ${name}`);
}
}
@@ -75,6 +77,8 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
if (res.ok) {
setWanted((s) => new Set(s).add(r.rgMbid));
toast(`Added ${r.album}`);
} else {
toast(`Couldn't add ${r.album}`);
}
}
@@ -175,6 +179,7 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
artist: name,
year: open.firstReleaseDate?.slice(0, 4) ?? null,
type: open.primaryType,
artistMbid: mbid,
}}
status={chipFor(open)}
actions={
+171 -81
View File
@@ -2,72 +2,123 @@
import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { timeAgo } from "../_ui/status";
import { SectionHeader } from "../_ui/section-header";
import { ArtistModal } from "../_ui/artist-modal";
import { AlbumModal } from "../_ui/album-modal";
import { CoverArt } from "../_ui/cover-art";
import { toast } from "../_ui/toast";
type Suggestion = {
type Album = {
id: string;
artistMbid: string;
artistName: string;
rgMbid: string | null;
album: string | null;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
albumReason: string | null;
};
type Row = {
id: string | null;
artistMbid: string;
artistName: string;
score: number;
seedCount: number;
sources: string[];
seeds: string[];
albums: Album[];
};
type Feed = { artists: Suggestion[]; albums: Suggestion[] };
/** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */
function similarTo(seeds: string[]): string | null {
if (!seeds.length) return null;
const shown = seeds.slice(0, 2).join(", ");
const extra = seeds.length - 2;
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
}
/** Full-length albums only — hide singles/EPs and secondary-type releases (live/comp) that
* pre-date the albums-only derivation and still linger in the DB. */
function isFullAlbum(a: Album): boolean {
return (a.primaryType ?? "Album") === "Album" && (a.secondaryTypes?.length ?? 0) === 0;
}
/** Human badge for why an album is surfaced. */
function badgeFor(reason: string | null): string | null {
if (reason === "newest") return "Newest";
if (reason === "popular") return "Most played";
if (reason === "newest,popular") return "Newest · Most played";
return null;
}
/** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */
function agoLabel(value: string): string {
const a = timeAgo(value, Date.now());
return !a || a === "just now" ? a || "recently" : `${a} ago`;
}
type SearchResult = {
seed: { mbid: string; name: string } | null;
similar: { mbid: string; name: string; score: number }[];
};
export function DiscoverClient() {
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] });
const [rows, setRows] = useState<Row[]>([]);
const [result, setResult] = useState<string | null>(null);
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [query, setQuery] = useState("");
const [search, setSearch] = useState<SearchResult | null>(null);
const [busy, setBusy] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
const loadFeed = useCallback(async () => {
setFeed(await (await fetch("/api/discover")).json());
const loadRows = useCallback(async () => {
setRows((await (await fetch("/api/discover")).json()).rows ?? []);
}, []);
useEffect(() => {
loadFeed();
loadRows();
fetch("/api/discover/run")
.then((r) => r.json())
.then((s: { result: string | null; requested: boolean }) => {
.then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => {
setResult(s.result);
setRunning(s.requested);
setLastRunAt(s.lastRunAt);
setRunning(s.requested || s.inProgress);
});
}, [loadFeed]);
}, [loadRows]);
useEffect(() => {
if (!running) return;
const id = setInterval(async () => {
const s = await (await fetch("/api/discover/run")).json();
setResult(s.result);
if (!s.requested) {
setLastRunAt(s.lastRunAt);
if (!s.requested && !s.inProgress) {
setRunning(false);
loadFeed();
loadRows();
}
}, 2000);
return () => clearInterval(id);
}, [running, loadFeed]);
}, [running, loadRows]);
async function discoverNow() {
await fetch("/api/discover/run", { method: "POST" });
setRunning(true);
}
async function resetDiscover() {
setConfirmReset(false);
const res = await fetch("/api/discover/reset", { method: "POST" }).catch(() => null);
if (res && (res.ok || res.status === 202)) {
setRows([]); // clears immediately; the rebuild repopulates
setRunning(true);
toast("Rebuilding suggestions from scratch…");
} else {
toast("Couldn't reset");
}
}
async function runSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
@@ -79,26 +130,48 @@ export function DiscoverClient() {
}
}
async function followSimilar(a: { mbid: string; name: string }) {
async function followByMbid(mbid: string, name: string) {
const res = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: a.mbid, name: a.name }),
});
if (res.ok || res.status === 409) {
body: JSON.stringify({ mbid, name }),
}).catch(() => null);
return !!res && (res.ok || res.status === 409);
}
async function followSimilar(a: { mbid: string; name: string }) {
if (await followByMbid(a.mbid, a.name)) {
setFollowedSimilar((s) => new Set(s).add(a.mbid));
toast(`Following ${a.name}`);
} else {
toast(`Couldn't follow ${a.name}`);
}
}
// Suggestion actions key off a suggestion id. A row with no artist-suggestion id (album-only,
// e.g. the artist was dismissed earlier) falls back to a plain follow-by-MBID.
async function act(id: string, action: "follow" | "want" | "dismiss") {
await fetch(`/api/discover/${id}`, {
const res = await fetch(`/api/discover/${id}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
}).catch(() => null);
if (!res?.ok) {
toast(`Couldn't ${action}`);
return;
}
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed");
loadFeed();
loadRows();
}
async function followRow(row: Row) {
if (row.id) return act(row.id, "follow");
if (await followByMbid(row.artistMbid, row.artistName)) {
toast(`Following ${row.artistName}`);
loadRows();
} else {
toast(`Couldn't follow ${row.artistName}`);
}
}
return (
@@ -109,7 +182,26 @@ export function DiscoverClient() {
<button className="btn accent" onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"}
</button>
{result ? <span className="rmeta">last run · {result}</span> : null}
{confirmReset ? (
<>
<span className="rmeta">Clear all suggestions and rebuild from scratch?</span>
<button className="btn sm accent" onClick={resetDiscover}>Reset</button>
<button className="btn sm ghost" onClick={() => setConfirmReset(false)}>Cancel</button>
</>
) : (
<>
<button className="btn sm ghost" onClick={() => setConfirmReset(true)} disabled={running}>
Reset
</button>
<span className="rmeta">
{running
? "Sweeping your followed artists for new suggestions…"
: lastRunAt
? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}`
: "Not yet run — press Discover now to sweep your followed artists"}
</span>
</>
)}
</div>
<SectionHeader title="Find similar" />
@@ -129,9 +221,7 @@ export function DiscoverClient() {
<li key={s.mbid} className="list-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}>
{s.name}
</button>
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
</div>
<div className="rmeta">
<span className="score">similarity {s.score.toFixed(1)}</span>
@@ -151,85 +241,85 @@ export function DiscoverClient() {
</ul>
) : null}
<SectionHeader title="Suggested artists" note={`${feed.artists.length}`} />
{feed.artists.length === 0 ? (
<SectionHeader title="Suggested artists" note={`${rows.length}`} />
{rows.length === 0 ? (
<p className="muted-note">No suggestions yet run Discover, or follow a few artists to seed it.</p>
) : (
<ul className="list">
{feed.artists.map((s) => (
<li key={s.id} className="list-row">
{rows.map((row) => {
const albums = row.albums.filter(isFullAlbum);
return (
<li key={row.artistMbid} className="list-row disco-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}>
{s.artistName}
</button>
<a href={`/discover/artist/${row.artistMbid}?name=${encodeURIComponent(row.artistName)}`}>
{row.artistName}
</a>
</div>
<div className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span>
<span className="score">score {row.score.toFixed(2)}</span>
<span className="dot">·</span>
<span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span>
<span className="dot">·</span>
<span>{s.sources.join(", ")}</span>
<span>{row.sources.join(", ")}</span>
</div>
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
</div>
{albums.length > 0 ? (
<div className="disco-albums">
{albums.map((a) => (
<div key={a.id} className="disco-album">
<button
className="disco-thumb"
onClick={() => setAlbumModal({ a, artistName: row.artistName, artistMbid: row.artistMbid })}
aria-label={`Open ${a.album ?? ""}`}
>
<CoverArt rgMbid={a.rgMbid} alt={a.album ?? ""} className="da-cover" />
<span className="da-text">
<span className="da-title">{a.album}</span>
{badgeFor(a.albumReason) ? <span className="badge">{badgeFor(a.albumReason)}</span> : null}
</span>
</button>
<button className="btn sm ghost want" onClick={() => act(a.id, "want")}>
Want
</button>
</div>
))}
</div>
) : null}
<div className="actions">
<button className="btn sm" onClick={() => act(s.id, "follow")}>
<button className="btn sm" onClick={() => followRow(row)}>
Follow
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
Dismiss
</button>
{row.id ? (
<button className="btn sm ghost" onClick={() => act(row.id!, "dismiss")}>
Dismiss
</button>
) : null}
</div>
</li>
))}
);
})}
</ul>
)}
<SectionHeader title="Suggested albums" note={`${feed.albums.length}`} />
{feed.albums.length === 0 ? (
<p className="muted-note">No album suggestions yet.</p>
) : (
<ul className="list">
{feed.albums.map((s) => (
<li key={s.id} className="list-row">
<div className="main">
<button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}>
<CoverArt rgMbid={s.rgMbid} alt={s.album ?? ""} className="thumb" />
<span>
<span className="rtitle">
{s.album} <span className="artist">· {s.artistName}</span>
</span>
<span className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span>
</span>
</span>
</button>
</div>
<div className="actions">
<button className="btn sm" onClick={() => act(s.id, "want")}>
Want
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
Dismiss
</button>
</div>
</li>
))}
</ul>
)}
{artistModal ? (
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
) : null}
{albumModal ? (
<AlbumModal
open
onClose={() => setAlbumModal(null)}
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null }}
album={{
rgMbid: albumModal.a.rgMbid,
album: albumModal.a.album ?? "",
artist: albumModal.artistName,
year: albumModal.a.firstReleaseDate?.slice(0, 4) ?? null,
type: albumModal.a.primaryType,
artistMbid: albumModal.artistMbid,
}}
actions={
<button
className="btn sm"
onClick={() => {
act(albumModal.id, "want");
act(albumModal.a.id, "want");
setAlbumModal(null);
}}
>
Binary file not shown.
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useRef, useState } from "react";
import { Modal } from "../_ui/modal";
import { toast } from "../_ui/toast";
const AUDIO_RE = /\.(flac|mp3|m4a|opus|ogg|aac|wav)$/i;
// A dropped item may be a whole folder; the plain dataTransfer.files doesn't recurse, so walk
// the entry tree with webkitGetAsEntry to collect files inside a dropped album folder.
function readEntries(reader: {
readEntries: (cb: (e: FileSystemEntry[]) => void, err: (e: unknown) => void) => void;
}): Promise<FileSystemEntry[]> {
return new Promise((res, rej) => reader.readEntries(res, rej));
}
async function walkEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((res, rej) => (entry as FileSystemFileEntry).file(res, rej));
out.push(file);
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
for (let batch = await readEntries(reader); batch.length; batch = await readEntries(reader)) {
for (const e of batch) await walkEntry(e, out);
}
}
}
/** Parse "Artist - Album (Year)" / "Album (Year)" from a dropped folder name. */
function parseFolder(name: string): { artist?: string; album: string; year?: string } {
let s = name.trim();
let year: string | undefined;
const ym = s.match(/[([]?(\d{4})[)\]]?\s*$/);
if (ym) {
year = ym[1];
s = s.slice(0, ym.index).trim().replace(/[-–—([]\s*$/, "").trim();
}
const dash = s.split(/\s+[-–—]\s+/);
if (dash.length >= 2) return { artist: dash[0].trim(), album: dash.slice(1).join(" - ").trim(), year };
return { album: s, year };
}
/** Manually add an album (ripped CD / local files): drag-drop the folder (or audio files) +
* fill in artist/album/year, POST to /api/library/import. */
export function ImportAlbum({ onImported }: { onImported: () => void }) {
const [open, setOpen] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [artist, setArtist] = useState("");
const [album, setAlbum] = useState("");
const [year, setYear] = useState("");
const [busy, setBusy] = useState(false);
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const folderRef = useRef<HTMLInputElement>(null);
const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length;
const ready = !!artist.trim() && !!album.trim() && audioCount > 0;
function reset() {
setFiles([]);
setArtist("");
setAlbum("");
setYear("");
}
function close() {
setOpen(false);
reset();
}
function addFiles(list: File[]) {
if (list.length) setFiles((prev) => [...prev, ...list]);
}
function prefillFolder(folder: string | null) {
if (!folder) return;
const p = parseFolder(folder);
setAlbum((a) => a || p.album);
if (p.artist) setArtist((a) => a || p.artist!);
if (p.year) setYear((y) => y || p.year!);
}
async function onDrop(e: React.DragEvent) {
e.preventDefault();
setDrag(false);
// Capture the entries SYNCHRONOUSLY — the DataTransfer is emptied once this handler yields.
const dt = e.dataTransfer;
const entries = Array.from(dt.items || [])
.map((it) => it.webkitGetAsEntry?.())
.filter((x): x is FileSystemEntry => !!x);
const plain = Array.from(dt.files || []);
try {
if (entries.length) {
const out: File[] = [];
for (const en of entries) await walkEntry(en, out);
const dirs = entries.filter((en) => en.isDirectory);
addFiles(out);
prefillFolder(dirs.length === 1 && entries.length === 1 ? dirs[0].name : null);
if (out.length === 0) toast("That folder had no files Lyra could read");
} else if (plain.length) {
addFiles(plain);
} else {
toast("Couldn't read the drop — try the “choose a folder” link");
}
} catch (err) {
console.error("import drop failed:", err);
toast("Couldn't read the dropped folder — try the “choose a folder” link");
}
}
// Reliable fallback: a folder picker (webkitdirectory). Files carry webkitRelativePath, whose
// first segment is the folder name — use it to prefill.
function onPickFolder(e: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(e.target.files ?? []);
addFiles(picked);
const rel = picked[0]?.webkitRelativePath ?? "";
prefillFolder(rel.includes("/") ? rel.split("/")[0] : null);
e.target.value = "";
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!ready) return;
setBusy(true);
try {
const fd = new FormData();
fd.set("artist", artist.trim());
fd.set("album", album.trim());
if (year.trim()) fd.set("year", year.trim());
for (const f of files) fd.append("files", f);
const res = await fetch("/api/library/import", { method: "POST", body: fd }).catch(() => null);
if (res?.ok) {
const d = (await res.json()) as { tracks: number };
toast(`Imported ${album.trim()} · ${d.tracks} tracks`);
onImported();
close();
} else {
const d = res ? ((await res.json().catch(() => ({}))) as { error?: string }) : {};
toast(d.error || "Import failed");
}
} finally {
setBusy(false);
}
}
return (
<>
<button type="button" className="btn accent" onClick={() => setOpen(true)}>
Add album
</button>
{open ? (
<Modal open onClose={close} title="Add an album">
<form onSubmit={submit} className="import-form">
<div
className={`dropzone${drag ? " over" : ""}`}
onDragEnter={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
>
{audioCount > 0
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
files.length > audioCount ? " (+ cover)" : ""
}`
: "Drop an album folder (or files) here"}
<input
ref={inputRef}
type="file"
multiple
accept="audio/*,.flac,.m4a,.opus,image/*"
hidden
onChange={(e) => addFiles(Array.from(e.target.files ?? []))}
/>
{/* @ts-expect-error webkitdirectory is a non-standard attribute for folder selection */}
<input ref={folderRef} type="file" webkitdirectory="" hidden onChange={onPickFolder} />
</div>
<p className="import-picks">
<button type="button" className="linkish" onClick={() => folderRef.current?.click()}>
Choose a folder
</button>
{" · "}
<button type="button" className="linkish" onClick={() => inputRef.current?.click()}>
Choose files
</button>
</p>
<div className="field-grid">
<label className="field">
<span>Artist</span>
<input aria-label="import artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="import album" value={album} onChange={(e) => setAlbum(e.target.value)} />
</label>
<label className="field">
<span>Year (optional)</span>
<input aria-label="import year" value={year} onChange={(e) => setYear(e.target.value)} placeholder="2020" />
</label>
</div>
<p className="help">
Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "}
<b>Scan</b> in Settings afterwards to fetch cover art and match MusicBrainz.
</p>
<div className="save-row">
<button type="submit" className="btn accent" disabled={busy || !ready}>
{busy ? "Importing…" : "Import"}
</button>
{files.length > 0 ? (
<button type="button" className="btn ghost sm" onClick={reset}>
Clear files
</button>
) : null}
</div>
</form>
</Modal>
) : null}
</>
);
}
+380 -10
View File
@@ -5,6 +5,8 @@ import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
import { CoverArt } from "../_ui/cover-art";
import { AlbumModal } from "../_ui/album-modal";
import { toast } from "../_ui/toast";
import { ImportAlbum } from "./import-album";
type Album = {
id: string;
@@ -12,37 +14,207 @@ type Album = {
album: string;
format: string;
qualityClass: number;
importedAt: string;
rgMbid: string | null;
year: string | null;
primaryType: string | null;
artistMbid: string | null;
monitoredReleaseId: string | null;
trackNames: string[];
};
type SortKey = "added" | "release" | "album" | "artist";
const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number }[] = [
{ key: "added", label: "Recently added", cmp: (a, b) => b.importedAt.localeCompare(a.importedAt) },
// Release date, newest first; albums with an unknown year sort to the bottom.
{ key: "release", label: "Release date", cmp: (a, b) => (b.year ?? "0000").localeCompare(a.year ?? "0000") || a.album.localeCompare(b.album) },
{ key: "album", label: "Album AZ", cmp: (a, b) => a.album.localeCompare(b.album) },
{ key: "artist", label: "Artist AZ", cmp: (a, b) => a.artist.localeCompare(b.artist) || (a.year ?? "").localeCompare(b.year ?? "") },
];
type Unmatched = { id: string; artist: string; album: string; path: string; reason: string };
type DupItem = { id: string; artist: string; album: string; format: string; qualityClass: number; year: string | null };
type IntegrityIssue = { id: string; artist: string; album: string; issues: string[] };
export function LibraryClient() {
const [albums, setAlbums] = useState<Album[]>([]);
const [q, setQ] = useState("");
const [open, setOpen] = useState<Album | null>(null);
const [unmatched, setUnmatched] = useState<Unmatched[]>([]);
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
const [checking, setChecking] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkConfirmDelete, setBulkConfirmDelete] = useState(false);
useEffect(() => {
function toggleSelect(id: string) {
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
}
function exitSelect() {
setSelectMode(false);
setSelected(new Set());
setBulkConfirmDelete(false);
}
async function bulk(action: "delete" | "ignore") {
const ids = [...selected];
if (ids.length === 0) return;
const res = await fetch("/api/library/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action, ids }),
}).catch(() => null);
if (res?.ok) {
const body = await res.json();
toast(
`${action === "delete" ? "Deleted" : "Ignoring"} ${body.ok} album${body.ok === 1 ? "" : "s"}` +
(body.failed?.length ? ` · ${body.failed.length} failed` : ""),
);
exitSelect();
refresh();
} else {
toast(`Bulk ${action} failed`);
}
}
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortKey>("added");
const [open, setOpen] = useState<Album | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [editing, setEditing] = useState(false);
const [edit, setEdit] = useState({ artist: "", album: "", year: "" });
const [saving, setSaving] = useState(false);
function show(a: Album | null) {
setOpen(a);
setConfirmDelete(false);
setEditing(false);
}
function startEdit() {
if (!open) return;
setEdit({ artist: open.artist, album: open.album, year: open.year ?? "" });
setEditing(true);
}
async function saveEdit() {
if (!open) return;
if (!edit.artist.trim() || !edit.album.trim()) {
toast("Artist and album are required");
return;
}
setSaving(true);
try {
const res = await fetch(`/api/library/${open.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ artist: edit.artist.trim(), album: edit.album.trim(), year: edit.year.trim() }),
}).catch(() => null);
if (res?.ok) {
toast(`Updated ${edit.album.trim()}`);
setEditing(false);
show(null);
refresh();
} else {
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't update album");
}
} finally {
setSaving(false);
}
}
async function remove() {
if (!open) return;
const target = open;
const res = await fetch(`/api/library/${target.id}`, { method: "DELETE" }).catch(() => null);
if (res?.ok) {
setAlbums((list) => list.filter((a) => a.id !== target.id));
toast(`Deleted ${target.album}`);
show(null);
} else {
toast(`Couldn't delete ${target.album}`);
}
}
function refresh() {
fetch("/api/library")
.then((r) => r.json())
.then((d) => setAlbums(d.albums ?? []));
fetch("/api/library/unmatched")
.then((r) => r.json())
.then((d) => setUnmatched(d.unmatched ?? []));
fetch("/api/library/duplicates")
.then((r) => r.json())
.then((d) => setDuplicates(d.duplicates ?? []));
}
useEffect(() => {
refresh();
}, []);
async function upgrade() {
if (!open) return;
const res = await fetch(`/api/library/${open.id}/upgrade`, { method: "POST" }).catch(() => null);
if (res && (res.status === 202 || res.ok)) {
const body = await res.json().catch(() => null);
toast(body?.enqueued === false ? `Already searching for ${open.album}` : `Re-acquiring ${open.album} — keeps it only if better`);
show(null);
} else {
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't start the upgrade");
}
}
async function checkIntegrity() {
setChecking(true);
try {
const res = await fetch("/api/library/integrity").catch(() => null);
if (res?.ok) {
setIntegrity(await res.json());
} else {
toast("Couldn't run the integrity check");
}
} finally {
setChecking(false);
}
}
async function dismissUnmatched(u: Unmatched) {
const res = await fetch(`/api/library/unmatched?id=${u.id}`, { method: "DELETE" }).catch(() => null);
if (res?.ok) {
setUnmatched((list) => list.filter((x) => x.id !== u.id));
} else {
toast("Couldn't dismiss");
}
}
const shown = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return albums;
return albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle));
}, [albums, q]);
const filtered = needle
? albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle))
: albums;
const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp;
return [...filtered].sort(cmp);
}, [albums, q, sort]);
return (
<div>
<PageHead title="Library" eyebrow="The pressings · everything on disk" />
<form className="request-form" onSubmit={(e) => e.preventDefault()}>
<ImportAlbum onImported={refresh} />
<label className="field">
<span>Filter</span>
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
<label className="field">
<span>Sort</span>
<select aria-label="sort library" value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
</label>
</form>
{albums.length === 0 ? (
@@ -50,13 +222,46 @@ export function LibraryClient() {
) : (
<SectionHeader
title="Pressings"
note={q ? `${shown.length} of ${albums.length} albums` : `${albums.length} albums`}
note={
<span className="sel-tools">
<span>{q ? `${shown.length} of ${albums.length}` : `${albums.length} albums`}</span>
<button className="btn sm ghost" onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}>
{selectMode ? "Cancel" : "Select"}
</button>
</span>
}
/>
)}
{selectMode ? (
<div className="bulk-bar">
<span>{selected.size} selected</span>
{bulkConfirmDelete ? (
<>
<span className="rmeta">Delete {selected.size} album{selected.size === 1 ? "" : "s"} and their files?</span>
<button className="btn sm accent" onClick={() => bulk("delete")} disabled={selected.size === 0}>Delete</button>
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(false)}>Cancel</button>
</>
) : (
<>
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(true)} disabled={selected.size === 0}>Delete selected</button>
<button className="btn sm ghost" onClick={() => bulk("ignore")} disabled={selected.size === 0}>Ignore selected</button>
</>
)}
</div>
) : null}
{albums.length > 0 ? (
<div className="album-grid">
{shown.map((a) => (
<button key={a.id} className="album-card" onClick={() => setOpen(a)} aria-label={`Open ${a.album}`}>
<button
key={a.id}
className={`album-card${selectMode && selected.has(a.id) ? " selected" : ""}`}
onClick={() => (selectMode ? toggleSelect(a.id) : show(a))}
aria-label={selectMode ? `Select ${a.album}` : `Open ${a.album}`}
aria-pressed={selectMode ? selected.has(a.id) : undefined}
>
{selectMode ? <span className="sel-check" aria-hidden>{selected.has(a.id) ? "✓" : ""}</span> : null}
<CoverArt rgMbid={a.rgMbid} alt={a.album} size={250} />
<div className="ac-title">{a.album}</div>
<div className="ac-artist">{a.artist}</div>
@@ -70,12 +275,177 @@ export function LibraryClient() {
</div>
) : null}
{albums.length > 0 ? (
<>
<SectionHeader
title="Integrity"
note={
<button className="btn sm ghost" onClick={checkIntegrity} disabled={checking}>
{checking ? "Checking…" : "Check now"}
</button>
}
/>
{integrity ? (
integrity.issues.length === 0 ? (
<p className="muted-note">Checked {integrity.checked} albums no problems found.</p>
) : (
<ul className="list">
{integrity.issues.map((it) => {
const album = albums.find((a) => a.id === it.id);
return (
<li key={it.id} className="list-row">
<div className="main">
{album ? (
<button className="linkish rtitle" onClick={() => show(album)}>
{it.artist} {it.album}
</button>
) : (
<div className="rtitle">{it.artist} {it.album}</div>
)}
<div className="rmeta">
<span className="score">{it.issues.join(" · ")}</span>
</div>
</div>
</li>
);
})}
</ul>
)
) : (
<p className="muted-note">
Check every album folder on disk for missing folders, empty/truncated files, and
track-count drift.
</p>
)}
</>
) : null}
{duplicates.length > 0 ? (
<>
<SectionHeader title="Possible duplicates" note={`${duplicates.length}`} />
<p className="muted-note">
Albums that look like the same release held more than once (same MusicBrainz release,
or the same title ignoring edition wording). Open one to edit or delete it.
</p>
{duplicates.map((group, gi) => (
<ul className="list" key={gi} style={{ marginBottom: 10 }}>
{group.map((d) => {
const album = albums.find((a) => a.id === d.id);
return (
<li key={d.id} className="list-row">
<div className="main">
{album ? (
<button className="linkish rtitle" onClick={() => show(album)}>
{d.artist} {d.album}
</button>
) : (
<div className="rtitle">{d.artist} {d.album}</div>
)}
<div className="rmeta">
<span className="score">
{d.format}
{d.qualityClass >= 3 ? " · hi-res" : ""}
{d.year ? ` · ${d.year}` : ""}
</span>
</div>
</div>
</li>
);
})}
</ul>
))}
</>
) : null}
{unmatched.length > 0 ? (
<>
<SectionHeader title="Couldnt match" note={`${unmatched.length}`} />
<p className="muted-note">
Album folders on disk the scan couldnt resolve to a MusicBrainz release. Fix the
folder name (Artist / Album (Year)) or re-add them manually, then re-scan. Dismiss to
hide an entry.
</p>
<ul className="list">
{unmatched.map((u) => (
<li key={u.id} className="list-row">
<div className="main">
<div className="rtitle">
{u.artist ? `${u.artist}` : ""}
{u.album}
</div>
<div className="rmeta">
<span className="dim">{u.path}</span> <span className="dot">·</span>{" "}
<span className="score">{u.reason}</span>
</div>
</div>
<div className="actions">
<button className="btn sm ghost" onClick={() => dismissUnmatched(u)}>
Dismiss
</button>
</div>
</li>
))}
</ul>
</>
) : null}
{open ? (
<AlbumModal
open
onClose={() => setOpen(null)}
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType }}
onClose={() => show(null)}
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }}
status={<span className="chip done">In library · {open.format}</span>}
actions={
editing ? (
<div className="edit-meta">
<label className="field">
<span>Artist</span>
<input aria-label="edit artist" value={edit.artist} onChange={(e) => setEdit((s) => ({ ...s, artist: e.target.value }))} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="edit album" value={edit.album} onChange={(e) => setEdit((s) => ({ ...s, album: e.target.value }))} />
</label>
<label className="field">
<span>Year</span>
<input aria-label="edit year" placeholder="YYYY" value={edit.year} onChange={(e) => setEdit((s) => ({ ...s, year: e.target.value }))} />
</label>
<div className="save-row">
<button className="btn sm accent" onClick={saveEdit} disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
<button className="btn sm ghost" onClick={() => setEditing(false)} disabled={saving}>
Cancel
</button>
</div>
<p className="muted-note">Renames the folder on disk and re-resolves cover art. Embedded file tags are left as-is.</p>
</div>
) : confirmDelete ? (
<>
<span className="rmeta">Delete this album and its files from disk?</span>
<button className="btn sm accent" onClick={remove}>
Delete
</button>
<button className="btn sm ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</button>
</>
) : (
<>
{open.monitoredReleaseId ? (
<button className="btn sm ghost" onClick={upgrade}>
Replace / upgrade
</button>
) : null}
<button className="btn sm ghost" onClick={startEdit}>
Edit metadata
</button>
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
Delete album
</button>
</>
)
}
/>
) : null}
</div>
+268 -85
View File
@@ -1,14 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import { describeJob, timeAgo } from "./_ui/status";
import { describeJob, etaLabel, timeAgo } from "./_ui/status";
import { StatTiles } from "./_ui/stat-tiles";
import { SectionHeader } from "./_ui/section-header";
import { JobRow } from "./_ui/job-row";
import { ProgressBar } from "./_ui/progress-bar";
import { PressedList } from "./_ui/pressed-list";
import { KebabMenu } from "./_ui/kebab-menu";
import { toast } from "./_ui/toast";
type Tab = "active" | "queue" | "errors";
// Split the on-the-press rows into three honest buckets so active presses aren't buried between
// queued items and errors. Active = being worked (searching/downloading/finishing), Queue =
// waiting to be claimed, Errors = needs attention.
function categoryOf(state: string, stage: string): Tab {
const kind = describeJob(state, stage).kind;
if (kind === "attention") return "errors";
if (state === "requested") return "queue";
return "active";
}
const TAB_LABEL: Record<Tab, string> = { active: "Active", queue: "In queue", errors: "Errors" };
const TAB_EMPTY: Record<Tab, string> = {
active: "Nothing pressing right now.",
queue: "The queue is empty.",
errors: "No errors — everything's flowing.",
};
type Row = {
id: string;
artist: string;
@@ -26,6 +46,8 @@ type Row = {
candidateCount: number;
chosen: { source: string; format: string; trackCount: number } | null;
downloadProgress: number;
downloadEtaSeconds: number | null;
paused: boolean;
} | null;
};
@@ -41,13 +63,16 @@ function jobOf(r: Row) {
candidateCount: 0,
chosen: null,
downloadProgress: 0,
downloadEtaSeconds: null,
paused: false,
}
);
}
function pressedMark(r: Row): string {
if (!r.createdAt) return "Lyra";
const d = new Date(r.createdAt);
const when = r.job?.updatedAt ?? r.createdAt; // when it was pressed, not when it was requested
if (!when) return "Lyra";
const d = new Date(when);
return `Lyra · ${d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" }).toUpperCase()}`;
}
@@ -57,16 +82,22 @@ export function Queue() {
const [watching, setWatching] = useState(0);
const [artist, setArtist] = useState("");
const [album, setAlbum] = useState("");
const [paused, setPaused] = useState(false);
// null = follow the smart default (Active if anything's active, else Queue, else Errors); once
// the user picks a tab, their choice sticks.
const [tab, setTab] = useState<Tab | null>(null);
async function refresh() {
const [reqRes, wantRes, artRes] = await Promise.all([
const [reqRes, wantRes, artRes, floorRes] = await Promise.all([
fetch("/api/requests"),
fetch("/api/wanted"),
fetch("/api/artists"),
fetch("/api/floor"),
]);
setRows((await reqRes.json()).requests ?? []);
setWanted(((await wantRes.json()).wanted ?? []).length);
setWatching(((await artRes.json()).artists ?? []).length);
setPaused((await floorRes.json()).paused ?? false);
}
useEffect(() => {
@@ -76,22 +107,77 @@ export function Queue() {
}, []);
async function retry(id: string) {
const res = await fetch(`/api/requests/${id}/retry`, { method: "POST" });
if (res.ok) {
toast("Retrying…");
refresh();
const res = await fetch(`/api/requests/${id}/retry`, { method: "POST" }).catch(() => null);
if (!res?.ok) {
toast("Couldn't retry");
return;
}
toast("Retrying…");
refresh();
}
async function floorAction(action: string, note: string) {
const res = await fetch("/api/floor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
}).catch(() => null);
if (!res?.ok) {
toast("Couldn't update the press");
return;
}
toast(note);
refresh();
}
async function clearPress() {
const queued = rows.filter((r) => jobOf(r).state === "requested").length;
if (queued === 0) return;
if (!confirm(`Clear ${queued} queued item${queued === 1 ? "" : "s"} from the press?`)) return;
await floorAction("clear", "Cleared the queue");
}
async function stopPress() {
if (!confirm("Stop the current download and pause the press? The worker restarts and the aborted album re-downloads when you resume.")) return;
await floorAction("stop", "Stopping the press…");
}
async function pauseItem(id: string, next: boolean) {
const res = await fetch(`/api/requests/${id}/pause`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ paused: next }),
}).catch(() => null);
if (!res?.ok) {
toast("Couldn't update the item");
return;
}
refresh();
}
async function removeItem(id: string) {
const res = await fetch(`/api/requests/${id}`, { method: "DELETE" }).catch(() => null);
if (!res?.ok) {
toast("Couldn't remove the item");
return;
}
toast("Removed from the press");
refresh();
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!artist.trim() || !album.trim()) return;
const label = album.trim();
await fetch("/api/requests", {
const res = await fetch("/api/requests", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ artist, album }),
});
}).catch(() => null);
if (!res?.ok) {
toast(`Couldn't queue ${label}`);
return;
}
toast(`Queued ${label}`);
setArtist("");
setAlbum("");
@@ -99,20 +185,167 @@ export function Queue() {
}
const active = rows.filter((r) => jobOf(r).state !== "imported");
const pressed = rows.filter((r) => jobOf(r).state === "imported");
const buckets: Record<Tab, Row[]> = { active: [], queue: [], errors: [] };
for (const r of active) {
const j = jobOf(r);
buckets[categoryOf(j.state, j.currentStage)].push(r);
}
// The API returns rows newest-first, but the worker claims the OLDEST queued job next
// (claim_next: ORDER BY createdAt ASC). Order the queue tab to match, so the item that runs
// next sits at the top instead of the bottom.
const ts = (r: Row) => (r.createdAt ? new Date(r.createdAt).getTime() : 0);
buckets.queue.sort((a, b) => ts(a) - ts(b));
// "Next up" = the first queued item the worker will actually claim (oldest, not individually
// paused) — mark it so the ordering reads clearly.
const nextUpId = buckets.queue.find((r) => !jobOf(r).paused)?.id ?? null;
const queuedCount = buckets.queue.length;
const downloading = buckets.active.length > 0;
// Smart default: land on the tab that has something worth looking at.
const defaultTab: Tab = buckets.active.length ? "active" : queuedCount ? "queue" : "errors";
const activeTab: Tab = tab ?? defaultTab;
const shown = buckets[activeTab];
// "Recently Pressed": imported albums ordered by when they were PRESSED (job.updatedAt ≈ import
// time), newest first — an album requested long ago but imported just now is recently pressed
// even though its Request is old (rows arrive by request createdAt, which buried fresh imports).
// Dedupe by artist+album (re-downloads leave several completed Requests; keep the most recent).
const seenPressed = new Set<string>();
const pressedAll = rows
.filter((r) => jobOf(r).state === "imported")
.sort((a, b) => new Date(jobOf(b).updatedAt ?? 0).getTime() - new Date(jobOf(a).updatedAt ?? 0).getTime())
.filter((r) => {
const key = `${r.artist} ${r.album}`.toLowerCase();
if (seenPressed.has(key)) return false;
seenPressed.add(key);
return true;
});
const pressed = pressedAll.slice(0, 10); // show only the 10 most recently pressed
const now = Date.now();
function renderRow(r: Row) {
const j = jobOf(r);
const d = describeJob(j.state, j.currentStage);
const attention = d.kind === "attention";
let meta: string | undefined;
if (j.state === "downloading" || j.state === "tagging") {
const parts: string[] = [];
if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`);
const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now);
if (elapsed) parts.push(elapsed);
meta = parts.join(" · ") || undefined;
} else if (j.state === "matching" || j.state === "matched") {
meta =
j.candidateCount > 0
? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}`
: "Searching sources…";
} else if (j.state === "requested") {
const elapsed = timeAgo(r.createdAt, now);
meta = elapsed ? `In the queue · ${elapsed}` : "In the queue";
} else {
meta = undefined;
}
const note = attention ? (
<>
{j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : j.state === "requested" ? (
<span className="item-controls">
{j.paused ? (
<>
<span className="chip">Paused</span>{" "}
<button className="btn sm ghost" onClick={() => pauseItem(r.id, false)}>
Resume
</button>
</>
) : (
<button className="btn sm ghost" onClick={() => pauseItem(r.id, true)}>
Pause
</button>
)}{" "}
<button className="btn sm ghost" onClick={() => removeItem(r.id)}>
Remove
</button>
</span>
) : undefined;
let bar: React.ReactNode;
if (j.state === "matching" || j.state === "matched") {
bar = <ProgressBar kind="working" indeterminate caption="searching" />;
} else if (j.state === "downloading") {
const pct = Math.round((j.downloadProgress || 0) * 100);
if (pct <= 0) {
// Bytes haven't started flowing yet — Qobuz login/metadata/artwork, a Soulseek peer
// queue, or a YouTube info-fetch. Show an indeterminate bar instead of a stuck-looking
// 0% (it flips to the % bar on the first byte).
bar = <ProgressBar kind="working" indeterminate caption="preparing…" />;
} else {
const total = j.chosen?.trackCount ?? 0;
const done = total ? Math.round((pct / 100) * total) : 0;
const base = total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`;
const eta = etaLabel(j.downloadEtaSeconds);
bar = <ProgressBar kind="working" percent={pct} caption={eta ? `${base} · ${eta}` : base} />;
}
} else if (j.state === "tagging") {
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
} else {
bar = undefined;
}
return (
<JobRow
key={r.id}
artist={r.artist}
album={r.album}
kind={d.kind}
label={d.label}
marker={r.id === nextUpId ? "Next up" : undefined}
meta={meta}
note={note}
bar={bar}
/>
);
}
return (
<div>
<StatTiles
items={[
{ k: "On the press", v: active.length },
{ k: "Pressed", v: pressed.length, accent: true },
{ k: "Pressed", v: pressedAll.length, accent: true },
{ k: "Wanted", v: wanted },
{ k: "Watching", v: watching, unit: "artists" },
]}
/>
<SectionHeader title="On the Press" note={`${active.length} running`} />
<div className="dept has-actions">
<h2>On the Press</h2>
<span className="fill" />
{paused ? <span className="count">paused</span> : null}
<KebabMenu label="Press actions">
{paused ? (
<button className="menu-item" onClick={() => floorAction("resume", "Press resumed")}>
Resume the press
</button>
) : (
<button className="menu-item" onClick={() => floorAction("pause", "Press paused")}>
Pause the press
</button>
)}
<button className="menu-item" onClick={clearPress} disabled={queuedCount === 0}>
Clear queue
</button>
{downloading ? (
<button className="menu-item danger" onClick={stopPress}>
Stop now
</button>
) : null}
</KebabMenu>
</div>
<form className="request-form" onSubmit={submit}>
<label className="field">
@@ -131,82 +364,32 @@ export function Queue() {
{active.length === 0 ? (
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
) : (
<section className="floor">
{(() => {
const now = Date.now();
return active.map((r) => {
const j = jobOf(r);
const d = describeJob(j.state, j.currentStage);
const attention = d.kind === "attention";
let meta: string | undefined;
if (j.state === "downloading" || j.state === "tagging") {
const parts: string[] = [];
if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`);
const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now);
if (elapsed) parts.push(elapsed);
meta = parts.join(" · ") || undefined;
} else if (j.state === "matching" || j.state === "matched") {
meta =
j.candidateCount > 0
? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}`
: "Searching sources…";
} else if (j.state === "requested") {
const elapsed = timeAgo(r.createdAt, now);
meta = elapsed ? `In the queue · ${elapsed}` : "In the queue";
} else {
meta = undefined;
}
const note = attention ? (
<>
{j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : undefined;
let bar: React.ReactNode;
if (j.state === "matching" || j.state === "matched") {
bar = <ProgressBar kind="working" indeterminate caption="searching" />;
} else if (j.state === "downloading") {
const pct = Math.round((j.downloadProgress || 0) * 100);
const total = j.chosen?.trackCount ?? 0;
const done = total ? Math.round((pct / 100) * total) : 0;
bar = (
<ProgressBar
kind="working"
percent={pct}
caption={total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`}
/>
);
} else if (j.state === "tagging") {
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
} else {
bar = undefined;
}
return (
<JobRow
key={r.id}
artist={r.artist}
album={r.album}
kind={d.kind}
label={d.label}
meta={meta}
note={note}
bar={bar}
/>
);
});
})()}
</section>
<>
<div className="tabs" role="tablist">
{(["active", "queue", "errors"] as Tab[]).map((t) => (
<button
key={t}
role="tab"
aria-selected={activeTab === t}
className={`tab${activeTab === t ? " active" : ""}${t === "errors" && buckets.errors.length ? " has-errors" : ""}`}
onClick={() => setTab(t)}
>
{TAB_LABEL[t]}
<span className="n">{buckets[t].length}</span>
</button>
))}
</div>
{shown.length === 0 ? (
<p className="empty">{TAB_EMPTY[activeTab]}</p>
) : (
<section className="floor">{shown.map(renderRow)}</section>
)}
</>
)}
{pressed.length > 0 ? (
<>
<SectionHeader title="Recently Pressed" note={`${pressed.length} in library`} />
<SectionHeader title="Recently Pressed" note={`last ${pressed.length} · ${pressedAll.length} in library`} />
<PressedList
items={pressed.map((r) => ({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r), rgMbid: r.rgMbid ?? null }))}
/>
+167 -30
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { HelpButton } from "../_ui/help-button";
type Loaded = {
qobuzEmail: string;
@@ -30,12 +31,12 @@ const MONITOR_FIELDS: [string, string][] = [
["monitor.retryIntervalHours", "Retry interval (hours)"],
["monitor.qualityCutoff", "Quality cutoff"],
["monitor.upgradeWindowDays", "Upgrade window (days)"],
["floor.jobDelaySeconds", "Delay between downloads (seconds)"],
];
export function SettingsForm() {
const [tab, setTab] = useState<Tab>("Qobuz");
const [qobuzEmail, setQobuzEmail] = useState("");
const [qobuzPassword, setQobuzPassword] = useState("");
const [qobuzEmail, setQobuzEmail] = useState(""); // legacy; no input, kept to detect stored creds
const [qobuzUserId, setQobuzUserId] = useState("");
const [qobuzToken, setQobuzToken] = useState("");
const [slskdUrl, setSlskdUrl] = useState("");
@@ -53,6 +54,8 @@ export function SettingsForm() {
const [discoverSaved, setDiscoverSaved] = useState(false);
const [monitorCfg, setMonitorCfg] = useState<Record<string, string>>({});
const [monitorSaved, setMonitorSaved] = useState(false);
const [pacingCfg, setPacingCfg] = useState<Record<string, string>>({});
const [pacingSaved, setPacingSaved] = useState(false);
useEffect(() => {
fetch("/api/scan")
@@ -105,14 +108,18 @@ export function SettingsForm() {
.then((c: Record<string, string>) => setMonitorCfg(c));
}, []);
useEffect(() => {
fetch("/api/qobuz/pacing")
.then((r) => r.json())
.then((c: Record<string, string>) => setPacingCfg(c));
}, []);
async function saveCredentials(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/config", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
qobuzEmail,
qobuzPassword,
qobuzUserId,
qobuzToken,
slskdUrl,
@@ -121,13 +128,11 @@ export function SettingsForm() {
lastfmUsername,
}),
});
setQobuzPassword("");
setQobuzToken("");
setSlskdApiKey("");
setLastfmApiKey("");
setSaved(true);
setTimeout(() => setSaved(false), 1500);
setPwSet(pwSet || qobuzPassword !== "");
setTokenSet(tokenSet || qobuzToken !== "");
setKeySet(keySet || slskdApiKey !== "");
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
@@ -163,16 +168,41 @@ export function SettingsForm() {
setTimeout(() => setMonitorSaved(false), 1500);
}
function setPacing(key: string, value: string) {
setPacingCfg((c) => ({ ...c, [key]: value }));
}
async function savePacing(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/qobuz/pacing", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(pacingCfg),
});
setPacingSaved(true);
setTimeout(() => setPacingSaved(false), 1500);
}
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
async function clearField(field: "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
await fetch(`/api/config?field=${field}`, { method: "DELETE" });
if (field === "qobuzPassword") { setPwSet(false); setQobuzPassword(""); }
if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); }
if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); }
if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); }
}
// Purge the legacy email/password login (kept working for backward-compat, but Qobuz
// often rejects it — the token path is preferred).
async function clearLegacyQobuz() {
await Promise.all([
fetch("/api/config?field=qobuzPassword", { method: "DELETE" }),
fetch("/api/config?field=qobuzEmail", { method: "DELETE" }),
]);
setPwSet(false);
setQobuzEmail("");
}
return (
<div>
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
@@ -186,27 +216,16 @@ export function SettingsForm() {
</div>
{tab === "Qobuz" ? (
<>
<form className="settings-section" onSubmit={saveCredentials}>
<div className="subhead">Email / password</div>
<div className="field-grid">
<label className="field">
<span>Qobuz email</span>
<input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} />
</label>
<label className="field">
<span>
Qobuz password{secretHint(pwSet)}
{pwSet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
aria-label="clear qobuz password" onClick={() => clearField("qobuzPassword")}>
Clear
</button>
) : null}
</span>
<input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• stored" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} />
</label>
<div className="subhead">
Auth token
<HelpButton title="Qobuz authentication">
Lyra signs in to Qobuz with a <b>user ID + auth token</b> the download engine
treats these as more reliable than email/password, which Qobuz often rejects. Find
them in your logged-in Qobuz web session (bundle/app secret + user auth token).
</HelpButton>
</div>
<div className="subhead">Auth token (more reliable; overrides email/password)</div>
<div className="field-grid">
<label className="field">
<span>Qobuz user ID</span>
@@ -216,7 +235,7 @@ export function SettingsForm() {
<span>
Qobuz auth token{secretHint(tokenSet)}
{tokenSet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
<button type="button" className="btn ghost sm"
aria-label="clear qobuz auth token" onClick={() => clearField("qobuzToken")}>
Clear
</button>
@@ -225,11 +244,70 @@ export function SettingsForm() {
<input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• stored" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} />
</label>
</div>
{pwSet || qobuzEmail ? (
<p className="help">
A legacy Qobuz email/password login is still stored (Qobuz often rejects it
the token above takes precedence).{" "}
<button type="button" className="btn ghost sm" aria-label="clear legacy qobuz login"
onClick={clearLegacyQobuz}>
Clear legacy login
</button>
</p>
) : null}
<div className="save-row">
<button type="submit" className="btn accent">Save</button>
{saved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
<form className="settings-section" onSubmit={savePacing}>
<div className="subhead">
Pacing
<HelpButton title="Qobuz pacing">
Throttles Qobuz to a human-looking volume and cadence so the account isn&apos;t flagged
for bulk downloading. Within active hours it downloads up to the daily cap, spaced out
with randomized gaps; when throttled, albums fall through to Soulseek/YouTube and
upgrade to Qobuz hi-res later. The daily cap ramps up over the first two weeks from the
new-account start date.
</HelpButton>
</div>
<label className="field-inline">
<input type="checkbox" aria-label="pace qobuz downloads"
checked={pacingCfg["qobuz.pacing.enabled"] !== "false"}
onChange={(e) => setPacing("qobuz.pacing.enabled", e.target.checked ? "true" : "false")} />
<span>Pace Qobuz downloads</span>
</label>
<div className="field-grid">
<label className="field">
<span>Active hours start (024)</span>
<input type="number" min={0} max={24} aria-label="qobuz active start hour"
value={pacingCfg["qobuz.pacing.activeStartHour"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.activeStartHour", e.target.value)} />
</label>
<label className="field">
<span>Active hours end (024)</span>
<input type="number" min={0} max={24} aria-label="qobuz active end hour"
value={pacingCfg["qobuz.pacing.activeEndHour"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.activeEndHour", e.target.value)} />
</label>
<label className="field">
<span>Daily cap (steady)</span>
<input type="number" min={0} aria-label="qobuz daily cap"
value={pacingCfg["qobuz.pacing.dailyCap"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.dailyCap", e.target.value)} />
</label>
<label className="field">
<span>New-account start (warm-up)</span>
<input type="date" aria-label="qobuz warmup start date"
value={pacingCfg["qobuz.pacing.warmupStartDate"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.warmupStartDate", e.target.value)} />
</label>
</div>
<div className="save-row">
<button type="submit" className="btn accent">Save</button>
{pacingSaved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
</>
) : null}
{tab === "Soulseek" ? (
@@ -244,7 +322,7 @@ export function SettingsForm() {
<span>
slskd API key{secretHint(keySet)}
{keySet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
<button type="button" className="btn ghost sm"
aria-label="clear slskd api key" onClick={() => clearField("slskdApiKey")}>
Clear
</button>
@@ -272,7 +350,7 @@ export function SettingsForm() {
<span>
Last.fm API key{secretHint(lastfmKeySet)}
{lastfmKeySet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
<button type="button" className="btn ghost sm"
aria-label="clear lastfm api key" onClick={() => clearField("lastfmApiKey")}>
Clear
</button>
@@ -299,6 +377,19 @@ export function SettingsForm() {
<code>docker-compose.yml</code> and rebuild it cant be remounted from here.
</div>
</div>
<div className="info-panel">
<div>
<span className="k">Staging path</span> &nbsp; <code>/staging</code>
</div>
<div className="muted-note" style={{ fontFamily: "inherit" }}>
Where each download is assembled + verified before being promoted into the library.
Set by <code>STAGING_DIR</code> in <code>docker-compose.yml</code> (defaults to{" "}
<code>MUSIC_DIR/.staging</code>). When your library is on a network share (SMB/NFS),
point <code>STAGING_DIR</code> at a <b>fast local disk</b> so temporary download I/O
doesnt hammer the share the finished album is copied onto the library volume and
swapped in atomically. Rebuild to change.
</div>
</div>
<p className="muted-note">Scan the library to record whats already on disk as owned + monitored.</p>
<div className="save-row">
<button type="button" className="btn" onClick={requestScan} disabled={scanRequested}>
@@ -325,6 +416,21 @@ export function SettingsForm() {
/>
Automatic monitoring (grab &amp; upgrade)
</label>
<HelpButton title="Monitoring">
When on, the worker periodically checks your followed artists: it <b>grabs new
releases</b> as they appear and <b>upgrades</b> owned albums that fall below the
quality cutoff.
<br />
<br />
<b>Quality cutoff</b> the minimum acceptable quality class: <code>1</code> = lossy,{" "}
<code>2</code> = CD-lossless (FLAC 16/44.1), <code>3</code> = hi-res. An owned album is
kept as-is when its the cutoff; otherwise a better copy is chased. Range 13.
<br />
<br />
<b>Poll interval</b> hours between new-release checks. <b>Retry interval</b> hours
before re-attempting a release that couldnt be found yet. <b>Upgrade window</b> how
many days after first acquiring an album to keep looking for a better copy.
</HelpButton>
</div>
<div className="field-grid">
{MONITOR_FIELDS.map(([key, label]) => (
@@ -352,6 +458,21 @@ export function SettingsForm() {
/>
Scheduled discovery sweep
</label>
<HelpButton title="Discovery">
When on, the worker periodically sweeps your followed artists for similar artists +
their new releases (via ListenBrainz and, if configured, Last.fm) and lists them on{" "}
<b>Discover</b>. Nothing is downloaded automatically you Follow/Want from there.
<br />
<br />
<b>Interval</b> hours between sweeps. <b>Seeds per chunk</b> how many followed
artists are processed per worker cycle (keeps job-claiming responsive on big rosters).
<b> Similar per seed</b> candidates pulled per artist. <b>Albums per artist</b>
releases surfaced per suggested artist. <b>Min score</b> drops weak suggestions.{" "}
<b>Albums only</b> suggest full-length albums only, hiding singles and EPs.{" "}
<b>Seed from library</b> also find artists similar to ones you own albums by, not
just the artists you follow.{" "}
<b>ListenBrainz base URL</b> leave blank for the default endpoint.
</HelpButton>
</div>
<div className="field-grid">
{DISCOVERY_FIELDS.map(([key, label]) => (
@@ -361,6 +482,22 @@ export function SettingsForm() {
</label>
))}
</div>
<label className="toggle" style={{ margin: "10px 0 0" }}>
<input
type="checkbox"
checked={discoverCfg["discover.albumsOnly"] !== "false"}
onChange={(e) => setCfg("discover.albumsOnly", e.target.checked ? "true" : "false")}
/>
Albums only (hide singles &amp; EPs)
</label>
<label className="toggle" style={{ margin: "6px 0 0" }}>
<input
type="checkbox"
checked={discoverCfg["discover.seedFromLibrary"] !== "false"}
onChange={(e) => setCfg("discover.seedFromLibrary", e.target.checked ? "true" : "false")}
/>
Seed from library (similar to albums you own)
</label>
<label className="field">
<span>ListenBrainz base URL (blank = default)</span>
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
+21 -2
View File
@@ -10,6 +10,7 @@ import { toast } from "../_ui/toast";
type Wanted = {
id: string;
artistName: string;
artistMbid: string | null;
album: string;
state: string;
currentQualityClass: number | null;
@@ -59,11 +60,25 @@ export function WantedClient() {
}
async function searchNow(w: Wanted) {
await fetch(`/api/releases/${w.id}/search`, { method: "POST" });
toast(`Searching for ${w.album}`);
const res = await fetch(`/api/releases/${w.id}/search`, { method: "POST" }).catch(() => null);
toast(res?.ok ? `Searching for ${w.album}` : `Couldn't search for ${w.album}`);
refresh();
}
async function ignore(w: Wanted) {
const res = await fetch(`/api/releases/${w.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ignored: true }),
}).catch(() => null);
if (res?.ok) {
setRows((list) => list.filter((x) => x.id !== w.id));
toast(`Ignoring ${w.album} — un-ignore from the artists discography`);
} else {
toast(`Couldn't ignore ${w.album}`);
}
}
return (
<div>
<PageHead title="Wanted" eyebrow="Cutting list · acquire & upgrade" />
@@ -108,6 +123,9 @@ export function WantedClient() {
<button className="btn sm ghost" onClick={() => searchNow(w)}>
Search now
</button>
<button className="btn sm ghost" onClick={() => ignore(w)}>
Ignore
</button>
</div>
</li>
))}
@@ -124,6 +142,7 @@ export function WantedClient() {
artist: open.artistName,
year: open.firstReleaseDate?.slice(0, 4) ?? null,
type: open.primaryType,
artistMbid: open.artistMbid,
}}
status={<span className="chip working">{stateLabel(open)}</span>}
actions={
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from "vitest";
import { cached, normKey } from "./apicache";
import { prisma } from "@/lib/db";
describe("cached (read-through)", () => {
it("fetches once, then serves the cached value without re-fetching", async () => {
const fetchFresh = vi.fn(async () => ({ v: 1 }));
const first = await cached("k1", 3600, fetchFresh);
const second = await cached("k1", 3600, fetchFresh);
expect(first).toEqual({ v: 1 });
expect(second).toEqual({ v: 1 });
expect(fetchFresh).toHaveBeenCalledTimes(1); // second served from cache
});
it("re-fetches once the TTL has elapsed", async () => {
const fetchFresh = vi.fn(async () => ({ v: 2 }));
await cached("k2", 3600, fetchFresh);
// backdate the row beyond the ttl
await prisma.apiCache.update({ where: { key: "k2" }, data: { fetchedAt: new Date(Date.now() - 7200_000) } });
await cached("k2", 3600, fetchFresh);
expect(fetchFresh).toHaveBeenCalledTimes(2);
});
it("serves a stale value when the fresh fetch throws", async () => {
await cached("k3", 3600, async () => ({ v: "old" }));
await prisma.apiCache.update({ where: { key: "k3" }, data: { fetchedAt: new Date(Date.now() - 7200_000) } });
const result = await cached("k3", 3600, async () => {
throw new Error("upstream down");
});
expect(result).toEqual({ v: "old" }); // stale-on-outage
});
it("rethrows when the fetch fails and there is no cached row", async () => {
await expect(
cached("k4", 3600, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
// nothing was cached
expect(await prisma.apiCache.findUnique({ where: { key: "k4" } })).toBeNull();
});
it("does not cache null results", async () => {
const fetchFresh = vi.fn(async () => null);
await cached("k5", 3600, fetchFresh);
await cached("k5", 3600, fetchFresh);
expect(fetchFresh).toHaveBeenCalledTimes(2); // null never cached → always re-fetched
expect(await prisma.apiCache.findUnique({ where: { key: "k5" } })).toBeNull();
});
it("force bypasses a fresh cached row and re-fetches + overwrites", async () => {
let n = 0;
const fetchFresh = () => Promise.resolve({ n: ++n });
expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 1 });
expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 1 }); // cache hit
expect(await cached("k6", 3600, fetchFresh, { force: true })).toEqual({ n: 2 }); // bypassed
expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 2 }); // overwrote the row
});
it("force still serves the stale row if the forced fetch fails", async () => {
await cached("k7", 3600, async () => ({ v: "cached" }));
const result = await cached("k7", 3600, async () => {
throw new Error("down");
}, { force: true });
expect(result).toEqual({ v: "cached" }); // stale-on-outage even on a forced refresh
});
it("normKey trims and lowercases", () => {
expect(normKey(" Radiohead ")).toBe("radiohead");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { prisma } from "@/lib/db";
// Read-through cache over the shared ApiCache table. Keyed on the LOGICAL operation (not the
// request URL) so the worker and web app share entries. TTL is applied here from fetchedAt,
// so TTLs are tunable in code with no migration. On a fetch failure a stale row is served.
// Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull).
export const CACHE_TTL = {
HOUR: 3_600,
DAY: 86_400,
WEEK: 7 * 86_400,
MONTH: 30 * 86_400,
};
/** Read-through cache. Pass `{ force: true }` to bypass a fresh cached row and re-fetch
* (a manual Refresh) — a stale row is still served if the forced fetch then fails. */
export async function cached<T>(
key: string,
ttlSec: number,
fetchFresh: () => Promise<T>,
opts?: { force?: boolean },
): Promise<T> {
const hit = await prisma.apiCache.findUnique({ where: { key } });
if (!opts?.force && hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) {
return hit.json as T;
}
try {
const fresh = await fetchFresh();
if (fresh !== null && fresh !== undefined) {
await prisma.apiCache.upsert({
where: { key },
create: { key, json: fresh as object },
update: { json: fresh as object, fetchedAt: new Date() },
});
}
return fresh;
} catch (e) {
if (hit) return hit.json as T; // serve stale on an upstream outage
throw e;
}
}
/** Normalize a free-text search term so casing/whitespace don't fragment the cache. MUST
* match the worker's normalization byte-for-byte (both: trim + lowercase). */
export function normKey(s: string): string {
return s.trim().toLowerCase();
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { resolveArtistChoice, type ArtistHit } from "./artist-resolve";
const US: ArtistHit = { mbid: "us", name: "Looking Glass", disambiguation: "70's US pop group" };
const AU: ArtistHit = { mbid: "au", name: "Looking Glass", disambiguation: "Australian stoner rock" };
describe("resolveArtistChoice", () => {
it("is ambiguous when 2+ candidates share the exact name", () => {
expect(resolveArtistChoice("Looking Glass", [US, AU], "au")).toEqual({
kind: "ambiguous",
candidates: [US, AU],
});
});
it("uses the single exact-name match even over a disagreeing fallback mbid", () => {
const hit: ArtistHit = { mbid: "r", name: "Radiohead", disambiguation: "" };
expect(resolveArtistChoice("Radiohead", [hit], "wrong-lastfm-mbid")).toEqual({
kind: "single",
mbid: "r",
});
});
it("matches exact name case- and whitespace-insensitively", () => {
expect(resolveArtistChoice(" looking GLASS ", [US, AU])).toEqual({
kind: "ambiguous",
candidates: [US, AU],
});
});
it("falls back to the provided mbid when no candidate name matches exactly", () => {
const fuzzy: ArtistHit = { mbid: "x", name: "The Beatles", disambiguation: "" };
expect(resolveArtistChoice("Beatles", [fuzzy], "lastfm-mbid")).toEqual({
kind: "single",
mbid: "lastfm-mbid",
});
});
it("falls back to MB's top hit when there is no exact match and no fallback", () => {
const fuzzy: ArtistHit = { mbid: "top", name: "The Beatles", disambiguation: "" };
expect(resolveArtistChoice("Beatles", [fuzzy])).toEqual({ kind: "single", mbid: "top" });
});
it("is none when there are no candidates and no fallback", () => {
expect(resolveArtistChoice("Nobody Here", [])).toEqual({ kind: "none" });
});
});
+34
View File
@@ -0,0 +1,34 @@
export type ArtistHit = { mbid: string; name: string; disambiguation: string };
export type Resolution =
| { kind: "single"; mbid: string }
| { kind: "ambiguous"; candidates: ArtistHit[] }
| { kind: "none" };
const norm = (s: string) => s.trim().toLowerCase();
/**
* Decide which MusicBrainz artist a Last.fm row refers to, given MB's search candidates.
*
* The bug this guards against: Last.fm's own MBID for an ambiguous name can point at the
* wrong same-named band (it mapped "Looking Glass" to an Australian stoner band, not the US
* pop group). So we prefer MB's own resolution:
* - 2+ candidates whose name EXACTLY matches (normalized) → ambiguous; let the user pick.
* - exactly 1 exact-name match → use it, even if it disagrees with `fallbackMbid`.
* - no exact-name match → keep prior behavior: the Last.fm `fallbackMbid`, else MB's top hit.
*
* `candidates` is assumed to arrive in MB relevance order (best first), so `candidates[0]`
* is the suggested default and exact-name matches keep that ordering.
*/
export function resolveArtistChoice(
name: string,
candidates: ArtistHit[],
fallbackMbid?: string | null,
): Resolution {
const exact = candidates.filter((a) => norm(a.name) === norm(name));
if (exact.length >= 2) return { kind: "ambiguous", candidates: exact };
if (exact.length === 1) return { kind: "single", mbid: exact[0].mbid };
if (fallbackMbid) return { kind: "single", mbid: fallbackMbid };
if (candidates.length > 0) return { kind: "single", mbid: candidates[0].mbid };
return { kind: "none" };
}

Some files were not shown because too many files have changed in this diff Show More