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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user