feat(library): surface albums the scan couldn't match
The library scan silently skipped album folders with no MusicBrainz match (or an unreadable file). Persist them so they're visible and fixable. - New UnmatchedAlbum model (migration add_unmatched_album), keyed by path. - scan_chunk records a skipped album as unmatched (reason "no MusicBrainz match" or "unreadable: <err>"), clears the row when an album later resolves, and prunes stale rows (a prior scan's, or a removed folder) when a scan completes. - GET /api/library/unmatched lists them; DELETE ?id= dismisses one. - /library shows a "Couldn't match" section (path + reason + Dismiss). Worker + web tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,11 @@ const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number
|
||||
{ key: "artist", label: "Artist A–Z", cmp: (a, b) => a.artist.localeCompare(b.artist) || (a.year ?? "").localeCompare(b.year ?? "") },
|
||||
];
|
||||
|
||||
type Unmatched = { id: string; artist: string; album: string; path: string; reason: string };
|
||||
|
||||
export function LibraryClient() {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [unmatched, setUnmatched] = useState<Unmatched[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("added");
|
||||
const [open, setOpen] = useState<Album | null>(null);
|
||||
@@ -60,11 +63,23 @@ export function LibraryClient() {
|
||||
fetch("/api/library")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setAlbums(d.albums ?? []));
|
||||
fetch("/api/library/unmatched")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setUnmatched(d.unmatched ?? []));
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
async function dismissUnmatched(u: Unmatched) {
|
||||
const res = await fetch(`/api/library/unmatched?id=${u.id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (res?.ok) {
|
||||
setUnmatched((list) => list.filter((x) => x.id !== u.id));
|
||||
} else {
|
||||
toast("Couldn't dismiss");
|
||||
}
|
||||
}
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const filtered = needle
|
||||
@@ -119,6 +134,38 @@ export function LibraryClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{unmatched.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title="Couldn’t match" note={`${unmatched.length}`} />
|
||||
<p className="muted-note">
|
||||
Album folders on disk the scan couldn’t resolve to a MusicBrainz release. Fix the
|
||||
folder name (Artist / Album (Year)) or re-add them manually, then re-scan. Dismiss to
|
||||
hide an entry.
|
||||
</p>
|
||||
<ul className="list">
|
||||
{unmatched.map((u) => (
|
||||
<li key={u.id} className="list-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
{u.artist ? `${u.artist} — ` : ""}
|
||||
{u.album}
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span className="dim">{u.path}</span> <span className="dot">·</span>{" "}
|
||||
<span className="score">{u.reason}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn sm ghost" onClick={() => dismissUnmatched(u)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<AlbumModal
|
||||
open
|
||||
|
||||
Reference in New Issue
Block a user