feat(verification): shared normalize() core for import + scan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cc18ec266e
commit
cf0a17c14a
2 changed files with 81 additions and 0 deletions
59
core/matching/audio_verification.py
Normal file
59
core/matching/audio_verification.py
Normal 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.0–1.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()
|
||||
22
tests/matching/test_audio_verification_core.py
Normal file
22
tests/matching/test_audio_verification_core.py
Normal 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"
|
||||
Loading…
Reference in a new issue