Fix import artist override and verification review

This commit is contained in:
dev 2026-06-11 01:24:22 +02:00
parent 5896f2dcc6
commit 37ea6604c7
12 changed files with 326 additions and 71 deletions

View file

@ -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"
}
}
}

View file

@ -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,

View file

@ -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/<category>/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)

View file

@ -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.

View file

@ -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'))

View file

@ -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 = {}

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -2390,7 +2390,7 @@
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
<button class="adl-pill" data-filter="unverified" onclick="adlSetFilter('unverified')" title="Completed downloads that were imported without hard AcoustID confirmation (unverified) or force-imported after repeated mismatches — review these.">⚠ Unverified</button>
<button class="adl-pill" data-filter="unverified" onclick="adlSetFilter('unverified')" title="Review queue: imported-but-unconfirmed downloads (unverified / force-imported) and quarantined files that were never imported.">⚠ Unverified/Quarantine</button>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>

View file

@ -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; }

View file

@ -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';