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 ? (
+ <>
+