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
+37 -2
View File
@@ -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: