soulsync/tests/test_watchlist_album_match.py
BoulderBadgeDad e6bf7c26de Watchlist: stop treating different decimal-volume albums as duplicates (Sokhi — the real bug)
Sokhi's log showed the actual cause: with the wishlist "allow duplicates" toggle
on, is_track_missing_from_library skips a track when _albums_likely_match() says
the wanted album and a library album are the same — and it was matching albums
that differ ONLY by a decimal volume number:
  [AllowDup] Album match — skipping (wanted: '...Vol.5', library: '...Vol.5.5')
  [AllowDup] Album match — skipping (wanted: '...Vol.5.5', library: '...Vol.4.5')
His character-song CDs share track titles across volumes, so tracks from albums
he DOESN'T have got skipped because a same-titled track sits in a similarly-named
volume he DOES have — the partially-filled discography never completed.

Root cause: _normalize_album_for_match strips the dot ("Vol.5.5" -> "vol 5 5"),
and _VOLUME_MARKER_RE captured only a single trailing digit, so Vol.5, Vol.5.5
and Vol.4.5 all reduced to marker "5" and the volume-disagreement guard never
fired. Fix: capture the full multi-part number ((\d+(?:\s+\d+)*)) so "5" / "5 5"
/ "4 5" are distinct and the guard correctly rejects the match.

(Not lookback — the log confirms lookback=all was already honored.)

Tests: decimal/multi-part volumes (incl. the real CJK names) now block the
match; identical decimal volumes + naming-drift cases still match. 111 watchlist
tests pass.
2026-06-08 20:46:40 -07:00

270 lines
10 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)"),
],
)
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"),
],
)
def test_likely_match_negative(spotify_name, lib_name) -> None:
assert not _albums_likely_match(spotify_name, lib_name)
# ---------------------------------------------------------------------------
# _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)