The Modal focus-trap effect depended on [open, onClose] and called
panelRef.focus() on every run. Callers that pass a fresh onClose each render
(the common case, e.g. artists-client's closeAdd) re-ran the effect on every
parent re-render — so typing one char into the add-artist search (setQuery →
re-render → new onClose) yanked focus off the <input> back onto the panel,
making the field untypable.
Hold the latest onClose in a ref and depend on [open] alone, so focus-on-open
runs once per open. Fixes every Modal caller, not just add-artist.
Verified live via Playwright: typing "radiohead" one key at a time keeps the
value and document.activeElement on the search input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-merge review of the P0 branch:
- Open redirect: login redirected to an unvalidated ?next param, so a crafted
/login?next=https://evil (or //evil) bounced off-site after login. Now only
same-origin relative paths are honored (else fall back to "/").
- Remove the unused useRouter import/binding in the login form.
- Memoize expectedToken() keyed on (password, secret) so middleware stops
recomputing an HMAC on every gated request.
Deferred (low-value/latent, noted): Soulseek cover.jpg isn't retrieved into
staging (UI still shows Cover Art Archive art); _retrieve's basename-keyed map
could undercount same-named files but source_refs come from a single slskd dir;
SignOut visibility rides a server-env read (cosmetic).
web 138 tests green, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.env.example shipped a valid, PUBLIC base64 key — if a user didn't replace it,
every credential got encrypted with a publicly-known key and everything still
"worked," so the footgun was silent.
- .env.example now ships an obvious placeholder (CHANGE_ME_generate_with_...)
with a "you MUST replace this" note and the openssl hint.
- web + worker validate LYRA_SECRET_KEY at startup: missing / placeholder /
wrong-length ⇒ loud FATAL banner and refuse to start (web via the Next.js
instrumentation hook, worker via _require_secret_key before touching the DB).
The old public example key ⇒ a loud WARNING but not fatal, since an existing
install may have encrypted its creds under it (rotating needs re-entry).
- Validation lives in an edge-safe web/src/lib/secret-key.ts (no node:crypto)
so the instrumentation hook bundles for both runtimes; crypto.ts re-exports.
- README emphasizes generating a fresh key + keeping it stable.
Verified live: worker exits 1 on placeholder/unset before any DB call; web
instrumentation throws and refuses to serve on the placeholder. web 138 tests,
worker 204/7-skip, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web app had no auth — anyone reaching the host could browse the library,
trigger downloads, and read/overwrite the stored credentials in Settings. Adds
an opt-in single-shared-password gate:
- Next.js middleware redirects unauthenticated page requests to /login and
returns 401 for /api/*; static assets are excluded via matcher.
- Auth is enabled ONLY when LYRA_PASSWORD is set (unset ⇒ app stays open, so
existing deployments are unchanged). Documented in README "Security".
- The session cookie is httpOnly and carries HMAC-SHA256(password) keyed by
LYRA_SECRET_KEY — unforgeable, and the plaintext password never touches the
client. Edge-safe Web Crypto so one lib/auth.ts serves middleware + route.
- Cookie is NOT Secure: Lyra serves plain HTTP on the LAN; a Secure cookie would
loop. Works behind an HTTPS proxy too.
- /login page + form, a Sign out button (shown only when auth is enabled), and
chrome (nav + worker-status poll) suppressed on /login.
- docker-compose passes LYRA_PASSWORD to web; .env.example documents it.
Verified live end-to-end (built + next start): no-cookie page→307 /login,
API→401, wrong password→401, correct→200+cookie, authed page/API→200, forged
cookie→401, and the auth-disabled (no LYRA_PASSWORD) path stays fully open.
web 133 tests green, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All durable state (library, monitor/wanted lists, discovery, config, and the
encrypted credentials) lived only in the single postgres-data volume with no
backup — one volume loss = total loss. Adds a db-backup compose sidecar that
pg_dumps immediately on start then every BACKUP_INTERVAL_SECONDS (default daily),
keeping the last BACKUP_KEEP (default 7) custom-format dumps in ${BACKUP_DIR}.
Travels with the stack (no host cron), starts with `up -d`. .env.example
documents the knobs; README adds a "Backup & restore" section with pg_restore
steps and an off-box note. Verified live: sidecar wrote a valid 128K dump of the
real lyra DB (pg_restore -l lists all tables + _prisma_migrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SlskdClient enqueued the transfer and polled to Completed but never moved any
files into `dest` — slskd saves them to its OWN downloads dir, so staging stayed
empty and every Soulseek job failed "incomplete download". Qobuz/YouTube write
straight into staging and were unaffected.
- SlskdClient._retrieve() moves the finished files out of slskd's downloads dir
into staging after completion, matching tolerantly by basename and preferring
a parent-folder + size match (slskd's on-disk layout varies by version).
- download() returns the moved count as track_count so the pipeline's
completeness check reflects what actually landed; raises loudly if nothing was
found (misconfigured/unmounted downloads dir).
- Downloads dir resolves slskd.downloadsDir Config > SLSKD_DOWNLOADS_ROOT env >
/slskd-downloads default; docker-compose mounts ${SLSKD_DOWNLOADS_DIR} there.
- .env.example documents SLSKD_DOWNLOADS_DIR; retrieval unit-tested offline.
slskd runs as an external daemon, so the shared dir must be a host path both
slskd and the worker mount — set once Lyra runs on the slskd host. Not yet
verified live end-to-end (no slskd access from this VM).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Last.fm browse page has the same live-external-call-per-load problem;
name the cache table/helper generically (ApiCache) so Last.fm rides the
same read-through, with a manual Refresh button and live own-state
annotation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A read-through cache shared by the web app and the worker so reopening an
artist/album (and repeat lookups during scans/discovery) stop re-hitting
MusicBrainz. Postgres-only (Redis disregarded — PG is already shared across
web + worker); keys on the logical operation so both processes share entries;
serve-stale-on-outage; TTL-only invalidation. Four tasks: MbCache table →
web read-through → worker CachingMbBrowser → client Map + pruning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the README current with everything shipped since it was written:
Last.fm as a second discovery source + the /lastfm browse page, the
/library grid, The Floor's three-phase progress + retry, and the
Pressing Plant design system. Add a "Running it" quick-start. Fix a
pre-existing inaccuracy: slskd is an external daemon reached via
config, not a compose service (stack is db + web + worker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the misleading 6-micro-step progress bar with three honest
phases (Searching -> Downloading -> Finishing). Downloading now shows
a live determinate percentage sourced from job.downloadProgress (Slice
A), with a "NN% · done/total tracks" caption when the track count is
known. Searching and Finishing stay indeterminate.
- status.ts: describeJob collapses to {label, kind}; drop step/totalSteps
and the STAGES array.
- progress-bar.tsx: ProgressBar takes {kind, indeterminate?, percent?,
caption?} instead of step/total/stageLabel.
- job-row.tsx: JobRow takes a `bar?: ReactNode` slot instead of
step/total/indeterminate/stageLabel props.
- queue.tsx: builds the per-state bar + meta; downloading meta drops
the track count (now shown in the bar). Retry button + error note
from the retry feature preserved exactly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Job.downloadProgress (0.0-1.0), derived from a worker background
poller that counts audio files landing in staging / expected track
count, since streamrip only reports 0/1. No UI yet (slice B).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add POST /api/requests/[id]/retry: 404 if the request/job is missing,
400 if the job isn't in needs_attention, otherwise clears the job's
stale candidates and resets it to state=requested/currentStage=intake
(error/claimedAt cleared) plus request status=pending, in a
transaction, so the worker's requested-only claim re-picks it up.
Wire a Retry button into the attention-row note on the Floor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each search/download called asyncio.run, creating and closing a fresh event loop.
streamrip's module-global download-concurrency Semaphore binds to the loop on first
use, so the 2nd+ download reused a Semaphore bound to a closed loop -> "Semaphore is
bound to a different event loop", tracks failed, album arrived short -> "incomplete
download" / needs_attention. Run all streamrip coroutines on one persistent loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_mark_chosen ran only after a download succeeded, so throughout the 'downloading'
state no candidate was chosen — the Floor row's "source · format · tracks" line was
blank (only elapsed showed). Now mark the candidate exclusively at each download
attempt so `chosen` reflects the source in flight (and updates on fall-through).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Last.fm returns error 27 "Deprecated" for user.getArtistTracks, so the per-artist
plays modal was 502ing for every artist. Pivot to filtering the user's supported
all-time top tracks + top albums (which carry real per-item play counts) to the
artist — exact personal counts, cheaper, and the clicked artists are the user's
top artists so their items cluster near the front. Adds topTracks(); route + modal
return shape unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an optional lastfmName prop to ArtistModal. When set (only on
/lastfm) it fetches /api/lastfm/artist-plays independently of the
existing preview fetch and renders a most-played-songs list plus a
most-played-albums list above the release grid. Album Want resolves
via /api/mb/release-group then POSTs /api/discover/want, mirroring
the existing want(r) flow. Own-state chips name-match Last.fm albums
against the already-loaded release grid. Other ArtistModal callers
(discover-client.tsx) pass no lastfmName, so their behavior is
unchanged.
searchReleaseGroup took the first MusicBrainz hit, so a title MB ranks with the
single first (e.g. "Thriller" ties single+album at score 100) resolved to the
single — the Last.fm album modal and the Want flow then showed a 1-track single.
Prefer a clean Album > EP > Single > album-with-secondary, keeping MB order as the
in-tier tiebreak; single-only titles still resolve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds LastfmSource, resolving Last.fm's often-empty artist mbid via the
injected MB browser (name->mbid cached) so every emitted SimilarArtist.mbid
is a real MusicBrainz artist MBID. Registered in build_similarity_sources,
gated on lastfm.api_key so the ListenBrainz-only path is unchanged when
unset.
Two row-leak bugs found in whole-branch review:
- discovery.run_discovery now prunes DiscoverySeedContribution rows whose
seedMbid is no longer a WatchedArtist (unfollow/removal has no FK cascade)
and recomputes the touched candidates' scores this sweep, instead of
letting a dead seed's contribution inflate the aggregate forever.
- maybe_run_scan's exception handler around scan_chunk now calls
clear_worklist and clears scan.id, matching the drain path. Previously
an exception mid-chunk left the already-done rows plus the remaining
worklist for that scan_id orphaned forever, since the next scan mints a
fresh uuid. Also clear scan.id in the build_worklist failure handler for
consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace scan_chunk's O(N^2) tree re-walk-and-skip-to-cursor with a
persisted ScanWorkItem worklist: build_worklist walks the tree once at
scan start and inserts one row per album (idempotent via ON CONFLICT),
scan_chunk pops up to `limit` not-done rows by indexed cursor and marks
them done, and clear_worklist drops the scan's rows once drained.
scan_library is reimplemented on top of the worklist (one-shot scan_id,
drain, clear) with an unchanged signature/behavior. main.py's
maybe_run_scan now drives a generated scan.id through Config
(scan.id/scan.inProgress/scan.progress/scan.result) instead of a
scan.cursor string, so a mid-scan library change no longer risks
skipping or re-walking albums.
The worker never reads the global monitor.autoMonitorFuture Config key;
auto-monitor-future is governed solely by the per-artist
WatchedArtist.autoMonitorFuture toggle on the Artists page. Removes the
misleading no-op checkbox and its DEFAULTS/test entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconciled the pre-existing test_config_from_config_parses_and_defaults
test in test_discovery.py, which referenced max_seeds/maxSeeds and would
otherwise have broken.
Adds a crash-recovery preamble that restores `final` from `.old` when a
prior swap was interrupted after rename(final, old) but before
rename(tmp, final), and always drops stale `.old`/`.importing` orphans.
Also guards the swap: if nothing new was staged into tmp (empty/missing
staging) and `final` already exists, leave it untouched instead of
swapping in an empty copy. This is required beyond the literal preamble
because the pre-existing swap logic unconditionally replaced `final`
with `tmp` regardless of whether anything was actually imported, which
would otherwise destroy a just-recovered (or any existing) copy whenever
staging is empty.
Two concurrent Wants for the same release-group both pass the
findUnique existence check as absent; the loser's create() then hits
the rgMbid unique constraint and threw an uncaught P2002, 500ing the
request. Wrap the create in try/catch and fall back to the same
update-and-return-200 path on P2002, converging instead of erroring.
Non-P2002 errors are rethrown unchanged.
A `"` or `\` inside artist/album terminates the quoted Lucene phrase
and corrupts the MusicBrainz search query. Escape both terms before
interpolation.
FIELDS[field] walked the prototype chain, so field=__proto__ (or
constructor, hasOwnProperty, etc.) resolved to a truthy inherited value
whose .key was undefined. Prisma drops undefined filter values, so
deleteMany({ where: { key: undefined } }) collapsed to deleteMany({}),
wiping the entire Config table. Guard with an own-property check before
indexing FIELDS so only real allowlist entries pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement GET and PATCH endpoints for monitor configuration, matching the existing discover config pattern. GET returns stored or default values; PATCH upserts known keys with allowlist filtering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Staging is no longer forced under the library. The worker reads STAGING_ROOT
(container path, default /music/.staging) and stages per-job downloads there;
docker-compose mounts ${STAGING_DIR:-${MUSIC_DIR}/.staging} at /staging with
STAGING_ROOT=/staging. Set STAGING_DIR to a fast local disk when the library
(MUSIC_DIR) is a network share so temp download I/O stays off the share.
Safe cross-volume: import_album already assembles into a temp dir on the
library volume and swaps atomically there, so partial downloads never touch
the library and the final swap stays atomic. clear_staging_root now clears the
root's contents (mount-safe) rather than removing the root. Documented in
.env.example; new tests cover the separate-root path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>