"""Name normalisation and fuzzy matching for collection completeness. Normalisation never mutates the stored original — callers keep both the original and normalised values. The point is to make "Album (Deluxe Edition)" and "Album - 2009 Remaster" collapse to the same comparable key without losing the display name. """ from __future__ import annotations import re from difflib import SequenceMatcher _BRACKET_RE = re.compile(r"[\(\[\{].*?[\)\]\}]") _PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) _WS_RE = re.compile(r"\s+") # Edition / remaster qualifiers stripped from album titles before comparison. _EDITION_PATTERNS = [ r"\bsuper deluxe( edition)?\b", r"\bdeluxe( edition| version)?\b", r"\bexpanded( edition| version)?\b", r"\bspecial edition\b", r"\bcollector'?s edition\b", r"\blegacy edition\b", r"\banniversary( edition)?\b", r"\b\d{1,3}(st|nd|rd|th) anniversary\b", r"\bremaster(ed)?\b", r"\bre-?master(ed)?\b", r"\breissue\b", r"\bbonus track(s)?( version)?\b", r"\bbonus edition\b", r"\b\d{4} remaster\b", r"\bexplicit( version)?\b", r"\bclean( version)?\b", r"\bmono\b", r"\bstereo\b", r"\bdisc \d+\b", r"\bcd\d+\b", ] _EDITION_RE = re.compile("|".join(_EDITION_PATTERNS), re.IGNORECASE) VARIOUS_ARTISTS = { "various artists", "various", "va", "v a", "soundtrack", "original soundtrack", } def _base_clean(text: str) -> str: text = text.lower() text = _BRACKET_RE.sub(" ", text) # drop (...) [...] {...} text = _EDITION_RE.sub(" ", text) # drop edition/remaster words text = text.replace("&", " and ") text = _PUNCT_RE.sub(" ", text) # drop remaining punctuation return _WS_RE.sub(" ", text).strip() def normalize_title(text: str | None) -> str: if not text: return "" return _base_clean(text) def normalize_artist(text: str | None) -> str: if not text: return "" cleaned = _base_clean(text) if cleaned.startswith("the "): cleaned = cleaned[4:] return cleaned def is_various_artists(name: str | None) -> bool: if not name: return False return normalize_artist(name) in VARIOUS_ARTISTS or _base_clean(name) in VARIOUS_ARTISTS def similarity(a: str | None, b: str | None) -> float: na, nb = normalize_title(a), normalize_title(b) if not na or not nb: return 0.0 if na == nb: return 1.0 return SequenceMatcher(None, na, nb).ratio() # ── Completeness classification ─────────────────────────────────────────────── # Statuses: owned | probably_owned | missing | uncertain (ignored is manual). OWNED = "owned" PROBABLY_OWNED = "probably_owned" UNCERTAIN = "uncertain" MISSING = "missing" FUZZY_PROBABLE = 0.88 FUZZY_UNCERTAIN = 0.60 def classify_release( ext_title: str, ext_year: int | None, ext_mbid: str | None, local_albums: list[dict], ) -> tuple[str, float, str, int | None]: """Decide a status for one external release group against local albums. ``local_albums`` items: ``{id, title, title_normalized, year, mbid}``. Match order: MusicBrainz id → normalised title (+year) → fuzzy title. Returns ``(status, confidence, reason, local_album_id|None)``. """ ext_norm = normalize_title(ext_title) # 1) Exact MusicBrainz id match. if ext_mbid: for la in local_albums: if la.get("mbid") and la["mbid"] == ext_mbid: return (OWNED, 1.0, "MusicBrainz ID match", la["id"]) # 2) Normalised title (with year corroboration). if ext_norm: for la in local_albums: if la.get("title_normalized") == ext_norm: ly, ey = la.get("year") or 0, ext_year or 0 if ly and ey and abs(ly - ey) <= 1: return (OWNED, 0.95, "Title and year match", la["id"]) return (PROBABLY_OWNED, 0.85, "Normalised title match", la["id"]) # 3) Fuzzy title. best_ratio, best = 0.0, None for la in local_albums: r = similarity(ext_title, la.get("title")) if r > best_ratio: best_ratio, best = r, la if best is not None: if best_ratio >= FUZZY_PROBABLE: return (PROBABLY_OWNED, round(best_ratio, 3), f"Fuzzy title match ({best_ratio:.2f})", best["id"]) if best_ratio >= FUZZY_UNCERTAIN: return (UNCERTAIN, round(best_ratio, 3), f"Weak title match ({best_ratio:.2f})", best["id"]) return (MISSING, 0.0, "No matching local album", None)