73 lines
2.5 KiB
Python
73 lines
2.5 KiB
Python
"""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*$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MatchVerdict:
|
|
"""Explain whether a Spotify track safely matches a proposed candidate."""
|
|
|
|
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 sequence similarity for two track titles."""
|
|
return SequenceMatcher(
|
|
None,
|
|
normalize_text(candidate_title),
|
|
normalize_text(track_title),
|
|
).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)}"
|