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.
270 lines
10 KiB
Python
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)
|