"""Pure normalization and conservative candidate matching.""" import re import unicodedata from dataclasses import dataclass from difflib import SequenceMatcher from app.domain.models import Track, TrackCandidate _SUFFIX_PATTERN = re.compile(r"(?:\s*(?:\([^)]*\)|\[[^]]*\]))+\s*$") _DASH_SUFFIX_PATTERN = re.compile(r"\s+-\s+[^-]+$") @dataclass(frozen=True) class MatchVerdict: """Match decision plus the two signals it was derived from. A rejection is attributable: either title_similarity fell below the caller's threshold, or is_artist_match is false, or both. """ is_match: bool title_similarity: float is_artist_match: bool def normalize_text(value: str) -> str: """Normalize names for stable comparisons without transport knowledge.""" decomposed = unicodedata.normalize("NFKD", value.casefold()) without_marks = "".join( character for character in decomposed if not unicodedata.combining(character) ) without_suffix = _SUFFIX_PATTERN.sub("", without_marks) words = "".join(character if character.isalnum() else " " for character in without_suffix) return " ".join(words.split()) def title_similarity(candidate_title: str, track_title: str) -> float: """Return normalized title similarity, tolerating version dash suffixes.""" normalized_candidate = normalize_text(candidate_title) full_similarity = _ratio(normalized_candidate, normalize_text(track_title)) # Spotify appends version info as "Title - Remaster 2023"; some tracks # only exist in suffixed releases. The tiny penalty keeps an exact # original title ahead of a suffixed release at equal similarity. stripped_title = _DASH_SUFFIX_PATTERN.sub("", track_title) stripped_similarity = _ratio(normalized_candidate, normalize_text(stripped_title)) - 0.001 return max(full_similarity, stripped_similarity) def _ratio(left: str, right: str) -> float: return SequenceMatcher(None, left, right).ratio() def artist_matches(candidate_artist: str, track_artists: tuple[str, ...]) -> bool: """Require one normalized Spotify artist to equal the proposed artist.""" normalized_candidate = normalize_text(candidate_artist) return bool(normalized_candidate) and any( normalize_text(track_artist) == normalized_candidate for track_artist in track_artists ) def judge_candidate_match( candidate: TrackCandidate, track: Track, title_threshold: float, ) -> MatchVerdict: """Accept only a similar title paired with a near-exact artist.""" similarity = title_similarity(candidate.title, track.title) is_artist_match = artist_matches(candidate.artist, track.artists) return MatchVerdict( is_match=similarity >= title_threshold and is_artist_match, title_similarity=similarity, is_artist_match=is_artist_match, ) def candidate_key(candidate: TrackCandidate) -> str: """Build the normalized cache key for a proposed title and artist.""" return f"{normalize_text(candidate.title)}\x00{normalize_text(candidate.artist)}" def track_key(track: Track) -> str: """Build the normalized title and primary-artist deduplication key.""" primary_artist = track.artists[0] if track.artists else "" return f"{normalize_text(track.title)}\x00{normalize_text(primary_artist)}"