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