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>
This commit is contained in:
Jonathan
2026-07-14 20:56:31 +02:00
parent 9e5d5d3af4
commit 6ca39859fa
9 changed files with 212 additions and 2 deletions
@@ -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");
+15
View File
@@ -191,6 +191,21 @@ model ScanWorkItem {
@@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).
@@ -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 });
}
+47
View File
@@ -31,8 +31,11 @@ const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number
{ 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 };
export function LibraryClient() {
const [albums, setAlbums] = useState<Album[]>([]);
const [unmatched, setUnmatched] = useState<Unmatched[]>([]);
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortKey>("added");
const [open, setOpen] = useState<Album | null>(null);
@@ -60,11 +63,23 @@ export function LibraryClient() {
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 ?? []));
}
useEffect(() => {
refresh();
}, []);
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();
const filtered = needle
@@ -119,6 +134,38 @@ export function LibraryClient() {
</div>
) : 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
+1
View File
@@ -18,4 +18,5 @@ beforeEach(async () => {
await prisma.discoverySuggestion.deleteMany();
await prisma.config.deleteMany();
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
await prisma.unmatchedAlbum.deleteMany();
});