From 7e2d2db08df067870dcdda866839119a1d26dc43 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 20:30:20 -0700 Subject: [PATCH] watchlist: don't fuse different editions as the same album (Sokhi: Expedition 33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- core/watchlist_scanner.py | 33 ++++++++++++++++++++++------- tests/test_watchlist_album_match.py | 24 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 55d03105..f8eda5e3 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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 diff --git a/tests/test_watchlist_album_match.py b/tests/test_watchlist_album_match.py index cbe9fbf2..2ededda9 100644 --- a/tests/test_watchlist_album_match.py +++ b/tests/test_watchlist_album_match.py @@ -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 # ---------------------------------------------------------------------------