Homelabtoolkit v1
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user