watchlist: don't fuse different editions as the same album (Sokhi: Expedition 33)
_normalize_album_for_match stripped ANY trailing '- clause', so a real distinguishing
subtitle ('Clair Obscur: Expedition 33 - Nos vies en Lumière (Bonus Edition)') collapsed to
the same name as the OST → _albums_likely_match treated them as one album → the watchlist
marked unowned tracks of one edition as owned via the other and under-wishlisted.
- strip a trailing '- ...' clause ONLY when every token in it is an edition/format
qualifier (+ connectors / year-ordinal): '- Single', '- Acoustic Version', '- 2011
Remaster' still collapse, but real subtitles ('- Nos vies en Lumière', '- Volume 2',
'- Live in Berlin') are kept. Avoids the inverse regression (a same-album pair splitting
into a redownload loop), which a naive narrow strip would have caused.
- drop the loose substring shortcut + raise the fuzzy floor 0.6→0.85; genuine drift already
collapses to an EXACT match, so the looseness only ever produced false fuses.
blast radius: _albums_likely_match has exactly one caller (the allow-duplicates skip).
48 album-match tests pass (qualifier-suffix merges + edition-subtitle splits) + 219 watchlist.
This commit is contained in:
parent
e96d62432f
commit
7e2d2db08d
2 changed files with 49 additions and 8 deletions
|
|
@ -328,6 +328,22 @@ _ALBUM_QUALIFIER_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# A trailing "- ..." clause is stripped ONLY when EVERY token in it is an edition/format
|
||||
# qualifier (+ connectors / a year-ordinal). So "- Single", "- Acoustic Version", "- 2011
|
||||
# Remaster" collapse to the base name, but a real distinguishing subtitle ("- Nos vies en
|
||||
# Lumière", "- Live in Berlin") is kept — the bug was a blanket "- anything$" strip that
|
||||
# erased subtitles and fused different editions (Sokhi: Expedition 33 OST vs Bonus Edition).
|
||||
_DASH_QUALIFIER_WORD = (
|
||||
r'live|acoustic|electric|instrumental|unplugged|mono|stereo|demos?|reissue|'
|
||||
r'remix(?:es)?|edit(?:ed)?|radio|single|ep|lp|version|mix(?:es)?|sessions?|bootleg|'
|
||||
r'covers?|original|redux|deluxe|expanded|remaster(?:ed)?|anniversary|special|'
|
||||
r'edition|bonus|extended|explicit|clean|soundtrack|ost|score'
|
||||
)
|
||||
_TRAILING_DASH_QUALIFIER_RE = re.compile(
|
||||
r'\s*-\s*(?:(?:' + _DASH_QUALIFIER_WORD + r'|the|a|and|&|\+|\d+(?:st|nd|rd|th)?)\b[\s\-]*)+$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_album_for_match(name: str) -> str:
|
||||
"""Return a canonical form of an album name suitable for fuzzy comparison.
|
||||
|
|
@ -347,8 +363,9 @@ def _normalize_album_for_match(name: str) -> str:
|
|||
# they're almost always edition or commentary noise, not part of the
|
||||
# album's identifying name.
|
||||
cleaned = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*', ' ', cleaned)
|
||||
# Trailing dash-clauses ("Album - Remastered", "Album - Live")
|
||||
cleaned = re.sub(r'\s*-\s*[^-]+$', '', cleaned)
|
||||
# Trailing dash-clause, but ONLY when it's entirely edition/format qualifiers — a real
|
||||
# subtitle is preserved (see _TRAILING_DASH_QUALIFIER_RE).
|
||||
cleaned = _TRAILING_DASH_QUALIFIER_RE.sub(' ', cleaned)
|
||||
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', cleaned.lower())
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||
return cleaned
|
||||
|
|
@ -382,7 +399,7 @@ def _extract_volume_marker(normalized_name: str):
|
|||
return last.group(1) or last.group(2)
|
||||
|
||||
|
||||
def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float = 0.6) -> bool:
|
||||
def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float = 0.85) -> bool:
|
||||
"""Return True when two album names plausibly identify the same release.
|
||||
|
||||
Designed to swallow naming drift between metadata sources and the
|
||||
|
|
@ -406,11 +423,11 @@ def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float =
|
|||
return False
|
||||
if norm_a == norm_b:
|
||||
return True
|
||||
# After normalization the shorter name often becomes a prefix /
|
||||
# substring of the longer one ("napoleon dynamite" ⊂ "napoleon
|
||||
# dynamite music from the motion picture" before stripping).
|
||||
if norm_a in norm_b or norm_b in norm_a:
|
||||
return True
|
||||
# No loose substring shortcut: after qualifier-stripping, a short name being a
|
||||
# prefix of a longer one is usually a DIFFERENT edition carrying a real subtitle
|
||||
# ("clair obscur expedition 33" ⊂ "clair obscur expedition 33 nos vies en lumiere"),
|
||||
# not naming drift. Genuine drift collapses to an EXACT match above; everything else
|
||||
# must clear a high overall-similarity bar.
|
||||
return SequenceMatcher(None, norm_a, norm_b).ratio() >= threshold
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,13 @@ def test_compilation_score_explanation() -> None:
|
|||
("Inception (Music From The Motion Picture)", "Inception Soundtrack"),
|
||||
# Substring containment
|
||||
("Random Access Memories", "Random Access Memories (Bonus Edition)"),
|
||||
# Dash-suffixed qualifiers must still collapse — these are the SAME album, so
|
||||
# treating them as different would re-wishlist/redownload forever (the failure the
|
||||
# original blanket strip guarded against; the narrowed strip must keep covering it).
|
||||
("Album Name", "Album Name - Single"),
|
||||
("Album Name", "Album Name - Acoustic Version"),
|
||||
("Hotel California", "Hotel California - 2013 Remaster"),
|
||||
("Album", "Album - The Remixes"),
|
||||
],
|
||||
)
|
||||
def test_likely_match_positive(spotify_name, lib_name) -> None:
|
||||
|
|
@ -176,12 +183,29 @@ def test_likely_match_positive(spotify_name, lib_name) -> None:
|
|||
("Abbey Road", "Sgt. Pepper's Lonely Hearts Club Band"),
|
||||
# Same word in title but different album
|
||||
("Greatest Hits Volume 1", "Greatest Hits Volume 2"),
|
||||
# Sokhi: distinct editions of the same franchise — the OST vs a bonus edition
|
||||
# with a real subtitle. USED to collapse to the same normalized name (the blanket
|
||||
# trailing-dash strip removed "- Nos vies en Lumière"), so the watchlist marked
|
||||
# unowned OST tracks as owned via the bonus edition. Must be DIFFERENT albums.
|
||||
("Clair Obscur: Expedition 33: Original Soundtrack",
|
||||
"Clair Obscur: Expedition 33 - Nos vies en Lumière (Bonus Edition)"),
|
||||
# A real subtitle after a dash must not be stripped down to the base name.
|
||||
("The Album", "The Album - A Whole Different Subtitle"),
|
||||
],
|
||||
)
|
||||
def test_likely_match_negative(spotify_name, lib_name) -> None:
|
||||
assert not _albums_likely_match(spotify_name, lib_name)
|
||||
|
||||
|
||||
def test_real_subtitle_after_dash_is_preserved() -> None:
|
||||
# the regression's root cause: a meaningful subtitle must survive normalization,
|
||||
# while a recognized qualifier after a dash ("- Live", "- 2011") still collapses.
|
||||
assert _normalize_album_for_match("Clair Obscur: Expedition 33 - Nos vies en Lumière") \
|
||||
!= _normalize_album_for_match("Clair Obscur: Expedition 33")
|
||||
assert _normalize_album_for_match("Some Album - Live") == _normalize_album_for_match("Some Album")
|
||||
assert _normalize_album_for_match("Some Album - 2011") == _normalize_album_for_match("Some Album")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _albums_likely_match — defensive cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue