_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.
294 lines
12 KiB
Python
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)
|