#929 added 'musicbrainz' to library_reorganize._ALBUM_ID_COLUMNS but not to the canonical layer, breaking the equality invariant test (canonical reads exactly what reorganize reads). add musicbrainz to CANONICAL_ALBUM_SOURCES, and move it from the 'can't pin' param group to the 'pins' group in the manual-lock tests — now consistent, and forward-compatible with pinning a deliberately-matched MB edition. inert at runtime today (mb isn't in the manual source selector, so should_pin is never called with it). 540 canonical/reorganize tests green.
230 lines
9.6 KiB
Python
230 lines
9.6 KiB
Python
"""Pick the canonical album release by best-fit to the user's actual files.
|
||
|
||
Issue #765 / #767-Bug2: SoulSync never pins ONE canonical album version per
|
||
album, so the Library Reorganizer, Track Number Repair, and tagging each
|
||
re-resolve independently and can land on different releases (standard vs
|
||
deluxe; Spotify vs MusicBrainz track numbering) and contradict each other.
|
||
|
||
This module is the pure, testable heart of the fix: given the metadata of the
|
||
files actually on disk and a set of candidate releases, score each release by
|
||
how well it FITS those files and pick the best. "Best-fit to the files" means:
|
||
|
||
- track-count fit — a 17-track deluxe is a poor fit for 11 files on disk
|
||
- duration alignment — each file should line up with a release track by length
|
||
- title overlap — a tiebreaker / sanity check
|
||
|
||
What this does and does NOT solve:
|
||
- It DOES pick the right EDITION (standard vs deluxe) — the discriminating
|
||
signal is track count + durations.
|
||
- It does NOT (and cannot) decide which of two listings of the SAME album is
|
||
"more correct" when they differ only in track numbering (same files match
|
||
both equally). Instead ``pick_canonical_release`` is DETERMINISTIC and
|
||
breaks ties toward the earlier candidate — so the caller passes candidates
|
||
in source-priority order and every tool that reads the pinned result agrees
|
||
on the same release. Agreement is what resolves #765, not picking a
|
||
"winner" of the numbering disagreement.
|
||
|
||
Pure, no I/O. Callers fetch candidate tracklists and read on-disk file metadata;
|
||
this module only scores.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from difflib import SequenceMatcher
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
# Weights for the three fit signals. Count + duration dominate because "matches
|
||
# my files" is fundamentally about having the right NUMBER of the right-LENGTH
|
||
# tracks; title is a tiebreaker. Missing signals are dropped and the present
|
||
# ones renormalized (see _combine).
|
||
_W_COUNT = 0.4
|
||
_W_DURATION = 0.4
|
||
_W_TITLE = 0.2
|
||
|
||
_DEFAULT_DURATION_TOLERANCE_MS = 3000 # ±3s — covers encode/version length jitter
|
||
_DEFAULT_MIN_SCORE = 0.5 # never pin below this — leave unresolved
|
||
_TITLE_FUZZY_THRESHOLD = 0.85
|
||
|
||
|
||
def _norm_title(text: str) -> str:
|
||
"""Lowercase, drop bracketed qualifiers ((feat. …), [Remastered]), strip
|
||
punctuation, collapse whitespace."""
|
||
if not text:
|
||
return ""
|
||
t = str(text).lower()
|
||
t = re.sub(r"[\(\[].*?[\)\]]", "", t)
|
||
t = re.sub(r"[^a-z0-9 ]", " ", t)
|
||
return " ".join(t.split())
|
||
|
||
|
||
def _count_fit(n_files: int, n_release: int) -> float:
|
||
"""1.0 when track counts match; decays with the relative difference."""
|
||
if n_files <= 0 or n_release <= 0:
|
||
return 0.0
|
||
return 1.0 - min(1.0, abs(n_files - n_release) / max(n_files, n_release))
|
||
|
||
|
||
def _duration_fit(
|
||
file_tracks: List[Dict[str, Any]],
|
||
release_tracks: List[Dict[str, Any]],
|
||
tolerance_ms: int,
|
||
) -> Optional[float]:
|
||
"""Fraction of tracks that line up by duration (greedy nearest match within
|
||
tolerance), over the larger of the two track counts — so missing or extra
|
||
tracks are penalised. Returns ``None`` when neither side has durations."""
|
||
f_durs = [int(f["duration_ms"]) for f in file_tracks if f.get("duration_ms")]
|
||
r_durs = [int(r["duration_ms"]) for r in release_tracks if r.get("duration_ms")]
|
||
if not f_durs or not r_durs:
|
||
return None
|
||
used = [False] * len(r_durs)
|
||
matched = 0
|
||
for fd in f_durs:
|
||
best_j, best_diff = -1, tolerance_ms + 1
|
||
for j, rd in enumerate(r_durs):
|
||
if used[j]:
|
||
continue
|
||
diff = abs(fd - rd)
|
||
if diff <= tolerance_ms and diff < best_diff:
|
||
best_diff, best_j = diff, j
|
||
if best_j >= 0:
|
||
used[best_j] = True
|
||
matched += 1
|
||
denom = max(len(file_tracks), len(release_tracks))
|
||
return matched / denom if denom else 0.0
|
||
|
||
|
||
def _title_fit(
|
||
file_tracks: List[Dict[str, Any]],
|
||
release_tracks: List[Dict[str, Any]],
|
||
) -> Optional[float]:
|
||
"""Fraction of files whose title matches some release title (exact-normalised
|
||
or fuzzy), over the larger track count. ``None`` when titles are absent."""
|
||
f_titles = [_norm_title(f.get("title", "")) for f in file_tracks]
|
||
f_titles = [t for t in f_titles if t]
|
||
r_titles = [_norm_title(r.get("title", "")) for r in release_tracks]
|
||
r_titles = [t for t in r_titles if t]
|
||
if not f_titles or not r_titles:
|
||
return None
|
||
r_set = set(r_titles)
|
||
matched = 0
|
||
for ft in f_titles:
|
||
if ft in r_set or any(
|
||
SequenceMatcher(None, ft, rt).ratio() >= _TITLE_FUZZY_THRESHOLD
|
||
for rt in r_titles
|
||
):
|
||
matched += 1
|
||
denom = max(len(file_tracks), len(release_tracks))
|
||
return matched / denom if denom else 0.0
|
||
|
||
|
||
def _combine(parts: List[Tuple[Optional[float], float]]) -> float:
|
||
"""Weighted mean over present (non-None) components, renormalising weights."""
|
||
present = [(v, w) for v, w in parts if v is not None]
|
||
total_w = sum(w for _, w in present)
|
||
if total_w <= 0:
|
||
return 0.0
|
||
return sum(v * w for v, w in present) / total_w
|
||
|
||
|
||
def score_release_against_files(
|
||
file_tracks: List[Dict[str, Any]],
|
||
release_tracks: List[Dict[str, Any]],
|
||
*,
|
||
duration_tolerance_ms: int = _DEFAULT_DURATION_TOLERANCE_MS,
|
||
) -> float:
|
||
"""Score 0.0–1.0 of how well ``release_tracks`` fits the on-disk
|
||
``file_tracks``. Each track dict may carry ``duration_ms`` and ``title``;
|
||
missing signals are dropped and the rest renormalised so the function never
|
||
crashes on sparse metadata (it just leans on what's available)."""
|
||
if not file_tracks or not release_tracks:
|
||
return 0.0
|
||
count = _count_fit(len(file_tracks), len(release_tracks))
|
||
dur = _duration_fit(file_tracks, release_tracks, duration_tolerance_ms)
|
||
title = _title_fit(file_tracks, release_tracks)
|
||
return _combine([(count, _W_COUNT), (dur, _W_DURATION), (title, _W_TITLE)])
|
||
|
||
|
||
def score_release_detail(
|
||
file_tracks: List[Dict[str, Any]],
|
||
release_tracks: List[Dict[str, Any]],
|
||
*,
|
||
duration_tolerance_ms: int = _DEFAULT_DURATION_TOLERANCE_MS,
|
||
) -> Dict[str, Any]:
|
||
"""Like ``score_release_against_files`` but returns the per-signal breakdown
|
||
so a UI can show WHY a release scored the way it did. ``duration_fit`` /
|
||
``title_fit`` are ``None`` when that signal was absent."""
|
||
if not file_tracks or not release_tracks:
|
||
return {
|
||
'score': 0.0, 'count_fit': 0.0, 'duration_fit': None, 'title_fit': None,
|
||
'release_track_count': len(release_tracks), 'file_track_count': len(file_tracks),
|
||
}
|
||
count = _count_fit(len(file_tracks), len(release_tracks))
|
||
dur = _duration_fit(file_tracks, release_tracks, duration_tolerance_ms)
|
||
title = _title_fit(file_tracks, release_tracks)
|
||
score = _combine([(count, _W_COUNT), (dur, _W_DURATION), (title, _W_TITLE)])
|
||
return {
|
||
'score': round(score, 4),
|
||
'count_fit': round(count, 3),
|
||
'duration_fit': round(dur, 3) if dur is not None else None,
|
||
'title_fit': round(title, 3) if title is not None else None,
|
||
'release_track_count': len(release_tracks),
|
||
'file_track_count': len(file_tracks),
|
||
}
|
||
|
||
|
||
def pick_canonical_release(
|
||
file_tracks: List[Dict[str, Any]],
|
||
candidates: List[Dict[str, Any]],
|
||
*,
|
||
min_score: float = _DEFAULT_MIN_SCORE,
|
||
duration_tolerance_ms: int = _DEFAULT_DURATION_TOLERANCE_MS,
|
||
) -> Tuple[Optional[Dict[str, Any]], float]:
|
||
"""Choose the best-fit candidate release for the on-disk files.
|
||
|
||
``candidates`` is a list of dicts each with a ``'tracks'`` list (plus any
|
||
caller fields like ``source``/``album_id``, returned untouched). **Pass
|
||
candidates in source-priority order** — ties break toward the EARLIER one,
|
||
so the choice is deterministic and priority-respecting (this is what makes
|
||
every tool agree, #765).
|
||
|
||
Returns ``(best_candidate, score)``, or ``(None, best_score)`` when nothing
|
||
clears ``min_score`` — so a low-confidence guess is never pinned (the caller
|
||
leaves the album unresolved and falls back to today's behaviour)."""
|
||
best: Optional[Dict[str, Any]] = None
|
||
best_score = 0.0
|
||
for cand in candidates:
|
||
score = score_release_against_files(
|
||
file_tracks, cand.get("tracks") or [],
|
||
duration_tolerance_ms=duration_tolerance_ms,
|
||
)
|
||
# Strictly-greater so equal scores keep the earlier (higher-priority)
|
||
# candidate — deterministic tiebreak.
|
||
if score > best_score + 1e-9:
|
||
best, best_score = cand, score
|
||
if best is None or best_score < min_score:
|
||
return None, best_score
|
||
return best, best_score
|
||
|
||
|
||
# Album sources the canonical system reads (mirror of
|
||
# core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual
|
||
# match on any of these should pin/lock the canonical version (#758); a match on
|
||
# a source the canonical tools don't read (e.g. lastfm) has no version to pin.
|
||
CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz'})
|
||
|
||
|
||
def should_pin_manual_canonical(entity_type: str, source: str) -> bool:
|
||
"""Whether a manual match should also pin (and lock) the canonical album
|
||
version (#758). True only for an ALBUM match on a canonical-recognised
|
||
source — so the user's chosen edition becomes the authority every downstream
|
||
tool (track-number repair, reorganize, missing-tracks) reads, and the auto
|
||
resolve job won't override it.
|
||
"""
|
||
return entity_type == 'album' and source in CANONICAL_ALBUM_SOURCES
|
||
|
||
|
||
__all__ = [
|
||
"score_release_against_files", "pick_canonical_release",
|
||
"CANONICAL_ALBUM_SOURCES", "should_pin_manual_canonical",
|
||
]
|