- import pipeline writes SOULSYNC_VERIFICATION tag + context status (verified / unverified / force_imported via version-mismatch fallback) - downloads payload + UI badge (tooltip explains each state) - AcoustID scan reads the tag: refreshes tracks.verification_status, reports force-imported mismatches as informational (clearly marked), optional skip via job setting skip_force_imported - evaluate(): empty expected artist = title-only comparison (old scanner behaviour); thresholds single-sourced in the core Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7 KiB
AcoustID Verification: Unify the Pipeline + Persist Verification Status
Date: 2026-06-10
Branch: fix/import-folder-artist-override-optin
Problem
Audio verification logic is duplicated across two code paths that have drifted apart, producing inconsistent results ("komische Fehler"):
- Import time —
core/acoustid_verification.py(verify_audio_file,_normalize@ line 59, alias-aware artist sim, cross-script SKIP, version gate, duration guard). Keeps CJK characters; strips()/[]/<>(after the recent fix). - Library scan —
core/repair_jobs/acoustid_scanner.py(its OWN_normalize@ line 485, which strips all non-ASCII — kanji/IPA vanish — and has NO cross-script alias bridge / SKIP). A full library scan therefore false-flags correct cross-script tracks (e.g. anime OSTs credited澤野弘之 <Vocal: …>).
Two _normalizes + two decision paths = the same bug must be fixed twice and
they diverge. There is also no record of how a track passed verification, so a
force-imported or skipped-but-imported track looks identical to a cleanly
verified one.
Goals
- One verification core used by BOTH import and scan. Normalization, alias-aware comparison, cross-script handling, version gate, duration guard, and thresholds live in exactly one place.
- Persist a verification status per track in the DB and as a file tag, so it survives DB resets / file moves and can drive the UI.
- Surface the status on the Downloads page as an info badge.
Consumer map (discovered 2026-06-10)
In scope (call the new core):
core/imports/pipeline.py:340-373— the only caller ofAcoustIDVerification.verify_audio_file(import path).core/repair_jobs/acoustid_scanner.py— the library-scan job.
Shared helpers (stay shared, unchanged; the core builds on them):
core/matching/artist_aliases.py::artist_names_match/best_alias_matchcore/matching/script_compat.py::is_cross_script_mismatchcore/matching/acoustid_candidates.py::find_matching_recording,duration_mismatches_stronglycore/matching/version_mismatch.py::is_acceptable_version_mismatch
Status hook:
core/imports/version_mismatch_fallback.pysetscontext["_version_mismatch_fallback"]when it force-accepts the best quarantined candidate after retries are exhausted → maps toforce_imported. Dispatched fromcore/downloads/task_worker.pyviatry_version_mismatch_fallback.
Deliberately OUT of scope (consume a shared helper but serve other purposes; NOT merged):
core/repair_worker.py::_album_fill_artist_names_match— Album-Completeness auto-fill gate.core/matching/album_context_title.py::_normalize— trivial album-grouping normalize.core/downloads/task_worker.py,core/matching_engine.py— pre-download (Soulseek candidate) matching, not post-download verification.
Design
1. Shared core — core/matching/audio_verification.py (new)
The single home for the verification decision. Pure where possible (no file I/O, no DB) so it is unit-testable in isolation. Callers keep their own I/O (fingerprinting, quarantine, finding creation, tag writing).
def normalize(text: str) -> str
# lowercase; strip ()/[]/<> annotations; strip version (- Live, etc) +
# featuring tags; KEEP CJK (\w under unicode); collapse whitespace.
# Single source of truth — replaces acoustid_verification._normalize AND
# acoustid_scanner._normalize.
def evaluate(
expected_title, expected_artist, recordings, *,
fingerprint_score, file_duration_s=None, aliases_provider=None,
) -> Outcome
# Outcome(decision, title_sim, artist_sim, matched_title, matched_artist, reason)
# decision in {PASS, SKIP, FAIL}.
# Encapsulates: alias-aware artist sim, version-mismatch gate, duration
# collision guard, cross-script SKIP, thresholds. Built on the shared helpers.
Thresholds (TITLE_MATCH_THRESHOLD, ARTIST_MATCH_THRESHOLD, MIN_ACOUSTID_SCORE)
move into the core as the single definition.
Caller mapping:
- Import (
acoustid_verification.verify_audio_file): PASS → import + statusverified; SKIP → import + statusunverified; FAIL → quarantine. - Scan (
acoustid_scanner._scan_file): PASS/SKIP → no finding (optionally refresh the stored status); FAIL → createacoustid_mismatchfinding.
acoustid_verification.py keeps verify_audio_file as the import-facing wrapper
(fingerprint lookup, MB enrichment, alias provider, returns VerificationResult)
but delegates the decision to evaluate. acoustid_scanner.py drops its private
_normalize and decision branches and calls normalize + evaluate.
2. Verification status
Values (the three the user selected; quarantined files are not imported, so they carry no status):
verified— clean AcoustID PASS.unverified— SKIP: cross-script / ambiguous / no AcoustID match. Imported, not hard-confirmed.force_imported— accepted viaversion_mismatch_fallbackafter retries exhausted.
3. Storage — DB + tag
- DB: new nullable column
tracks.verification_status TEXT(idempotent migration indatabase/music_database.py). Set at import; refreshed by the scan. Mirrored onto the download record/context so the Downloads page can show it before a library re-scan. - Tag:
SOULSYNC_VERIFICATION=<status>written viacore/tag_writer.py(Vorbis comment / ID3TXXX). Travels with the file; the scanner reads it and may skip re-verifying an already-verifiedfile (perf bonus) and to display.
4. Downloads page UI
A small, info-only badge per row sourced from the status:
- ✓
verified· ⚠unverified· ⚑force_imported. Wired inwebui/static/downloads.js(+ the download-status API payload).
5. Testing (TDD)
- Unit-test the core
normalize(incl.<>/()/[], CJK retention) andevaluate(Sawano/IPA cross-script → SKIP not FAIL; vocal-credit artist → alias 100%; version mismatch → FAIL; duration collision → no FAIL). - Wire import + scanner to the core; their existing tests
(
test_acoustid_skip_logic,test_acoustid_scanner,test_acoustid_version_mismatch,test_acoustid_error_reporting,test_acoustid_normalize_angle_annotations) act as regression. - New tests for the status: force-import →
force_imported; PASS →verified; SKIP →unverified; tag written + read back; DB column populated.
Rollout / risk
- Pure-core extraction is behaviour-preserving for the import path (existing tests pin it); the scan path changes (gains alias bridge + cross-script SKIP) — that is the intended fix.
- DB migration is additive (nullable column), safe on existing DBs.
- Tag writing reuses the existing
tag_writerpath; failures are non-fatal. - Staged on the current branch with TDD; ships in the next image build.
Out of scope (now)
Unifying the pre-download Soulseek matcher or the album-completeness gate. They consume shared helpers but are different decisions; folding them in would widen blast radius without serving this goal.