diff --git a/web/src/app/api/library/duplicates/route.test.ts b/web/src/app/api/library/duplicates/route.test.ts new file mode 100644 index 0000000..c9648e3 --- /dev/null +++ b/web/src/app/api/library/duplicates/route.test.ts @@ -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([]); + }); +}); diff --git a/web/src/app/api/library/duplicates/route.ts b/web/src/app/api/library/duplicates/route.ts new file mode 100644 index 0000000..d4a645f --- /dev/null +++ b/web/src/app/api/library/duplicates/route.ts @@ -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(); + 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(); + const byTitle = new Map(); + 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(); + 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 }); +} diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index 5aaf098..e868634 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -32,10 +32,12 @@ const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number ]; 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 }; export function LibraryClient() { const [albums, setAlbums] = useState([]); const [unmatched, setUnmatched] = useState([]); + const [duplicates, setDuplicates] = useState([]); const [q, setQ] = useState(""); const [sort, setSort] = useState("added"); const [open, setOpen] = useState(null); @@ -102,6 +104,9 @@ export function LibraryClient() { 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(); @@ -170,6 +175,43 @@ export function LibraryClient() { ) : null} + {duplicates.length > 0 ? ( + <> + +

+ 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. +

+ {duplicates.map((group, gi) => ( +
    + {group.map((d) => { + const album = albums.find((a) => a.id === d.id); + return ( +
  • +
    + {album ? ( + + ) : ( +
    {d.artist} — {d.album}
    + )} +
    + + {d.format} + {d.qualityClass >= 3 ? " · hi-res" : ""} + {d.year ? ` · ${d.year}` : ""} + +
    +
    +
  • + ); + })} +
+ ))} + + ) : null} + {unmatched.length > 0 ? ( <>