soulsync/tests/test_watchlist_album_match.py
BoulderBadgeDad 7e2d2db08d 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.
2026-06-26 20:38:10 -07:00

294 lines
12 KiB
Python

"""Regression tests for watchlist album-name matching helpers.
Discord-reported (Mushy): the watchlist scanner re-downloaded the same
track up to 7 times because Spotify's album name
(``"Napoleon Dynamite (Music From The Motion Picture)"``) and the
media-server scan's album name (``"Napoleon Dynamite OST"``) failed a
strict 0.85 fuzzy threshold. ``is_track_missing_from_library`` then
declared the track missing on every scan and added it back to the
wishlist.
The fix replaces the raw SequenceMatcher comparison with two pure
helpers — ``_normalize_album_for_match`` (strips qualifying
parentheticals like ``(Music From X)``, ``(Deluxe Edition)``, OST,
Remastered, etc.) and ``_albums_likely_match`` (substring check +
relaxed ratio). These tests pin the behavior so the regression doesn't
return.
"""
import sys
import types
import pytest
# ---------------------------------------------------------------------------
# Fixture: stub the heavy module-level imports so we can import the
# watchlist_scanner module without a live Spotify client / config DB.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module", autouse=True)
def _stub_imports():
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
oauth2 = types.ModuleType("spotipy.oauth2")
class _Dummy:
def __init__(self, *a, **kw):
pass
spotipy.Spotify = _Dummy
oauth2.SpotifyOAuth = _Dummy
oauth2.SpotifyClientCredentials = _Dummy
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _CM:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "plex"
settings_mod.config_manager = _CM()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
if "core.matching_engine" not in sys.modules:
me = types.ModuleType("core.matching_engine")
class _ME:
def clean_title(self, title):
return title
me.MusicMatchingEngine = _ME
sys.modules["core.matching_engine"] = me
yield
# Imports happen lazily so the stubs above are in place first.
from core.watchlist_scanner import ( # noqa: E402
_albums_likely_match,
_normalize_album_for_match,
)
# ---------------------------------------------------------------------------
# _normalize_album_for_match
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
("Napoleon Dynamite (Music From The Motion Picture)", "napoleon dynamite"),
("Napoleon Dynamite OST", "napoleon dynamite"),
("Napoleon Dynamite [Original Soundtrack]", "napoleon dynamite"),
("Abbey Road (Deluxe Edition)", "abbey road"),
("Abbey Road (50th Anniversary Edition)", "abbey road"),
("Hotel California (Remastered)", "hotel california"),
("Thriller - Remastered 2011", "thriller"),
("Mr. Morale & The Big Steppers", "mr morale the big steppers"),
("Random album with NO qualifiers", "random album with no qualifiers"),
("", ""),
],
)
def test_normalize_strips_known_qualifiers(raw, expected) -> None:
assert _normalize_album_for_match(raw) == expected
def test_normalize_handles_none_safely() -> None:
"""Defensive: callers pass DB rows that may have a None album."""
assert _normalize_album_for_match(None) == "" # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# _albums_likely_match — primary regression: the reported scenario
# ---------------------------------------------------------------------------
def test_napoleon_dynamite_compilation_naming_drift_treated_as_match() -> None:
"""The bug we're fixing: this pair USED to score 0.49 SequenceMatcher
against the raw strings (below 0.85) and trigger an infinite
redownload loop. Must now match."""
assert _albums_likely_match(
"Napoleon Dynamite (Music From The Motion Picture)",
"Napoleon Dynamite OST",
)
def test_compilation_score_explanation() -> None:
"""Document the underlying scenario for future readers — both sides
refer to the same compilation, named differently by Spotify and by
the media-server tag scan."""
a = "Napoleon Dynamite (Music From The Motion Picture)"
b = "Napoleon Dynamite OST"
# After normalization both collapse to "napoleon dynamite", so the
# equality short-circuit fires before the fuzzy ratio matters.
assert _normalize_album_for_match(a) == _normalize_album_for_match(b)
# ---------------------------------------------------------------------------
# _albums_likely_match — positive cases (should match)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
# Edition drift
("Abbey Road (Deluxe Edition)", "Abbey Road"),
("Abbey Road", "Abbey Road (Deluxe Edition)"),
("Abbey Road (50th Anniversary Edition)", "Abbey Road [Remastered]"),
# Remaster drift
("Hotel California (Remastered)", "Hotel California"),
("Thriller - Remastered 2011", "Thriller"),
# Soundtrack naming variations
("The Lion King (Original Motion Picture Soundtrack)", "The Lion King OST"),
("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:
assert _albums_likely_match(spotify_name, lib_name)
# ---------------------------------------------------------------------------
# _albums_likely_match — negative cases (genuinely different albums)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
# Genuinely different albums by the same artist
("To Pimp a Butterfly", "DAMN."),
("Thriller", "Bad"),
("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
# ---------------------------------------------------------------------------
def test_empty_inputs_do_not_match() -> None:
"""A comparison with a missing side never matches — avoids returning
True for blank-vs-blank which would mask real bugs."""
assert not _albums_likely_match("", "")
assert not _albums_likely_match("Album", "")
assert not _albums_likely_match("", "Album")
def test_none_inputs_do_not_raise() -> None:
"""DB rows occasionally carry NULL albums."""
assert not _albums_likely_match(None, "Album") # type: ignore[arg-type]
assert not _albums_likely_match("Album", None) # type: ignore[arg-type]
def test_only_qualifiers_does_not_falsely_match() -> None:
"""Two albums whose only common substance is the stripped qualifier
must NOT match — otherwise '(Deluxe Edition)' vs '(Deluxe Edition)'
would collapse to '' == '' and trigger a true."""
assert not _albums_likely_match("(Deluxe Edition)", "(Deluxe Edition)")
assert not _albums_likely_match("(OST)", "(OST)")
# ---------------------------------------------------------------------------
# Volume / part / disc marker disagreement — explicit tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
("Greatest Hits Vol. 1", "Greatest Hits Vol. 2"),
("Greatest Hits Volume 1", "Greatest Hits Volume 2"),
("Best Of, Pt. 1", "Best Of, Pt. 2"),
("Live in Tokyo Disc 1", "Live in Tokyo Disc 2"),
("Live Album 1995", "Live Album 1997"), # trailing year as standalone number
],
)
def test_disagreeing_volume_markers_block_match(spotify_name, lib_name) -> None:
assert not _albums_likely_match(spotify_name, lib_name)
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
("Greatest Hits Vol. 1", "Greatest Hits Vol. 1 (Remastered)"),
("Greatest Hits Volume 1", "Greatest Hits Vol 1"),
],
)
def test_agreeing_volume_markers_still_match(spotify_name, lib_name) -> None:
"""Same volume marker should NOT block a match that other rules accept."""
assert _albums_likely_match(spotify_name, lib_name)
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
# Decimal / multi-part volume numbers must be distinguished — the dot is
# stripped to a space in normalization, and grabbing only the last digit
# made these collapse to the same marker (Sokhi: character-song CDs).
("Character CD Vol.5", "Character CD Vol.5.5"),
("Character CD Vol.5.5", "Character CD Vol.4.5"),
("Anime OST Vol.1.5", "Anime OST Vol.2.5"),
# The real CJK album names from the report.
("TVアニメ「【推しの子】」キャラクターソングCD Vol.5",
"TVアニメ「【推しの子】」キャラクターソングCD Vol.5.5"),
],
)
def test_decimal_volume_markers_block_match(spotify_name, lib_name) -> None:
assert not _albums_likely_match(spotify_name, lib_name)
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
("Character CD Vol.5.5", "Character CD Vol.5.5"), # identical decimal vol
("Character CD Vol.5.5", "Character CD Vol.5.5 (Deluxe)"),
],
)
def test_same_decimal_volume_still_matches(spotify_name, lib_name) -> None:
assert _albums_likely_match(spotify_name, lib_name)