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:
Jonathan
2026-07-14 20:56:31 +02:00
parent 9e5d5d3af4
commit 6ca39859fa
9 changed files with 212 additions and 2 deletions
+35
View File
@@ -82,11 +82,46 @@ def test_scan_skips_a_corrupt_album_and_captures_tracknames(conn, tmp_path):
assert cur.fetchone()[0] == ["01 One.flac", "02 Two.flac"] # on-disk tracks captured at scan
def _unmatched(conn):
with conn.cursor() as cur:
cur.execute('SELECT artist, album, reason FROM "UnmatchedAlbum" ORDER BY album')
return cur.fetchall()
def test_scan_skips_unmatched_albums(conn, tmp_path):
_album(tmp_path, "Obscure", "Nope (1999)")
result = scan_library(conn, FakeResolver({}), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path))
assert (result.imported, result.skipped) == (0, 1)
assert _counts(conn) == {"LibraryItem": 0, "MonitoredRelease": 0, "WatchedArtist": 0}
# the unmatched album is surfaced (folder name kept for display) with a reason
assert _unmatched(conn) == [("Obscure", "Nope (1999)", "no MusicBrainz match")]
def test_scan_records_corrupt_album_as_unmatched_with_error_reason(conn, tmp_path):
_album(tmp_path, "Bad Artist", "Bad Album (2020)")
class RaisingProbe:
def probe(self, path):
raise RuntimeError("file said 4 bytes, read 0 bytes")
scan_library(conn, FakeResolver({}), RaisingProbe(), FakeMbBrowser(), dest_root=str(tmp_path))
rows = _unmatched(conn)
assert len(rows) == 1 and rows[0][:2] == ("Bad Artist", "Bad Album (2020)")
assert rows[0][2].startswith("unreadable:")
def test_scan_clears_unmatched_when_album_resolves(conn, tmp_path):
_album(tmp_path, "Obscure", "Nope (1999)")
# first scan: no resolver match → recorded as unmatched
scan_library(conn, FakeResolver({}), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path))
assert len(_unmatched(conn)) == 1
# second scan: now it resolves → the unmatched row is dropped
resolver = FakeResolver({
("Obscure", "Nope"): MBTarget(
artist="Obscure", album="Nope", year=1999, rg_mbid="rgN", artist_mbid="aN"),
})
scan_library(conn, resolver, FakeProbe(quality_class=2), FakeMbBrowser(), dest_root=str(tmp_path))
assert _unmatched(conn) == []
def test_scan_falls_back_to_tags_when_folder_does_not_resolve(conn, tmp_path):