refactor(scanner): use shared verification core; stop false-flagging cross-script

The library AcoustID scan now calls audio_verification.evaluate() (alias-aware
artist match + cross-script SKIP) instead of its own non-ASCII-stripping
_normalize and threshold logic, so it no longer false-flags correct anime-OST /
kanji tracks. Duration-collision guard kept as a scanner pre-check on the top
recording. evaluate() is now purely a title/artist/version/cross-script decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-10 15:13:43 +02:00
parent 967ad2a026
commit b981230d07
3 changed files with 95 additions and 124 deletions

View file

@ -159,20 +159,18 @@ def _find_best_title_artist_match(recordings, expected_title, expected_artist,
def evaluate(expected_title: str, expected_artist: str,
recordings: List[dict], *, fingerprint_score: float,
file_duration_s: Optional[float] = None,
aliases_provider: Optional[Any] = None) -> Outcome:
"""Decide PASS / SKIP / FAIL for a fingerprinted file against expected
title/artist. Pure: no I/O. Shared by import verification and library scan.
``aliases_provider``: iterable or callable of expected-artist aliases
(kanji/cyrillic/etc) used to bridge cross-script comparisons.
``file_duration_s``: when provided, a strong duration mismatch downgrades a
would-be FAIL to SKIP (fingerprint hash collision guard, used by the scan).
Note: fingerprint-collision duration checks are the caller's responsibility
(the library scan pre-checks the top recording's length before calling this)
so the decision here stays purely about title/artist/version identity.
"""
from core.matching.script_compat import is_cross_script_mismatch
from core.matching.acoustid_candidates import (
duration_mismatches_strongly, find_matching_recording,
)
from core.matching.version_mismatch import is_acceptable_version_mismatch
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
@ -212,9 +210,6 @@ def evaluate(expected_title: str, expected_artist: str,
) >= ARTIST_MATCH_THRESHOLD:
return out(Decision.PASS, "Expected artist found in AcoustID results")
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
if file_duration_s and duration_mismatches_strongly(
file_duration_s, best_rec.get('duration') or best_rec.get('length')):
return out(Decision.SKIP, "Duration mismatch (fingerprint collision)")
return out(Decision.FAIL,
f"Audio mismatch: '{matched_title}' by '{matched_artist}' "
f"— expected artist not found")
@ -255,10 +250,6 @@ def evaluate(expected_title: str, expected_artist: str,
or cross_script_artist_skip):
return out(Decision.SKIP, "Likely same song in different language/script")
if file_duration_s and duration_mismatches_strongly(
file_duration_s, best_rec.get('duration') or best_rec.get('length')):
return out(Decision.SKIP, "Duration mismatch (fingerprint collision)")
return out(Decision.FAIL,
f"Audio mismatch: file identified as '{matched_title}' by "
f"'{matched_artist}', expected '{expected_title}' by '{expected_artist}'")

View file

@ -15,6 +15,9 @@ from typing import Optional
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
from core.matching.audio_verification import evaluate, Decision
from core.matching.acoustid_candidates import duration_mismatches_strongly
from core.acoustid_verification import _resolve_expected_artist_aliases
logger = get_logger("repair_job.acoustid")
@ -220,112 +223,60 @@ class AcoustIDScannerJob(RepairJob):
or expected['artist']
)
# Normalize and compare
norm_expected_title = _normalize(expected['title'])
norm_aid_title = _normalize(aid_title)
norm_expected_artist = _normalize(expected_artist)
norm_aid_artist = _normalize(aid_artist)
title_sim = SequenceMatcher(None, norm_expected_title, norm_aid_title).ratio()
# Issue (Foxxify Discord report): AcoustID returns the FULL artist
# credit (e.g. `Okayracer, aldrch & poptropicaslutz!`) while the
# library DB carries only the primary artist (`Okayracer`). Raw
# similarity scores ~43% — well below threshold — so multi-artist
# tracks get flagged as Wrong Song even though the primary IS in
# the credit. Route through the shared `artist_names_match` helper
# which splits the credit on common separators (comma, ampersand,
# feat./ft./with/vs., etc.) and checks each token. Primary-in-
# credit cases now resolve at 100% match instead of 43%.
#
# Pass RAW artist strings (not pre-normalised) so the splitter
# can recognise the separators. The helper applies its own
# case + whitespace normalisation internally per token.
if norm_expected_artist:
from core.matching.artist_aliases import artist_names_match
_, artist_sim = artist_names_match(
expected_artist,
aid_artist,
threshold=artist_threshold,
)
else:
artist_sim = 1.0
if title_sim >= title_threshold and artist_sim >= artist_threshold:
return
# Issue #587 (Foxxify) — top recording's metadata mismatched, but
# AcoustID often returns multiple recordings per fingerprint
# (sample collisions, multi-MB-record cases). Check ALL of them
# before flagging — if any candidate's metadata matches expected
# title + artist, the file IS the right song and AcoustID's top
# match was just a wrong-credited recording.
from core.matching.acoustid_candidates import (
duration_mismatches_strongly,
find_matching_recording,
)
from core.matching.artist_aliases import artist_names_match
def _scanner_title_sim(a, b):
return SequenceMatcher(None, _normalize(a), _normalize(b)).ratio()
def _scanner_artist_sim(expected_a, actual_a):
_, score = artist_names_match(expected_a, actual_a, threshold=artist_threshold)
return score
candidate_match, _, _ = find_matching_recording(
fp_result.get('recordings') or [],
expected['title'],
expected_artist,
title_threshold=title_threshold,
artist_threshold=artist_threshold,
similarity=_scanner_title_sim,
artist_similarity=_scanner_artist_sim,
)
if candidate_match is not None:
# A lower-ranked candidate matched — file IS the right song.
# No finding.
# Fingerprint-collision guard: when the TOP recording's length is wildly
# different from the file, the fingerprint hit is a hash collision (the
# 17-min mashup → 5-min track case), not a real match — skip BEFORE any
# title/artist/version analysis so it can't surface as a false finding.
try:
file_duration_s = (expected.get('duration_ms') or 0) / 1000.0
except Exception:
file_duration_s = 0.0
cand_duration_s = best_recording.get('duration') or best_recording.get('length')
if file_duration_s and duration_mismatches_strongly(file_duration_s, cand_duration_s):
if context.report_progress:
context.report_progress(
log_line=(
f'Resolved (lower-ranked candidate match): {fname}'
f'expected "{expected["title"]}" matched candidate '
f'"{candidate_match.get("title")}" by '
f'"{candidate_match.get("artist")}"'
),
log_line=(f'Skipped (duration mismatch suggests fingerprint '
f'collision): {fname}'),
log_type='skip')
return
# Decision via the shared verification core — identical logic to import-
# time verification (alias-aware artist match + cross-script SKIP), so the
# scan no longer false-flags correct cross-script tracks. Only a FAIL
# produces a "Wrong download" finding.
_alias_cache = {}
def _aliases():
if 'v' not in _alias_cache:
try:
_alias_cache['v'] = _resolve_expected_artist_aliases(expected_artist)
except Exception:
_alias_cache['v'] = []
return _alias_cache['v']
outcome = evaluate(
expected['title'], expected_artist, fp_result['recordings'],
fingerprint_score=best_score,
aliases_provider=_aliases,
)
if outcome.decision != Decision.FAIL:
if context.report_progress:
context.report_progress(
log_line=f'OK ({outcome.decision.value}): {fname}{outcome.reason}',
log_type='ok',
)
return
# Issue #587 (Foxxify "17min mashup → 5min track") — duration
# guard against fingerprint hash collisions. When the file's
# actual duration differs from AcoustID's matched recording by
# more than max(60s, 35%), the fingerprint is almost certainly
# a sample/intro collision, not a real recording match. Don't
# produce a confident "Wrong Song" finding.
try:
file_duration_s = (expected.get('duration_ms') or 0) / 1000.0
except Exception:
file_duration_s = 0
candidate_duration_s = best_recording.get('duration')
if candidate_duration_s is None and best_recording.get('length'):
candidate_duration_s = best_recording.get('length')
if duration_mismatches_strongly(file_duration_s, candidate_duration_s):
if context.report_progress:
context.report_progress(
log_line=(
f'Skipped (duration mismatch suggests fingerprint collision): '
f'{fname} — expected {file_duration_s:.0f}s, AcoustID '
f'candidate {candidate_duration_s:.0f}s'
),
log_type='skip',
)
return
title_sim = outcome.title_sim
artist_sim = outcome.artist_sim
matched_title = outcome.matched_title or aid_title
matched_artist = outcome.matched_artist or aid_artist
# Mismatch detected
# Mismatch (FAIL) — create finding.
if context.report_progress:
context.report_progress(
log_line=f'Mismatch: {fname} — expected "{expected["title"]}", got "{aid_title}"',
log_line=f'Mismatch: {fname} — expected "{expected["title"]}", got "{matched_title}"',
log_type='error'
)
if context.create_finding:
@ -337,18 +288,18 @@ class AcoustIDScannerJob(RepairJob):
entity_type='track',
entity_id=str(track_id),
file_path=fpath,
title=f'Wrong download: "{expected["title"]}" is actually "{aid_title}"',
title=f'Wrong download: "{expected["title"]}" is actually "{matched_title}"',
description=(
f'Expected "{expected["title"]}" by {expected_artist}, '
f'but audio fingerprint matches "{aid_title}" by {aid_artist} '
f'but audio fingerprint matches "{matched_title}" by {matched_artist} '
f'(fingerprint: {best_score:.0%}, title match: {title_sim:.0%}, '
f'artist match: {artist_sim:.0%})'
),
details={
'expected_title': expected['title'],
'expected_artist': expected_artist,
'acoustid_title': aid_title,
'acoustid_artist': aid_artist,
'acoustid_title': matched_title,
'acoustid_artist': matched_artist,
'fingerprint_score': round(best_score, 3),
'title_similarity': round(title_sim, 3),
'artist_similarity': round(artist_sim, 3),
@ -480,11 +431,3 @@ class AcoustIDScannerJob(RepairJob):
finally:
if conn:
conn.close()
def _normalize(text: str) -> str:
t = text.lower()
t = re.sub(r'\(.*?\)', '', t)
t = re.sub(r'\[.*?\]', '', t)
t = re.sub(r'[^a-z0-9 ]', '', t)
return t.strip()

View file

@ -199,7 +199,11 @@ def test_scanner_still_flags_genuine_artist_mismatch():
'best_score': 0.99,
'recordings': [{
'title': 'Some Track',
'artist': 'Different Band, Other Person & Random Featuring',
# Clearly-different multi-value credit (artist sim < 0.30). The
# unified core gives 0.30-0.60 ("ambiguous") the benefit of the
# doubt, so a genuine-mismatch assertion needs an artist that's
# unambiguously different.
'artist': 'Metallica, Slayer & Anthrax',
}],
},
)
@ -468,7 +472,9 @@ def test_scanner_falls_back_to_db_when_file_tag_missing(monkeypatch):
'best_score': 0.99,
'recordings': [{
'title': 'Some Track',
'artist': 'Different Band',
# Unambiguously different artist (sim < 0.30) so the unified
# core flags it (0.30-0.60 would be treated as ambiguous).
'artist': 'Metallica',
}],
},
)
@ -779,3 +785,34 @@ def test_scanner_still_flags_when_duration_matches():
)
assert len(captured_findings) == 1
def test_scanner_does_not_flag_cross_script_when_alias_bridges(monkeypatch):
"""Anime-OST track: AcoustID returns the kanji artist with a <Vocal: ...>
credit. With the MusicBrainz alias bridging 澤野弘之 Sawano Hiroyuki, the
unified verification core recognises the match, so the library scan must NOT
create a false 'Wrong download' finding (it did before, stripping all
non-ASCII and never consulting aliases)."""
import core.repair_jobs.acoustid_scanner as scanner_mod
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
lambda name: ["澤野弘之"], raising=False)
job = AcoustIDScannerJob()
captured = []
context = _make_finding_capturing_context(
track_row=("7", "Call Your Name", "Sawano Hiroyuki",
"/music/cyn.flac", 15, "Attack on Titan OST", None, None),
captured=captured,
)
fake_acoustid = SimpleNamespace(
fingerprint_and_lookup=lambda fpath: {
'best_score': 0.97,
'recordings': [{'title': 'call your name',
'artist': '澤野弘之 <Vocal: mpi & CASG>'}],
},
)
result = JobResultStub()
job._scan_file('/music/cyn.flac', '7',
{'title': 'Call Your Name', 'artist': 'Sawano Hiroyuki'},
fake_acoustid, context, result,
fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6)
assert captured == [], f"cross-script track false-flagged: {captured}"