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:
@@ -30,6 +30,7 @@ def conn():
|
||||
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
||||
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
||||
cur.execute('DELETE FROM "ScanWorkItem"')
|
||||
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
||||
cur.execute('DELETE FROM "ApiCache"')
|
||||
cur.execute('DELETE FROM "Config"')
|
||||
connection.commit()
|
||||
@@ -48,6 +49,7 @@ def conn():
|
||||
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
||||
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
||||
cur.execute('DELETE FROM "ScanWorkItem"')
|
||||
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
||||
cur.execute('DELETE FROM "ApiCache"')
|
||||
cur.execute('DELETE FROM "Config"')
|
||||
connection.commit()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user