soulsync/tests/metadata/test_art_lookup.py
BoulderBadgeDad 142a1aaf38 Cover art: a numeric difference is a different release — Vol.4 stops wearing Vol.4.5's cover
Sokhi (continued from #806): volume-numbered series ('B小町 …キャラクター
ソングCD Vol.2' / 'Vol.2.5' / 'Vol.4' / 'Vol.4.5') got each other's art from
both normal downloads and the retag tool. Two distinct holes, one principle:

1. The art picker's _album_matches validates by significant-token SUBSET —
   built to tolerate '(Deluxe)'/'- Remastered' suffixes. CJK strips out of
   the normalizer entirely, so Vol.4 → {b,tv,cd,vol,4}, a clean subset of
   Vol.4.5's {b,tv,cd,vol,4,5}: the wrong volume validated as "the same
   album with a suffix". Affected every fuzzy art source (iTunes, Deezer,
   AudioDB, Spotify) in downloads, retag, and the missing-art repair.

2. MusicBrainz match_release scores by string similarity — Vol.4 vs Vol.4.5
   is 0.973, so the wrong volume could win the match outright, and its MBID
   then feeds Cover Art Archive with NO downstream validation (CAA is
   MBID-keyed, trusted by design). With Sokhi's MB metadata source this is
   the likely path in his logs (his release-group 404s push re-matching).

The shared rule (core.text.title_match.numeric_tokens_differ): digit-bearing
tokens must be IDENTICAL between the two titles. A number on one side only —
volume, part, sequel, remaster year — is a different release, never a
suffix. '1989' vs '1989 (Deluxe)' still matches (digits shared); 'Album' vs
'Album 2' now rejects (sequels!). Art picker rejects outright (falls through
to next source / the download's own art — the designed cost of a false
reject); MB matcher halves the candidate's confidence, landing it below the
70 gate while the exact-volume result is untouched.

Tests: helper truth table, the exact reported pairs through _album_matches,
and match_release end-to-end (wrong volume alone → no match beats a wrong
MBID; exact volume beats near-identical wrong one despite lower MB score).
828 matching/metadata + 301 musicbrainz/retag/artwork tests pass.
2026-06-07 10:21:23 -07:00

281 lines
13 KiB
Python

"""Seam tests for the per-source album-art lookups + availability.
Real clients are stubbed (monkeypatched at the lazy-import sites), so these
exercise the field-extraction, caching, guarding, and availability gating
without any network.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.metadata import art_lookup
# ---------------------------------------------------------------------------
# Availability — "not everybody has every source"
# ---------------------------------------------------------------------------
def test_free_sources_always_available():
for s in ("caa", "deezer", "itunes", "audiodb"):
assert art_lookup.is_art_source_available(s) is True
def test_unknown_or_unsupported_source_unavailable():
assert art_lookup.is_art_source_available("tidal") is False # deferred
assert art_lookup.is_art_source_available("genius") is False
assert art_lookup.is_art_source_available("") is False
def test_spotify_availability_follows_connection(monkeypatch):
import core.metadata.registry as registry
monkeypatch.setattr(registry, "get_client_for_source", lambda s: object())
assert art_lookup.is_art_source_available("spotify") is True
monkeypatch.setattr(registry, "get_client_for_source", lambda s: None)
assert art_lookup.is_art_source_available("spotify") is False
def test_spotify_availability_swallows_errors(monkeypatch):
import core.metadata.registry as registry
def _boom(_s):
raise RuntimeError("registry down")
monkeypatch.setattr(registry, "get_client_for_source", _boom)
assert art_lookup.is_art_source_available("spotify") is False
def test_available_sources_lists_free_plus_connected_spotify(monkeypatch):
import core.metadata.registry as registry
monkeypatch.setattr(registry, "get_client_for_source", lambda s: object())
avail = art_lookup.available_art_sources()
assert avail == ["caa", "deezer", "itunes", "spotify", "audiodb"]
monkeypatch.setattr(registry, "get_client_for_source", lambda s: None)
assert "spotify" not in art_lookup.available_art_sources()
# ---------------------------------------------------------------------------
# Per-source extraction
# ---------------------------------------------------------------------------
def test_caa_art_builds_url_from_release_mbid():
url = art_lookup._caa_art("A", "B", {"musicbrainz_release_id": "abc-123"})
assert url == "https://coverartarchive.org/release/abc-123/front-1200"
def test_caa_art_none_without_mbid():
assert art_lookup._caa_art("A", "B", {}) is None
def test_deezer_art_extracts_and_prefers_largest(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "B", "artist": {"name": "A"},
"cover_big": "http://x/big.jpg",
"cover_xl": "http://x/xl.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# _upgrade_deezer_cover_url returns non-Deezer URLs unchanged.
assert art_lookup._deezer_art("A", "B", {}) == "http://x/xl.jpg"
def test_deezer_art_none_when_no_cover_fields(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
# Matches the album but carries no cover_* keys.
client.search_album.return_value = {"title": "B", "artist": {"name": "A"}, "id": 1}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
assert art_lookup._deezer_art("A", "B", {}) is None
def test_deezer_art_rejects_wrong_album(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "A Totally Different Record",
"artist": {"name": "A"},
"cover_xl": "http://x/wrong.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# Wrong album -> no art (falls back to today's cover), never wrong art.
assert art_lookup._deezer_art("A", "B", {}) is None
def test_deezer_art_rejects_wrong_artist(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "21", "artist": {"name": "Someone Else"},
"cover_xl": "http://x/wrong.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# Album matches but the artist doesn't -> reject (don't embed wrong art).
assert art_lookup._deezer_art("Adele", "21", {}) is None
def test_itunes_art_returns_first_matching_album_image(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_albums.return_value = [
SimpleNamespace(name="B", artists=["A"], image_url=None), # match but no art -> skip
SimpleNamespace(name="Wrong", artists=["A"], image_url="http://it/wrong.jpg"), # art but wrong album -> skip
SimpleNamespace(name="B (Deluxe)", artists=["A"], image_url="http://it/600.jpg"), # match + art
]
monkeypatch.setattr(registry, "get_itunes_client", lambda *a, **k: client)
assert art_lookup._itunes_art("A", "B", {}) == "http://it/600.jpg"
def test_itunes_art_upgrades_to_max_resolution(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_albums.return_value = [
SimpleNamespace(name="GNX", artists=["Kendrick Lamar"],
image_url="https://is1.mzstatic.com/image/source/600x600bb.jpg")]
monkeypatch.setattr(registry, "get_itunes_client", lambda *a, **k: client)
# The 600x600 default is bumped to the max so iTunes contributes big art.
assert art_lookup._itunes_art("Kendrick Lamar", "GNX", {}) == \
"https://is1.mzstatic.com/image/source/3000x3000bb.jpg"
def test_audiodb_art_extracts_thumb(monkeypatch):
import core.audiodb_client as adb
fake = MagicMock()
fake.search_album.return_value = {"strAlbum": "B", "strArtist": "A",
"strAlbumThumb": "http://adb/cover.jpg"}
monkeypatch.setattr(adb, "AudioDBClient", lambda *a, **k: fake)
assert art_lookup._audiodb_art("A", "B", {}) == "http://adb/cover.jpg"
def test_spotify_art_uses_connected_client(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_albums.return_value = [
SimpleNamespace(name="B", artists=["A"], image_url="http://sp/640.jpg")]
monkeypatch.setattr(registry, "get_client_for_source", lambda s: client)
assert art_lookup._spotify_art("A", "B", {}) == "http://sp/640.jpg"
# --- album-match validation (the wrong-art guard) ---
def test_album_matches_exact_and_suffix_variants():
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989")
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)")
assert art_lookup._album_matches("Pink Floyd", "The Dark Side of the Moon",
"Pink Floyd", "Dark Side of the Moon - Remastered")
def test_album_matches_multi_artist_and_feat():
assert art_lookup._album_matches("Drake", "Scorpion", "Drake & Future", "Scorpion")
assert art_lookup._album_matches("Tyler, The Creator", "IGOR", "Tyler The Creator", "IGOR")
def test_album_matches_rejects_wrong_album_or_artist():
assert not art_lookup._album_matches("Adele", "21", "Adele", "Completely Different")
# Generic album title, different artist -> the artist gate rejects it.
assert not art_lookup._album_matches("Coldplay", "Greatest Hits", "Other Band", "Greatest Hits")
assert not art_lookup._album_matches("Adele", "21", "Adele", "")
assert not art_lookup._album_matches("Adele", "", "Adele", "21")
def test_album_matches_unknown_requested_artist_allows_album_match():
# cover.jpg path may lack artist context -> album match alone suffices.
assert art_lookup._album_matches("", "1989", "Taylor Swift", "1989")
def test_album_matches_rejects_numeric_difference():
"""Sokhi: same series, different volume number. CJK strips to latin
tokens, so Vol.4 was a token-subset of Vol.4.5 and inherited its art.
A number on only one side = a different release, never a suffix."""
A = "B小町"
assert not art_lookup._album_matches(
A, "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4",
A, "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4.5")
assert not art_lookup._album_matches(
A, "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.2",
A, "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.2.5")
# Sequels are different albums too.
assert not art_lookup._album_matches("Artist", "Album", "Artist", "Album 2")
# Identical volume numbers still match.
assert art_lookup._album_matches(
A, "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4",
A, "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4")
# Numeric token shared by BOTH sides keeps non-numeric suffix tolerance.
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)")
# ---------------------------------------------------------------------------
# build_art_lookup — caching + guarding
# ---------------------------------------------------------------------------
def test_lookup_is_cached_per_source(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "B", "artist": {"name": "A"},
"cover_xl": "http://x/xl.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
lookup = art_lookup.build_art_lookup("A", "B", {})
first = lookup("deezer")
second = lookup("deezer")
assert first == second == "http://x/xl.jpg"
# Cached: the underlying client was only hit once across both calls.
assert client.search_album.call_count == 1
def test_lookup_guards_source_exceptions(monkeypatch):
import core.metadata.registry as registry
def _boom(*a, **k):
raise RuntimeError("network down")
monkeypatch.setattr(registry, "get_deezer_client", _boom)
lookup = art_lookup.build_art_lookup("A", "B", {})
assert lookup("deezer") is None # swallowed, not raised
def test_lookup_unknown_source_returns_none():
lookup = art_lookup.build_art_lookup("A", "B", {})
assert lookup("tidal") is None
assert lookup("bogus") is None
# ---------------------------------------------------------------------------
# select_preferred_art_url — the gate the artwork pipeline calls
# ---------------------------------------------------------------------------
def test_selector_feature_off_returns_none():
# The critical non-breaking case: no configured order -> no-op, caller
# keeps today's art.
assert art_lookup.select_preferred_art_url("A", "B", {}, None) is None
assert art_lookup.select_preferred_art_url("A", "B", {}, []) is None
assert art_lookup.select_preferred_art_url("A", "B", {}, "deezer") is None
def test_selector_resolves_first_available_source(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "B", "artist": {"name": "A"},
"cover_xl": "http://x/xl.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# Order lists an unsupported source first (filtered out), then deezer.
url = art_lookup.select_preferred_art_url("A", "B", {}, ["tidal", "deezer"])
assert url == "http://x/xl.jpg"
def test_selector_none_when_order_has_no_available_sources(monkeypatch):
import core.metadata.registry as registry
monkeypatch.setattr(registry, "get_client_for_source", lambda s: None) # spotify off
# 'tidal' unsupported, 'spotify' unavailable -> empty effective order.
assert art_lookup.select_preferred_art_url("A", "B", {}, ["tidal", "spotify"]) is None
def test_selector_none_when_nothing_resolves(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = None # deezer has no match
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
assert art_lookup.select_preferred_art_url("A", "B", {}, ["deezer"]) is None
def test_selector_caa_uses_release_mbid(monkeypatch):
url = art_lookup.select_preferred_art_url(
"A", "B", {"musicbrainz_release_id": "mbid-9"}, ["caa"])
assert url == "https://coverartarchive.org/release/mbid-9/front-1200"