Files
2026-06-08 00:01:55 +12:00

146 lines
4.5 KiB
Python

"""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)