feat(verification): shared normalize() core for import + scan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-10 14:43:57 +02:00
parent cc18ec266e
commit cf0a17c14a
2 changed files with 81 additions and 0 deletions

View file

@ -0,0 +1,59 @@
"""Shared audio-verification decision core (pure; no file/DB I/O).
Single source of truth for normalization + the PASS/SKIP/FAIL decision used by
BOTH import-time verification (``core/acoustid_verification.py``) and the library
scan (``core/repair_jobs/acoustid_scanner.py``). Historically each path had its
own ``_normalize`` and decision branches that drifted apart and produced
inconsistent results (a correct cross-script anime-OST track passed at import but
was false-flagged by the scan). Centralising the decision here means the
thresholds, normalization, alias-aware comparison, cross-script handling, version
gate and duration guard are defined exactly once.
"""
import re
from difflib import SequenceMatcher
# Thresholds — the single definition both paths share.
MIN_ACOUSTID_SCORE = 0.80 # Minimum fingerprint score to trust a match.
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity to consider a match.
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity to consider a match.
def normalize(text: str) -> str:
"""Normalize a title/artist for comparison.
lowercase; strip ``()`` / ``[]`` / ``<>`` annotations (version tags,
performer credits like ``<Vocal: MIKA KOBAYASHI>``); strip trailing
version / featuring tags; KEEP CJK characters (``\\w`` is unicode-aware) so
Japanese/Chinese/Korean titles produce a comparable form instead of an empty
string; collapse whitespace.
"""
if not text:
return ""
s = text.lower().strip()
# Annotations that are metadata, not core identity.
s = re.sub(r'\s*\([^)]*\)', '', s)
s = re.sub(r'\s*\[[^\]]*\]', '', s)
s = re.sub(r'\s*<[^>]*>', '', s)
# Trailing featuring / version tags.
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
s = re.sub(
r'\s*-\s*(?:vocal|instrumental|acoustic|live|remix|cover|clean|explicit|'
r'radio\s*edit|original\s*mix|extended\s*mix|club\s*mix)\s*$',
'', s, flags=re.IGNORECASE,
)
s = re.sub(r'\s*-\s*from\s+.+$', '', s, flags=re.IGNORECASE)
# Drop remaining punctuation but keep word chars (incl. CJK) + spaces.
s = re.sub(r'[^\w\s]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def similarity(a: str, b: str) -> float:
"""Similarity (0.01.0) between two strings after normalization."""
na, nb = normalize(a), normalize(b)
if not na or not nb:
return 0.0
if na == nb:
return 1.0
return SequenceMatcher(None, na, nb).ratio()

View file

@ -0,0 +1,22 @@
"""Shared audio-verification decision core: normalize() + evaluate().
One place for normalization + the PASS/SKIP/FAIL decision used by BOTH import-time
verification and the library AcoustID scan, so the two paths can't drift apart.
"""
from core.matching.audio_verification import normalize
def test_normalize_strips_paren_bracket_angle_and_keeps_cjk():
assert normalize("澤野弘之 <Vocal: MIKA KOBAYASHI>") == "澤野弘之"
assert normalize("Clarity (Live at X) [Remastered]") == "clarity"
assert normalize("Attack on Titan <TV Size>") == "attack on titan"
def test_normalize_strips_version_and_featuring():
assert normalize("In My Feelings - Instrumental") == "in my feelings"
assert normalize("Song feat. Someone") == "song"
def test_normalize_keeps_plain_text():
assert normalize("Sawano Hiroyuki") == "sawano hiroyuki"