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:
@@ -134,6 +134,32 @@ def _process_album(conn, resolver, probe, browser, artist_name, album_folder, al
|
||||
return "imported" if _record_owned(conn, target, pr.quality_class, pr.fmt, album_path, watched_id) else "recorded"
|
||||
|
||||
|
||||
def _record_unmatched(conn, scan_id: str, artist: str, album: str, path: str, reason: str) -> None:
|
||||
"""Persist an album folder the scan couldn't resolve, so /library can surface it.
|
||||
Upsert by path; the current scan's id tags the row so stale ones can be pruned."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'INSERT INTO "UnmatchedAlbum" (id, artist, album, path, reason, "scanId", "scannedAt") '
|
||||
"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, now()) "
|
||||
'ON CONFLICT (path) DO UPDATE SET artist = EXCLUDED.artist, album = EXCLUDED.album, '
|
||||
'reason = EXCLUDED.reason, "scanId" = EXCLUDED."scanId", "scannedAt" = now()',
|
||||
(artist or "", album or "", path, reason, scan_id),
|
||||
)
|
||||
|
||||
|
||||
def _clear_unmatched(conn, path: str) -> None:
|
||||
"""Drop an album's unmatched row once it resolves (matched, or fixed + re-scanned)."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('DELETE FROM "UnmatchedAlbum" WHERE path = %s', (path,))
|
||||
|
||||
|
||||
def _prune_unmatched(conn, scan_id: str) -> None:
|
||||
"""Once a scan completes, drop unmatched rows it did not touch — a prior scan's rows,
|
||||
or folders removed/fixed since — so the list reflects only the latest scan."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('DELETE FROM "UnmatchedAlbum" WHERE "scanId" <> %s', (scan_id,))
|
||||
|
||||
|
||||
def build_worklist(conn: psycopg.Connection, scan_id: str, dest_root: str = "/music") -> int:
|
||||
"""Walk the tree once and persist this scan's album worklist. Idempotent
|
||||
(ON CONFLICT DO NOTHING), so a re-run after a crash adds only missing rows.
|
||||
@@ -169,6 +195,7 @@ def scan_chunk(conn: psycopg.Connection, resolver, probe: AudioProbe, browser,
|
||||
imported = skipped = 0
|
||||
browsed: set[str] = set() # artists whose discography we've populated this chunk
|
||||
for item_id, artist_name, album_folder, album_path in items:
|
||||
reason = "no MusicBrainz match"
|
||||
try:
|
||||
outcome = _process_album(conn, resolver, probe, browser, artist_name, album_folder, album_path, browsed)
|
||||
except Exception as e:
|
||||
@@ -179,11 +206,15 @@ def scan_chunk(conn: psycopg.Connection, resolver, probe: AudioProbe, browser,
|
||||
except Exception:
|
||||
pass
|
||||
print(f"scan: skipping {artist_name}/{album_folder}: {e}", flush=True)
|
||||
reason = f"unreadable: {e}"
|
||||
outcome = "skipped"
|
||||
if outcome == "imported":
|
||||
imported += 1
|
||||
elif outcome == "skipped":
|
||||
if outcome == "skipped":
|
||||
skipped += 1
|
||||
_record_unmatched(conn, scan_id, artist_name, album_folder, album_path, reason)
|
||||
elif outcome in ("imported", "recorded"):
|
||||
_clear_unmatched(conn, album_path) # it resolved — no longer unmatched
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('UPDATE "ScanWorkItem" SET done = true WHERE id = %s', (item_id,))
|
||||
conn.commit()
|
||||
@@ -191,7 +222,11 @@ def scan_chunk(conn: psycopg.Connection, resolver, probe: AudioProbe, browser,
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s AND NOT done', (scan_id,))
|
||||
remaining = cur.fetchone()[0]
|
||||
return imported, skipped, remaining == 0
|
||||
done = remaining == 0
|
||||
if done:
|
||||
_prune_unmatched(conn, scan_id)
|
||||
conn.commit()
|
||||
return imported, skipped, done
|
||||
|
||||
|
||||
def clear_worklist(conn: psycopg.Connection, scan_id: str) -> None:
|
||||
|
||||
@@ -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