146 lines
4.5 KiB
Python
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)
|