Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.6 KiB
Cleanup batch — Tier A (loose ends) + Tier B (design tradeoffs)
Date: 2026-07-13
Status: design approved, pending spec review
Branch: cleanup/tier-a-b
Goal
Close the deferred backlog down to zero fixes so the codebase has no known loose ends before we brainstorm net-new features (Tier C). This batch covers the clear hardening/polish items (Tier A) and the two benign-but-real design tradeoffs the user chose to fix properly (Tier B). Tier C (Last.fm source, crash-resume mid-pipeline, per-track duration matching, UI error states) is explicitly out of scope here — it goes to the feature brainstorm next.
Non-goals: no behavior change to the acquisition pipeline, no new user-facing features, no schema change beyond the two additive tables below.
Tier A — loose ends (web, mechanical)
A1. PATCH allowlist hardening
Both web/src/app/api/monitor/config/route.ts and
web/src/app/api/discover/config/route.ts PATCH handlers currently gate writes on
k in DEFAULTS, which walks the prototype chain. The DELETE /api/config handler
was already hardened to Object.prototype.hasOwnProperty.call(DEFAULTS, k); apply
the same own-property check to both PATCH routes. This only ever prevented writing
harmless junk Config rows (neither PATCH deletes), so this is consistency
hardening, not a live vulnerability — but it removes the last prototype-chain gap.
A2. a11y + copy
- Add an
aria-labelto the Monitor tabenabledtoggle (it currently has none; every other control on the page is labelled and web tests key off labels). - Update the Settings page eyebrow at
web/src/app/settings/settings-form.tsx:159from"Credentials · library · discovery"to cover all five tabs, e.g."Credentials · library · monitor · discovery".
A3. DELETE allowlist test
Add a ?field=constructor case to the DELETE /api/config allowlist test
(web/src/app/api/config/route.test.ts) alongside the existing __proto__ case, so
the own-property guard is covered against both prototype-chain keys. Structural fix
already in place; this is belt-and-suspenders coverage.
Tier B1 — discovery scoring: per-seed contributions table (worker + migration)
Problem
_upsert_artist / _upsert_album upsert with score = EXCLUDED.score (replace),
but the intended semantic is score = sum of every seed's contribution. Since
discover.maxSeeds was retired and chunking is now the default, a single sweep
partitions its seeds across many worker iterations. A candidate surfaced by seeds in
different chunks has its score overwritten by the last chunk instead of summed
(item 4), and the same replace-not-accumulate breaks across throttled sweeps
(item 5). The score a suggestion shows reflects only its most-recent chunk, not the
whole library's affinity.
Design
Introduce a normalized contributions table so a suggestion's score is a derived aggregate, independent of chunk and sweep boundaries.
model DiscoverySeedContribution {
id String @id @default(cuid())
candidateMbid String
candidateName String
seedMbid String
score Float
sources String[]
updatedAt DateTime @updatedAt
@@unique([candidateMbid, seedMbid])
@@index([candidateMbid])
@@index([seedMbid])
}
run_discovery, per seed S processed in the chunk:
affected := SELECT DISTINCT "candidateMbid" WHERE "seedMbid" = S— the candidates S used to contribute to (so drop-offs are recomputed too).DELETE FROM "DiscoverySeedContribution" WHERE "seedMbid" = S.- Build a per-seed aggregate over the live sources: for each similar candidate
(skipping already-followed mbids), sum score across sources and union source
names.
INSERTone contribution row per (candidate, S). affected |= {candidate mbids newly inserted for S}.
After the chunk's seeds are processed, recompute every candidate in the union of all
affected sets:
SELECT SUM(score), COUNT(*), <sources>, any candidateName FROM "DiscoverySeedContribution" WHERE "candidateMbid" = ANY(affected) GROUP BY "candidateMbid".COUNT(*)is the seedCount (unique on(candidate, seed)→ one row per seed).- For each with
score >= min_score, upsertDiscoverySuggestionexactly as today, keeping theWHERE status = 'pending'guard (dismissed/wanted rows untouched) and the rowcount-drives-album-derivation logic. Album suggestions inherit the recomputed artist aggregate.
Properties
- Score is now exactly the sum of all seeds' latest contributions — no chunk artifact, no partial-sweep reset. A sweep triggered by one newly-followed artist updates only that seed's rows and leaves every other seed's contribution intact.
- Deleting-then-reinserting a seed's rows self-heals stale entries: if S stops
finding a candidate, S's row for it vanishes and the candidate is recomputed
(seedCount drops) because it was in
affected. - A candidate whose contributions fall to zero: its pending suggestion is left as is (not auto-retracted), consistent with today's semantics — suggestions persist until dismissed/actioned. Documented, not a bug.
Migration & cleanup
- Additive table only; no data backfill (existing suggestions keep their current score and are corrected on the next sweep that touches their seeds).
- Consider a one-time note: existing
DiscoverySuggestionscores stay stale until re-swept — acceptable for an off-by-default feed.
Tier B2 — scan worklist table (worker + migration)
Problem
scan_chunk re-walks the whole /music tree every chunk and skips entries sorting
<= cursor — O(N²/C) directory stat ops over a full scan. The scan is
MB-rate-limited (~1 req/s), so this is ~2% overhead, but the only true O(N) fix is
a persisted worklist (a JSON-blob-in-Config would just move the cost to re-parsing
the blob each chunk).
Design
Persist the album worklist once at scan start and pop it by indexed cursor.
model ScanWorkItem {
id String @id @default(cuid())
scanId String
artist String
album String
path String
done Boolean @default(false)
@@unique([scanId, path])
@@index([scanId, done, artist, album])
}
scan.py:
scan_build_worklist(conn, scan_id, dest_root)— walk once via the existing_iter_album_entries,INSERTa row per album folder (ON CONFLICT (scanId, path) DO NOTHING, so a rebuild is idempotent). Storeartist,album(= album folder),pathso the chunk needs no re-derivation.scan_chunk(conn, resolver, probe, browser, scan_id, limit)—SELECTthe nextlimitrowsWHERE scanId = %s AND NOT done ORDER BY artist, album, process each via the existing_process_album, markdone = true. Returns(imported, skipped, done)wheredoneis True when no undone rows remain.scan_library(conn, resolver, probe, browser, dest_root)(test-only unbounded path) — reimplemented on the worklist: build with a fixed scan id, drain fully, delete that scan's rows, returnScanResult. Keeps existing tests meaningful.
main.maybe_run_scan:
- On start (
scan.requestedand not in progress): setscan.inProgress=true,scan.requested=false,scan.progress="0/0", generate ascan_id(uuid), store it inscan.id, andscan_build_worklist. - Each iteration: read
scan.id, pop one chunk, accumulate imported/skipped inscan.progress. - On drain: write
scan.result(unchanged"imported X, skipped Y"format), clearscan.progress/scan.id, setscan.inProgress=false, andDELETE FROM "ScanWorkItem" WHERE scanId = %s. Preserve the existing "rescan requested mid-scan triggers one more full pass" guarantee. scan.cursoris retired (worklist replaces the string cursor).
Properties
O(log N + C)per chunk via the(scanId, done, artist, album)index; one tree walk per scan total.- Robust to folders appearing/disappearing mid-scan: the worklist is frozen at scan start, so the set processed is deterministic.
- Resumable/idempotent:
doneflag +ON CONFLICTinserts +ON CONFLICTrecord writes mean an interrupted worker resumes cleanly.
Migration
One additive Prisma migration adds both tables (DiscoverySeedContribution,
ScanWorkItem). No column changes to existing tables, no backfill. Reaches live
lyra via the web entrypoint's migrate deploy on rebuild.
Testing
- Worker (
pytest, againstlyra_test):- Contribution accumulation: a candidate surfaced by seeds split across two simulated chunks sums to the same score as a single-chunk sweep; a partial sweep (one seed) preserves other seeds' contributions; a seed dropping a candidate lowers its seedCount and recomputes the suggestion.
- Scan worklist: build → pop chunks → drain marks all done; resume after a partial
chunk; idempotent rebuild;
scan_libraryunbounded path still returns the sameScanResult.
- Web (
vitest):- PATCH
/api/monitor/configand/api/discover/configreject a prototype-chain key (__proto__,constructor) — noConfigwrite. - DELETE
/api/config?field=constructorrejected (added to existing test).
- PATCH
- Full suites +
tsc+next buildgreen before merge.
Delivery
Branch cleanup/tier-a-b → writing-plans → subagent-driven build (per-task review +
Opus whole-branch review) → ff-merge to main → git push →
docker compose up -d --build web worker (runs migrate deploy) → verify live
(web 200, worker healthy, migration applied). Then move to the Tier C feature
brainstorm.
Out of scope (→ Tier C brainstorm)
Last.fm second SimilaritySource, crash-resume mid-pipeline, per-track duration
matching, UI error states on mutations.