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:
|
||||
|
||||
Reference in New Issue
Block a user