From 7719b2a0d8e43dbf9c6f95ae17dec2bf90a0a610 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 21:20:39 +0200 Subject: [PATCH] feat(library): on-demand integrity check GET /api/library/integrity stats every album folder on disk (via the /music mount) and flags: missing folders, folders with no audio, zero-byte/ unreadable (truncated) files, and on-disk track count drift vs the names captured at import/scan. Metadata-only (readdir + stat), so it runs only when the user clicks "Check now" from a new Integrity section on /library; each flagged album opens its modal to fix or delete. SectionHeader.note widened to ReactNode to host the button. web 178 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/section-header.tsx | 4 +- .../app/api/library/integrity/route.test.ts | 63 +++++++++++++++++++ web/src/app/api/library/integrity/route.ts | 51 +++++++++++++++ web/src/app/library/library-client.tsx | 62 ++++++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 web/src/app/api/library/integrity/route.test.ts create mode 100644 web/src/app/api/library/integrity/route.ts diff --git a/web/src/app/_ui/section-header.tsx b/web/src/app/_ui/section-header.tsx index ef14af5..0c1a528 100644 --- a/web/src/app/_ui/section-header.tsx +++ b/web/src/app/_ui/section-header.tsx @@ -1,4 +1,6 @@ -export function SectionHeader({ title, note }: { title: string; note?: string }) { +import type { ReactNode } from "react"; + +export function SectionHeader({ title, note }: { title: string; note?: ReactNode }) { return (

{title}

diff --git a/web/src/app/api/library/integrity/route.test.ts b/web/src/app/api/library/integrity/route.test.ts new file mode 100644 index 0000000..9efe3f7 --- /dev/null +++ b/web/src/app/api/library/integrity/route.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { prisma } from "@/lib/db"; +import { GET } from "./route"; + +let root: string; +const original = process.env.LYRA_LIBRARY_ROOT; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lyra-integ-")); + process.env.LYRA_LIBRARY_ROOT = root; +}); +afterEach(() => { + if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT; + else process.env.LYRA_LIBRARY_ROOT = original; + rmSync(root, { recursive: true, force: true }); +}); + +async function item(artist: string, album: string, dir: string, trackNames: string[] = []) { + return prisma.libraryItem.create({ + data: { artist, album, path: dir, source: "scan", format: "FLAC", qualityClass: 2, trackNames }, + }); +} + +describe("library integrity API", () => { + it("flags a healthy album with no issues", async () => { + const dir = join(root, "A", "Good (2020)"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01 One.flac"), "xxxx"); + writeFileSync(join(dir, "02 Two.flac"), "yyyy"); + await item("A", "Good", dir, ["01 One.flac", "02 Two.flac"]); + const { checked, issues } = await (await GET()).json(); + expect(checked).toBe(1); + expect(issues).toEqual([]); + }); + + it("flags a missing folder, empty files, and a track-count drift", async () => { + await item("Gone", "Missing", join(root, "Gone", "Missing")); + + const zdir = join(root, "Z", "Zero (2021)"); + mkdirSync(zdir, { recursive: true }); + writeFileSync(join(zdir, "01 A.flac"), ""); // 0 bytes → truncated + writeFileSync(join(zdir, "02 B.flac"), "ok"); + await item("Z", "Zero", zdir, ["01 A.flac", "02 B.flac", "03 C.flac"]); // expected 3, on disk 2 + + const { issues } = await (await GET()).json(); + const byAlbum = Object.fromEntries(issues.map((i: { album: string; issues: string[] }) => [i.album, i.issues])); + expect(byAlbum["Missing"]).toEqual(["folder is missing on disk"]); + expect(byAlbum["Zero"]).toContain("1 empty/unreadable file"); + expect(byAlbum["Zero"]).toContain("2 of 3 tracks on disk"); + }); + + it("flags a folder with no audio", async () => { + const dir = join(root, "N", "NoAudio"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "cover.jpg"), "img"); + await item("N", "NoAudio", dir); + const { issues } = await (await GET()).json(); + expect(issues[0].issues).toEqual(["no audio files in the folder"]); + }); +}); diff --git a/web/src/app/api/library/integrity/route.ts b/web/src/app/api/library/integrity/route.ts new file mode 100644 index 0000000..8ce3c6b --- /dev/null +++ b/web/src/app/api/library/integrity/route.ts @@ -0,0 +1,51 @@ +import { readdir, stat } from "node:fs/promises"; +import { join, resolve, sep, extname } from "node:path"; +import { prisma } from "@/lib/db"; + +// GET /api/library/integrity — on-demand disk check of every owned album folder (via the +// /music mount). Flags folders that are missing, hold no audio, contain zero-byte (truncated) +// files, or whose on-disk audio count drifted from what was captured at import/scan. Cheap +// (metadata only: readdir + stat), so it runs only when the user asks. +const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]); + +export async function GET() { + const items = await prisma.libraryItem.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] }); + const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music"); + + const results = await Promise.all( + items.map(async (li) => { + const dir = resolve(li.path); + const issues: string[] = []; + if (dir !== root && !dir.startsWith(root + sep)) { + return { id: li.id, artist: li.artist, album: li.album, issues: ["path is outside the library root"] }; + } + let names: string[]; + try { + names = await readdir(dir); + } catch { + return { id: li.id, artist: li.artist, album: li.album, issues: ["folder is missing on disk"] }; + } + const audio = names.filter((n) => AUDIO_EXT.has(extname(n).toLowerCase())); + if (audio.length === 0) { + issues.push("no audio files in the folder"); + } else { + let empty = 0; + for (const n of audio) { + try { + if ((await stat(join(dir, n))).size === 0) empty++; + } catch { + empty++; // unreadable counts as broken + } + } + if (empty > 0) issues.push(`${empty} empty/unreadable file${empty === 1 ? "" : "s"}`); + const expected = li.trackNames.length; + if (expected > 0 && audio.length !== expected) { + issues.push(`${audio.length} of ${expected} tracks on disk`); + } + } + return { id: li.id, artist: li.artist, album: li.album, issues }; + }), + ); + + return Response.json({ checked: items.length, issues: results.filter((r) => r.issues.length > 0) }); +} diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index e868634..5e93ad9 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -33,11 +33,14 @@ 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 }; +type IntegrityIssue = { id: string; artist: string; album: string; issues: string[] }; export function LibraryClient() { const [albums, setAlbums] = useState([]); const [unmatched, setUnmatched] = useState([]); const [duplicates, setDuplicates] = useState([]); + const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null); + const [checking, setChecking] = useState(false); const [q, setQ] = useState(""); const [sort, setSort] = useState("added"); const [open, setOpen] = useState(null); @@ -112,6 +115,20 @@ export function LibraryClient() { refresh(); }, []); + async function checkIntegrity() { + setChecking(true); + try { + const res = await fetch("/api/library/integrity").catch(() => null); + if (res?.ok) { + setIntegrity(await res.json()); + } else { + toast("Couldn't run the integrity check"); + } + } finally { + setChecking(false); + } + } + async function dismissUnmatched(u: Unmatched) { const res = await fetch(`/api/library/unmatched?id=${u.id}`, { method: "DELETE" }).catch(() => null); if (res?.ok) { @@ -175,6 +192,51 @@ export function LibraryClient() {
) : null} + {albums.length > 0 ? ( + <> + + {checking ? "Checking…" : "Check now"} + + } + /> + {integrity ? ( + integrity.issues.length === 0 ? ( +

Checked {integrity.checked} albums — no problems found.

+ ) : ( +
    + {integrity.issues.map((it) => { + const album = albums.find((a) => a.id === it.id); + return ( +
  • +
    + {album ? ( + + ) : ( +
    {it.artist} — {it.album}
    + )} +
    + {it.issues.join(" · ")} +
    +
    +
  • + ); + })} +
+ ) + ) : ( +

+ Check every album folder on disk for missing folders, empty/truncated files, and + track-count drift. +

+ )} + + ) : null} + {duplicates.length > 0 ? ( <>