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:
Jonathan
2026-07-14 21:20:39 +02:00
parent 6453cfd27b
commit 7719b2a0d8
4 changed files with 179 additions and 1 deletions
+62
View File
@@ -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}`} />