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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="dept">
|
||||
<h2>{title}</h2>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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<Album[]>([]);
|
||||
const [unmatched, setUnmatched] = useState<Unmatched[]>([]);
|
||||
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
|
||||
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("added");
|
||||
const [open, setOpen] = useState<Album | null>(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() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Integrity"
|
||||
note={
|
||||
<button className="btn sm ghost" onClick={checkIntegrity} disabled={checking}>
|
||||
{checking ? "Checking…" : "Check now"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{integrity ? (
|
||||
integrity.issues.length === 0 ? (
|
||||
<p className="muted-note">Checked {integrity.checked} albums — no problems found.</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{integrity.issues.map((it) => {
|
||||
const album = albums.find((a) => a.id === it.id);
|
||||
return (
|
||||
<li key={it.id} className="list-row">
|
||||
<div className="main">
|
||||
{album ? (
|
||||
<button className="linkish rtitle" onClick={() => show(album)}>
|
||||
{it.artist} — {it.album}
|
||||
</button>
|
||||
) : (
|
||||
<div className="rtitle">{it.artist} — {it.album}</div>
|
||||
)}
|
||||
<div className="rmeta">
|
||||
<span className="score">{it.issues.join(" · ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
) : (
|
||||
<p className="muted-note">
|
||||
Check every album folder on disk for missing folders, empty/truncated files, and
|
||||
track-count drift.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{duplicates.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title="Possible duplicates" note={`${duplicates.length}`} />
|
||||
|
||||
Reference in New Issue
Block a user