From 37ea6604c7024d6e1b0201d9ea682fbd97c1a25d Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 11 Jun 2026 01:24:22 +0200 Subject: [PATCH] Fix import artist override and verification review --- config/config.example.json | 7 +- config/settings.py | 8 +- core/auto_import_worker.py | 13 +-- core/imports/quarantine.py | 46 +++++++++ core/matching/verification_status.py | 13 ++- core/repair_jobs/acoustid_scanner.py | 100 ++++++++++++++----- tests/imports/test_folder_artist_override.py | 13 ++- tests/matching/test_verification_status.py | 12 ++- tests/test_acoustid_scanner.py | 91 ++++++++++++++--- webui/index.html | 2 +- webui/static/style.css | 30 ++++++ webui/static/wishlist-tools.js | 62 +++++++++--- 12 files changed, 326 insertions(+), 71 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index efbb1da9..0543822c 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -56,6 +56,11 @@ "playlist_path": "$playlist/$artist - $title" } }, + "import": { + "staging_path": "./Staging", + "replace_lower_quality": false, + "folder_artist_override": true + }, "lossy_copy": { "enabled": false, "bitrate": "320", @@ -67,4 +72,4 @@ "listenbrainz": { "token": "LISTENBRAINZ_TOKEN" } -} \ No newline at end of file +} diff --git a/config/settings.py b/config/settings.py index c32c5b75..a522d4a7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -684,7 +684,13 @@ class ConfigManager: }, "import": { "staging_path": "./Staging", - "replace_lower_quality": False + "replace_lower_quality": False, + # Use the top Staging folder as the artist (Artist/Album layouts, + # mixtapes). On by default to preserve the long-standing import + # behaviour for existing users. Turn OFF if you stage a mixed pile + # of songs under one container folder, otherwise that folder's name + # overrides every metadata-identified artist (the "soulsync" case). + "folder_artist_override": True }, "m3u_export": { "enabled": False, diff --git a/core/auto_import_worker.py b/core/auto_import_worker.py index 39cc0c22..8bfbea34 100644 --- a/core/auto_import_worker.py +++ b/core/auto_import_worker.py @@ -1577,15 +1577,12 @@ class AutoImportWorker: album_name = identification.get('album_name', 'Unknown') image_url = identification.get('image_url', '') - # Parent folder artist override — OPT-IN via import.folder_artist_override - # (default off). When enabled it uses the top Staging folder as the artist - # for Artist/Album or Artist//Album layouts, which helps - # mixtapes/compilations whose embedded tags carry DJ names. Off by default - # because it otherwise clobbers a confidently metadata-identified artist - # when a user stages a mixed pile under a single container folder (the - # "soulsync" mass-mislabel incident). + # Parent folder artist override via import.folder_artist_override. + # Default on to preserve the legacy Artist/Album staging behavior. + # Users who stage mixed piles under one container folder can turn it off + # to keep the metadata-identified artist. try: - if self._config_manager.get('import.folder_artist_override', False): + if self._config_manager.get('import.folder_artist_override', True): staging_root = self._resolve_staging_path() or self.staging_path rel_path = os.path.relpath(candidate.path, staging_root) folder_artist = resolve_folder_artist(rel_path, artist_name, enabled=True) diff --git a/core/imports/quarantine.py b/core/imports/quarantine.py index 3df79f85..0e9af594 100644 --- a/core/imports/quarantine.py +++ b/core/imports/quarantine.py @@ -219,6 +219,7 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: "trigger": sidecar.get("trigger", "unknown"), "source_username": source_username, "source_filename": source_filename, + "thumb_url": _extract_context_thumb(ctx), } ) @@ -226,6 +227,51 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: return entries +def get_quarantine_entry_context(quarantine_dir: str, entry_id: str) -> Dict[str, Any]: + """The sidecar's embedded pipeline ``context`` dict for one entry. + Returns {} for thin/legacy sidecars, missing entries or read errors.""" + _, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id) + if not sidecar_path or not os.path.isfile(sidecar_path): + return {} + try: + with open(sidecar_path, encoding="utf-8") as f: + loaded = json.load(f) + ctx = loaded.get("context") if isinstance(loaded, dict) else None + return ctx if isinstance(ctx, dict) else {} + except Exception as exc: + logger.debug("quarantine context read failed for %s: %s", entry_id, exc) + return {} + + +def _extract_context_thumb(ctx: Dict[str, Any]) -> str: + """Album-art URL from a sidecar's pipeline context — same lookup chain the + library-history recorder uses (album/spotify_album image, then album_info, + then the track_info's embedded album images). Empty string when absent.""" + def _first_image(album: Any) -> str: + if not isinstance(album, dict): + return "" + url = album.get("image_url") or "" + if url: + return url + images = album.get("images") or [] + if images and isinstance(images[0], dict): + return images[0].get("url", "") or "" + return "" + + thumb = _first_image(ctx.get("album")) or _first_image(ctx.get("spotify_album")) + if not thumb: + album_info = ctx.get("album_info") + if isinstance(album_info, dict): + thumb = album_info.get("album_image_url", "") or "" + if not thumb: + ti = ctx.get("track_info") + if isinstance(ti, dict): + thumb = _first_image(ti.get("album")) + if not thumb: + thumb = ti.get("image_url", "") or "" + return thumb + + def _resolve_entry_paths(quarantine_dir: str, entry_id: str) -> Tuple[Optional[str], Optional[str]]: """Locate the `.quarantined` file + JSON sidecar for an entry id. diff --git a/core/matching/verification_status.py b/core/matching/verification_status.py index 05be1050..f064ea1f 100644 --- a/core/matching/verification_status.py +++ b/core/matching/verification_status.py @@ -42,10 +42,17 @@ def status_from_acoustid_result(result_value): def status_for_import(context: dict): """Status for a just-imported file from its pipeline context. - The version-mismatch fallback flag wins: a force-accepted file is - ``force_imported`` regardless of what the (earlier, failed) verification - said about the candidate. + Priority order: + 1. A quarantine entry the user explicitly approved ("yes, import this + exact file") is a human decision — ``human_verified``, outranking + whatever the machine said about the candidate earlier. + 2. The version-mismatch fallback flag: a force-accepted file is + ``force_imported`` regardless of what the (earlier, failed) + verification said. + 3. The AcoustID result of this pipeline run. """ + if context.get('_approved_quarantine_trigger'): + return HUMAN_VERIFIED if context.get('_version_mismatch_fallback'): return FORCE_IMPORTED return status_from_acoustid_result(context.get('_acoustid_result')) diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py index dd7db7af..916829b6 100644 --- a/core/repair_jobs/acoustid_scanner.py +++ b/core/repair_jobs/acoustid_scanner.py @@ -53,10 +53,6 @@ class AcoustIDScannerJob(RepairJob): 'title_similarity': 0.70, 'artist_similarity': 0.60, 'batch_size': 200, - # Skip tracks the user force-imported via the version-mismatch - # fallback (they are expected to mismatch; default: still scan them - # but report as informational). - 'skip_force_imported': False, } auto_fix = False # User chooses fix action per finding @@ -228,9 +224,11 @@ class AcoustIDScannerJob(RepairJob): ) # Verification status from the embedded SOULSYNC_VERIFICATION tag. - # force_imported = user accepted this file as best candidate after the - # retry budget was exhausted — a mismatch here is EXPECTED. Either skip - # (job setting) or downgrade the finding to informational below. + # Only a human decision short-circuits the scan: the user explicitly + # confirmed the file via the review queue. Everything else (verified / + # unverified / force_imported / untagged) is re-checked; force_imported + # mismatches are reported as informational below since a mismatch + # there is EXPECTED (the user accepted the best candidate). file_verif_status = None try: from core.tag_writer import read_file_tags as _rft @@ -244,13 +242,6 @@ class AcoustIDScannerJob(RepairJob): context.report_progress( log_line=f'Skipped (human-verified): {fname}', log_type='skip') return - if file_verif_status == 'force_imported' and \ - self._get_settings(context).get('skip_force_imported', False): - if context.report_progress: - context.report_progress( - log_line=f'Skipped (force-imported fallback): {fname}', - log_type='skip') - return # Fingerprint-collision guard: when the TOP recording's length is wildly # different from the file, the fingerprint hit is a hash collision (the @@ -289,17 +280,26 @@ class AcoustIDScannerJob(RepairJob): aliases_provider=_aliases, ) - # Refresh the DB column from the file tag (the tag travels with the - # file and survives DB resets; the tracks row is the UI-facing cache). - if file_verif_status: - try: - conn = context.db._get_connection() - conn.cursor().execute( - "UPDATE tracks SET verification_status = ? WHERE id = ?", - (file_verif_status, track_id)) - getattr(conn, 'commit', lambda: None)() - except Exception as e: - logger.debug("verification_status refresh failed for %s: %s", track_id, e) + # Persist the scan outcome so it feeds the same review pipeline as + # import-time verification: PASS backfills 'verified' on untagged or + # previously-unverified files; SKIP (ambiguous / cross-script / no + # hard confirmation) marks untagged files 'unverified' so they surface + # in the Downloads-page review queue. force_imported is never blessed + # here (normalize() strips version words, so an instrumental can PASS + # the title check) and 'verified' is never downgraded by a SKIP (the + # import-time check ran with richer candidate metadata). FAIL keeps + # the finding flow below. + new_status = file_verif_status + if outcome.decision == Decision.PASS and file_verif_status in (None, '', 'unverified'): + new_status = 'verified' + elif outcome.decision == Decision.SKIP and not file_verif_status: + new_status = 'unverified' + if new_status: + self._persist_status( + context, track_id, fpath, + (expected.get('file_path') or '').strip() or None, + new_status, write_tag=(new_status != file_verif_status), + expected=expected) if outcome.decision != Decision.FAIL: if context.report_progress: @@ -362,6 +362,56 @@ class AcoustIDScannerJob(RepairJob): else: result.findings_skipped_dedup += 1 + def _persist_status(self, context, track_id, fpath, db_path, status, write_tag, + expected=None): + """Persist a verification status to the file tag (durable, travels with + the file), the tracks row (UI cache) and any library_history rows for + this file (feeds the Unverified review queue on the Downloads page). + ``db_path`` is the unresolved DB-side path — history rows may store + either form, so both are matched. + + Files SoulSync never downloaded have no history row at all — for an + 'unverified' outcome one is inserted (download_source 'acoustid_scan') + so EVERY scan-flagged file lands in the review queue, not just past + downloads. Re-scans then match this row via file_path (no duplicates). + """ + if not status: + return + if write_tag: + try: + from core.tag_writer import write_verification_status + write_verification_status(fpath, status) + except Exception as e: + logger.debug("verification tag write failed for %s: %s", fpath, e) + try: + conn = context.db._get_connection() + cur = conn.cursor() + cur.execute( + "UPDATE tracks SET verification_status = ? WHERE id = ?", + (status, track_id)) + matched = 0 + for p in {p for p in (fpath, db_path) if p}: + cur.execute( + "UPDATE library_history SET verification_status = ? WHERE file_path = ?", + (status, p)) + matched += max(getattr(cur, 'rowcount', 0) or 0, 0) + if status == 'unverified' and matched == 0: + exp = expected or {} + cur.execute( + """INSERT INTO library_history + (event_type, title, artist_name, album_name, file_path, + thumb_url, download_source, verification_status) + VALUES ('download', ?, ?, ?, ?, ?, 'acoustid_scan', ?)""", + (exp.get('title') or os.path.basename(fpath), + exp.get('artist') or None, + exp.get('album_title') or None, + db_path or fpath, + exp.get('album_thumb_url') or None, + status)) + getattr(conn, 'commit', lambda: None)() + except Exception as e: + logger.debug("verification_status persist failed for %s: %s", track_id, e) + def _load_db_tracks(self, context: JobContext) -> dict: """Load all tracks from DB keyed by track ID.""" tracks = {} diff --git a/tests/imports/test_folder_artist_override.py b/tests/imports/test_folder_artist_override.py index 8bc102d8..ad069d2b 100644 --- a/tests/imports/test_folder_artist_override.py +++ b/tests/imports/test_folder_artist_override.py @@ -1,4 +1,4 @@ -"""Folder-artist override is opt-in and never clobbers an identified artist. +"""Folder-artist override can be disabled to keep an identified artist. Background ---------- @@ -12,16 +12,15 @@ named ``soulsync`` therefore got the album-artist of *every* file forced to with a Spotify/MusicBrainz id. Navidrome groups by album-artist, so 65 singles collapsed under a bogus "soulsync" artist. -``resolve_folder_artist`` is the extracted, pure decision. It must: -- return ``None`` (i.e. keep the identified artist) when the feature is OFF, - regardless of folder structure — this is the regression guard; -- only when explicitly enabled, reproduce the original folder-derived artist. +``resolve_folder_artist`` is the extracted, pure decision. It must keep the +identified artist when the feature is OFF, and reproduce the original +folder-derived artist when enabled. """ from core.imports.folder_artist import resolve_folder_artist -# --- OFF by default: never override (the bug fix) --------------------------- +# --- Disabled: never override ----------------------------------------------- def test_disabled_keeps_identified_artist_even_with_artist_album_structure(): # The 'soulsync' mass mis-file: identified artist is real, folder is a @@ -41,7 +40,7 @@ def test_disabled_returns_none_for_clean_artist_album_path(): ) is None -# --- Enabled: original behaviour is available on request -------------------- +# --- Enabled: original behaviour -------------------------------------------- def test_enabled_uses_top_folder_as_artist_when_it_differs(): assert resolve_folder_artist( diff --git a/tests/matching/test_verification_status.py b/tests/matching/test_verification_status.py index d488710e..5e37813e 100644 --- a/tests/matching/test_verification_status.py +++ b/tests/matching/test_verification_status.py @@ -1,7 +1,7 @@ """Verification-status vocabulary + mapping (DB column / file tag / UI badge).""" from core.matching.verification_status import ( - VERIFIED, UNVERIFIED, FORCE_IMPORTED, + VERIFIED, UNVERIFIED, FORCE_IMPORTED, HUMAN_VERIFIED, status_from_acoustid_result, status_for_import, ) @@ -24,3 +24,13 @@ def test_status_for_import_falls_back_to_acoustid_result(): assert status_for_import({'_acoustid_result': 'pass'}) == VERIFIED assert status_for_import({'_acoustid_result': 'skip'}) == UNVERIFIED assert status_for_import({}) is None + + +def test_approved_quarantine_import_is_human_verified(): + # The user clicked Approve on a quarantine entry — an explicit human + # decision about THIS file. Outranks both the fallback flag and whatever + # the (earlier, failed) verification said. + ctx = {'_approved_quarantine_trigger': 'acoustid', + '_version_mismatch_fallback': 'instrumental', + '_acoustid_result': 'fail'} + assert status_for_import(ctx) == HUMAN_VERIFIED diff --git a/tests/test_acoustid_scanner.py b/tests/test_acoustid_scanner.py index 089d594e..1c83cac6 100644 --- a/tests/test_acoustid_scanner.py +++ b/tests/test_acoustid_scanner.py @@ -818,7 +818,7 @@ def test_scanner_does_not_flag_cross_script_when_alias_bridges(monkeypatch): assert captured == [], f"cross-script track false-flagged: {captured}" -def _force_imported_scan(monkeypatch, *, skip_setting): +def _force_imported_scan(monkeypatch): """Drive a scan over a force-imported file whose fingerprint clearly mismatches. Returns the captured findings.""" import core.repair_jobs.acoustid_scanner as scanner_mod @@ -829,10 +829,6 @@ def _force_imported_scan(monkeypatch, *, skip_setting): lambda fpath: {'artist': None, 'verification_status': 'force_imported'}, ) job = AcoustIDScannerJob() - if skip_setting: - monkeypatch.setattr(job, '_get_settings', - lambda ctx: {**job.default_settings, - 'skip_force_imported': True}) captured = [] context = _make_finding_capturing_context( track_row=("42", "Wanted Song", "Real Artist", @@ -857,19 +853,14 @@ def _force_imported_scan(monkeypatch, *, skip_setting): def test_force_imported_mismatch_is_reported_as_informational(monkeypatch): # The user opted into the fallback, so the scan must still TELL them the # file is e.g. an instrumental — but as 'info', clearly marked, not as a - # red Wrong-download warning. - captured = _force_imported_scan(monkeypatch, skip_setting=False) + # red Wrong-download warning. Only human_verified short-circuits the scan. + captured = _force_imported_scan(monkeypatch) assert len(captured) == 1 assert captured[0]['severity'] == 'info' assert captured[0]['details'].get('force_imported') is True assert 'Force-imported' in captured[0]['title'] -def test_force_imported_can_be_skipped_via_setting(monkeypatch): - captured = _force_imported_scan(monkeypatch, skip_setting=True) - assert captured == [] - - def test_human_verified_files_are_never_scanned(monkeypatch): import core.repair_jobs.acoustid_scanner as scanner_mod monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases", @@ -888,3 +879,79 @@ def test_human_verified_files_are_never_scanned(monkeypatch): fake, context, JobResultStub(), fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6) assert captured == [] + + +# --------------------------------------------------------------------------- +# Scan-outcome persistence — the scan feeds the same review pipeline as +# import-time verification (tag + tracks row + library_history rows). +# --------------------------------------------------------------------------- + + +def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_artist): + """Drive one _scan_file call and return (status_updates, tag_writes) where + status_updates is the list of (query, params) UPDATEs the scanner ran.""" + import core.repair_jobs.acoustid_scanner as scanner_mod + monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases", + lambda name: [], raising=False) + monkeypatch.setattr( + 'core.tag_writer.read_file_tags', + lambda fpath: {'artist': None, 'verification_status': file_status}) + tag_writes = [] + monkeypatch.setattr( + 'core.tag_writer.write_verification_status', + lambda fpath, status: tag_writes.append((fpath, status)) or True) + job = AcoustIDScannerJob() + captured = [] + context = _make_finding_capturing_context( + track_row=("9", "Call Your Name", expected_artist, + "/music/cyn.flac", 1, "Album", None, None), + captured=captured) + fake = SimpleNamespace(fingerprint_and_lookup=lambda f: { + 'best_score': 0.97, + 'recordings': [{'title': 'Call Your Name', 'artist': aid_artist}]}) + job._scan_file('/music/cyn.flac', '9', + {'title': 'Call Your Name', 'artist': expected_artist}, + fake, context, JobResultStub(), + fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6) + conn = context.db._get_connection() + updates = [(q, p) for q, p in conn.cursor().executed + if 'verification_status' in q] + return updates, tag_writes, captured + + +def test_scan_pass_backfills_verified_status(monkeypatch): + # Untagged file + clean fingerprint PASS → the scan backfills 'verified' + # into the tag, the tracks row AND library_history (review-queue feed). + updates, tag_writes, captured = _run_persistence_scan( + monkeypatch, file_status=None, + aid_artist='Sawano Hiroyuki', expected_artist='Sawano Hiroyuki') + assert captured == [] + assert tag_writes == [('/music/cyn.flac', 'verified')] + assert any('tracks' in q and p == ('verified', '9') for q, p in updates) + assert any('library_history' in q and p == ('verified', '/music/cyn.flac') + for q, p in updates) + + +def test_scan_skip_marks_untagged_file_unverified(monkeypatch): + # Title matches but the artist is ambiguous (cover/collab band?) → SKIP. + # An untagged file gets 'unverified' so it surfaces in the Downloads-page + # review queue instead of silently passing or being deleted. + updates, tag_writes, captured = _run_persistence_scan( + monkeypatch, file_status=None, + aid_artist='Mantilla', expected_artist='Metallica') + assert captured == [] + assert tag_writes == [('/music/cyn.flac', 'unverified')] + assert any('tracks' in q and p == ('unverified', '9') for q, p in updates) + assert any('library_history' in q and p == ('unverified', '/music/cyn.flac') + for q, p in updates) + + +def test_scan_skip_does_not_downgrade_verified(monkeypatch): + # A SKIP must not downgrade an import-time 'verified' (that check ran with + # richer candidate metadata). Status is refreshed, tag untouched. + updates, tag_writes, captured = _run_persistence_scan( + monkeypatch, file_status='verified', + aid_artist='Mantilla', expected_artist='Metallica') + assert captured == [] + assert tag_writes == [] + assert any('tracks' in q and p == ('verified', '9') for q, p in updates) diff --git a/webui/index.html b/webui/index.html index d46ee112..e9c836ce 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2390,7 +2390,7 @@ - +
diff --git a/webui/static/style.css b/webui/static/style.css index 35567a5d..241bf33c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -10864,6 +10864,26 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(239, 83, 80, 0.3); color: #ef5350; } +.download-audit-hero-pill-verified { + background: rgba(74, 222, 128, 0.12); + border-color: rgba(74, 222, 128, 0.3); + color: #4ade80; +} +.download-audit-hero-pill-human_verified { + background: rgba(52, 152, 219, 0.12); + border-color: rgba(52, 152, 219, 0.35); + color: #3498db; +} +.download-audit-hero-pill-force_imported { + background: rgba(230, 126, 34, 0.12); + border-color: rgba(230, 126, 34, 0.35); + color: #e67e22; +} +.download-audit-hero-pill-unverified { + background: rgba(241, 196, 15, 0.12); + border-color: rgba(241, 196, 15, 0.35); + color: #f1c40f; +} /* Tab bar */ .download-audit-tabs { @@ -67906,3 +67926,13 @@ body.em-scroll-lock { overflow: hidden; } .verif-act-ok:hover { background: rgba(46,204,113,0.25); border-color: rgba(46,204,113,0.5); } .verif-act-del:hover { background: rgba(231,76,60,0.25); border-color: rgba(231,76,60,0.5); } .verif-act-play.playing { background: rgba(var(--accent-rgb),0.3); } +/* Review-queue row annotations (library-history-quarantine style) */ +.verif-reason-badge { display: inline-block; font-size: 9.5px; font-weight: 600; letter-spacing: 0.04em; border: 1px solid; border-radius: 999px; padding: 1px 7px; line-height: 14px; white-space: nowrap; cursor: help; } +.verif-rb-unv { color: #f1c40f; border-color: rgba(241,196,15,0.5); } +.verif-rb-force { color: #ef5350; border-color: rgba(239,83,80,0.5); } +.verif-rb-int { color: #facc15; border-color: rgba(250,204,21,0.5); } +.verif-time { font-size: 11px; color: rgba(255,255,255,0.45); white-space: nowrap; } +.verif-quar-details { margin-top: 6px; font-size: 11.5px; color: rgba(255,255,255,0.6); line-height: 1.5; word-break: break-all; } +.verif-detail-label { color: rgba(255,255,255,0.4); margin-right: 4px; } +.verif-banner-spacer { flex: 1; } +.verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; } diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 1842e72a..481938b4 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -3794,8 +3794,10 @@ function openDownloadAuditModal(entry) { // Live tag read for the Tags tab. Fire-and-forget on open so the // data is ready by the time the user switches to the Tags tab. - if (entry.id != null) { - fetchAndRenderEmbeddedTags(entry.id); + // Synthetic entries (quarantine review queue) have no history id but + // carry their own tags URL in _file_tags_url. + if (entry.id != null || entry._file_tags_url) { + fetchAndRenderEmbeddedTags(entry.id, entry._file_tags_url); } } @@ -3809,11 +3811,9 @@ function switchAuditTab(tabName) { if (body) body.innerHTML = renderDownloadAuditTabPanel(tabName, _downloadAuditEntry); // Re-fire the live fetch when switching to Tags (in case it // raced past first mount before the user got there). - if (tabName === 'tags' && _downloadAuditEntry.id != null) { - fetchAndRenderEmbeddedTags(_downloadAuditEntry.id); - } - if (tabName === 'lyrics' && _downloadAuditEntry.id != null) { - fetchAndRenderEmbeddedTags(_downloadAuditEntry.id); + if ((tabName === 'tags' || tabName === 'lyrics') && + (_downloadAuditEntry.id != null || _downloadAuditEntry._file_tags_url)) { + fetchAndRenderEmbeddedTags(_downloadAuditEntry.id, _downloadAuditEntry._file_tags_url); } } window.switchAuditTab = switchAuditTab; @@ -3851,7 +3851,14 @@ function renderDownloadAuditHero(entry) { const pills = []; if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source)); if (entry.quality) pills.push(_auditHeroPill('quality', entry.quality)); - if (entry.acoustid_result) pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result)); + const _vsLabels = { verified: 'Verified', unverified: 'Unverified', force_imported: 'Force-imported', human_verified: 'Human verified' }; + if (entry.verification_status && _vsLabels[entry.verification_status]) { + pills.push(_auditHeroPill('verify', _vsLabels[entry.verification_status], entry.verification_status)); + } else if (entry._quarantined) { + pills.push(_auditHeroPill('verify', 'Quarantined', 'fail')); + } else if (entry.acoustid_result) { + pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result)); + } return ` ${art} @@ -3952,7 +3959,7 @@ function renderEmbeddedTagsSection(entry) { return renderDownloadAuditTagsTab(entry); } -async function fetchAndRenderEmbeddedTags(historyId) { +async function fetchAndRenderEmbeddedTags(historyId, urlOverride) { const tagsSlot = document.getElementById('download-audit-tags-body'); const lyricsSlot = document.getElementById('download-audit-lyrics-body'); if (!tagsSlot && !lyricsSlot) return; @@ -3962,7 +3969,7 @@ async function fetchAndRenderEmbeddedTags(historyId) { if (lyricsSlot) lyricsSlot.innerHTML = html; }; try { - const resp = await fetch(`/api/library/history/${historyId}/file-tags`); + const resp = await fetch(urlOverride || `/api/library/history/${historyId}/file-tags`); if (!resp.ok) { renderError(`Could not read file tags (HTTP ${resp.status}).`); return; } const data = await resp.json(); if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; } @@ -4258,8 +4265,8 @@ function buildDownloadAuditSteps(entry) { { key: 'verify', title: 'Verification', - status: auditStatusFromAcoustid(entry.acoustid_result), - detail: buildAcoustidDetail(entry.acoustid_result), + status: auditVerifyStatus(entry), + detail: buildVerificationDetail(entry), meta: [], }, (() => { @@ -4376,6 +4383,37 @@ function buildSourceMatchMeta(entry) { return out; } +function auditVerifyStatus(entry) { + // The persisted verification status is the final word — it captures + // outcomes the raw acoustid_result can't express (force-imported after + // N retries, human approval via the review queue). + if (entry._quarantined) return 'error'; + if (entry.verification_status === 'human_verified') return 'complete'; + if (entry.verification_status === 'verified') return 'complete'; + if (entry.verification_status === 'force_imported') return 'partial'; + if (entry.verification_status === 'unverified') return 'partial'; + return auditStatusFromAcoustid(entry.acoustid_result); +} + +function buildVerificationDetail(entry) { + if (entry._quarantined) { + return `Quarantined — the file was NOT imported. ${entry._quarantine_reason || ''}`.trim(); + } + if (entry.verification_status === 'force_imported') { + return 'Force-imported: accepted as the best available candidate after the retry budget was exhausted (version-mismatch fallback). The AcoustID check was bypassed for this final re-import — earlier attempts had failed it.'; + } + if (entry.verification_status === 'human_verified') { + return 'Human verified: you confirmed this file via the review queue. The AcoustID scanner skips it.'; + } + if (entry.verification_status === 'unverified') { + return 'Imported but not hard-confirmed: AcoustID could not verify this file (ambiguous / cross-script metadata / no fingerprint match). Review it under Unverified/Quarantine on the Downloads page.'; + } + if (entry.verification_status === 'verified') { + return 'AcoustID fingerprint matched the expected track.'; + } + return buildAcoustidDetail(entry.acoustid_result); +} + function auditStatusFromAcoustid(result) { if (!result) return 'unknown'; if (result === 'pass') return 'complete';