soulsync/tests/test_missing_cover_art.py
BoulderBadgeDad 7207ec61fb Cover Art Filler: fix album art or artist art independently (Pache711)
Pache711: a cover-art finding showed the (correct) found album art next to a
(wrong) artist image with one "Apply Art" button — no way to take one and
skip the other. Turned out "Apply Art" only ever applied ALBUM art anyway;
the artist image was display-only context, so the bundling was an illusion
the UI created.

Now the finding is genuinely multi-target:
- scan (missing_cover_art.py): also searches for an artist image (always, so
  a WRONG existing one can be replaced — Boulder's call), name-matched
  exactly. Stored as found_artist_url only when it differs from the current
  artist thumb, so nothing is offered when there's nothing to change.
- apply (_fix_missing_cover_art): honors a target via _fix_action —
  'album' (default, unchanged "Apply Art" behavior: DB thumb + embed +
  cover.jpg), 'artist' (the artist's DB image), or 'both'. New _fix_artist_art
  sets artists.thumb_url for the album's artist.
- UI: each found image gets its own apply button — "Use for album" /
  "Use for artist". Applying either resolves the finding, so taking the
  correct one and ignoring the wrong one IS "fix one, dismiss the other".
  Current artist art shows as "(current)" context with no button.

Default stays album-only, so the plain Apply Art button and every existing
caller behave exactly as before. Tests: 5 on the apply targets (artist-only /
album-only / default / both / missing-url) against a real SQLite DB, plus the
existing cover-art suite updated for the new artist search. 107 repair/
cover-art/UI-integrity tests pass.
2026-06-07 13:24:46 -07:00

283 lines
11 KiB
Python

import sqlite3
import sys
import types
from types import SimpleNamespace
# Stub optional Spotify dependency so metadata_service can import in tests.
if 'spotipy' not in sys.modules:
spotipy = types.ModuleType('spotipy')
oauth2 = types.ModuleType('spotipy.oauth2')
class _DummySpotify:
pass
class _DummyOAuth:
pass
spotipy.Spotify = _DummySpotify
oauth2.SpotifyOAuth = _DummyOAuth
oauth2.SpotifyClientCredentials = _DummyOAuth
spotipy.oauth2 = oauth2
sys.modules['spotipy'] = spotipy
sys.modules['spotipy.oauth2'] = oauth2
if 'config.settings' not in sys.modules:
config_mod = types.ModuleType('config')
settings_mod = types.ModuleType('config.settings')
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "plex"
settings_mod.config_manager = _DummyConfigManager()
config_mod.settings = settings_mod
sys.modules['config'] = config_mod
sys.modules['config.settings'] = settings_mod
from core.repair_jobs import missing_cover_art as mca
class _FakeClient:
def __init__(self, album_image=None, search_image=None):
self.album_image = album_image
self.search_image = search_image
self.get_album_calls = []
self.search_calls = []
def get_album(self, album_id, include_tracks=False):
self.get_album_calls.append((album_id, include_tracks))
if self.album_image is None:
return None
if isinstance(self.album_image, str):
return {'images': [{'url': self.album_image}]}
return self.album_image
def search_albums(self, query, limit=1):
self.search_calls.append((query, limit))
if self.search_image is None:
return []
# Real source clients return album results carrying title + artist;
# the filler now validates those before trusting the artwork.
return [SimpleNamespace(id='search-album', image_url=self.search_image,
title='Album', artist='Artist')]
def _make_db(album_row):
conn = sqlite3.connect(':memory:')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE artists (
id INTEGER PRIMARY KEY,
name TEXT,
thumb_url TEXT
)
"""
)
cursor.execute(
"""
CREATE TABLE albums (
id INTEGER PRIMARY KEY,
title TEXT,
artist_id INTEGER,
thumb_url TEXT,
spotify_album_id TEXT,
itunes_album_id TEXT,
deezer_id TEXT,
discogs_id TEXT,
soul_id TEXT
)
"""
)
# The scan now joins a representative track path to check art on disk.
cursor.execute(
"""
CREATE TABLE tracks (
id INTEGER PRIMARY KEY,
album_id INTEGER,
file_path TEXT,
disc_number INTEGER,
track_number INTEGER
)
"""
)
cursor.execute(
"INSERT INTO artists (id, name, thumb_url) VALUES (?, ?, ?)",
(1, 'Artist', 'https://artist/thumb'),
)
cursor.execute(
"""
INSERT INTO albums
(id, title, artist_id, thumb_url, spotify_album_id, itunes_album_id, deezer_id, discogs_id, soul_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
album_row,
)
conn.commit()
return conn
def _make_context(conn, prefer_source=None):
job_settings = {}
if prefer_source is not None:
job_settings['prefer_source'] = prefer_source
settings = {'repair.jobs.missing_cover_art.settings': job_settings}
findings = []
return SimpleNamespace(
db=SimpleNamespace(_get_connection=lambda: conn),
config_manager=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)),
check_stop=lambda: False,
wait_if_paused=lambda: False,
update_progress=lambda *args, **kwargs: None,
report_progress=lambda *args, **kwargs: None,
# Mirror real `_create_finding` contract: True on insert.
create_finding=lambda **kwargs: (findings.append(kwargs) or True),
findings=findings,
)
def test_missing_cover_art_prefers_explicit_source_over_primary(monkeypatch):
conn = _make_db((1, 'Album', 1, '', 'sp-album', 'it-album', 'dz-album', 'dg-album', 'hy-album'))
context = _make_context(conn, prefer_source='spotify')
deezer_client = _FakeClient(album_image='https://img/deezer-direct')
spotify_client = _FakeClient(album_image='https://img/spotify-direct')
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'deezer')
monkeypatch.setattr(
mca,
'get_client_for_source',
lambda source: {'deezer': deezer_client, 'spotify': spotify_client}.get(source),
)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1
assert spotify_client.get_album_calls == [('sp-album', False)]
assert deezer_client.get_album_calls == []
assert context.findings[0]['details']['found_artwork_url'] == 'https://img/spotify-direct'
def test_missing_cover_art_uses_configured_art_sources(monkeypatch):
"""When cover-art sources are configured (album_art_order), the Filler pulls
art from them and skips the metadata source-priority loop — same 'cover art
sources' notion the Re-tag job and post-process embed honor."""
conn = _make_db((1, 'Album', 1, '', 'sp-album', 'it-album', 'dz-album', 'dg-album', 'hy-album'))
settings = {
'repair.jobs.missing_cover_art.settings': {},
'metadata_enhancement.album_art_order': ['itunes', 'deezer'],
}
findings = []
context = SimpleNamespace(
db=SimpleNamespace(_get_connection=lambda: conn),
config_manager=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)),
check_stop=lambda: False, wait_if_paused=lambda: False,
update_progress=lambda *a, **k: None, report_progress=lambda *a, **k: None,
create_finding=lambda **kw: (findings.append(kw) or True),
findings=findings,
)
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
consulted = []
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: consulted.append(s) or _FakeClient())
monkeypatch.setattr('core.metadata.art_lookup.select_preferred_art_url',
lambda artist, album, meta, order, **k: 'https://configured/art.jpg')
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1
# ALBUM art came from the configured order — the album source-priority loop
# was skipped (if it had run, the URL would be the fake client's art).
assert findings[0]['details']['found_artwork_url'] == 'https://configured/art.jpg'
# Artist-art search (Pache711) is a SEPARATE lookup that does consult the
# sources; the fake client has no search_artists, so it finds nothing and
# no artist target is offered.
assert findings[0]['details']['found_artist_url'] is None
def test_missing_cover_art_uses_primary_when_prefer_unset(monkeypatch):
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None))
context = _make_context(conn)
discogs_client = _FakeClient(search_image='https://img/discogs-search')
spotify_client = _FakeClient(search_image='https://img/spotify-search')
itunes_client = _FakeClient(search_image='https://img/itunes-search')
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'discogs')
monkeypatch.setattr(
mca,
'get_client_for_source',
lambda source: {'discogs': discogs_client, 'spotify': spotify_client, 'itunes': itunes_client}.get(source),
)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1
assert discogs_client.search_calls == [('Artist Album', 5)]
assert spotify_client.search_calls == []
assert itunes_client.search_calls == []
assert context.findings[0]['details']['found_artwork_url'] == 'https://img/discogs-search'
# ── Stricter matching (issue: new sources returning WRONG cover art) ──
class _SearchClient:
"""search_albums returns whatever results it's given (title/artist/image)."""
def __init__(self, results):
self._results = results
self.search_calls = []
def get_album(self, album_id, include_tracks=False):
return None
def search_albums(self, query, limit=1):
self.search_calls.append((query, limit))
return list(self._results)
def test_search_rejects_wrong_artist_result(monkeypatch):
"""A result with the right-ish title but a DIFFERENT artist must be rejected
(this is what produced wrong covers from the new sources)."""
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None))
context = _make_context(conn)
client = _SearchClient([SimpleNamespace(id='x', image_url='https://img/wrong',
title='Album', artist='Different Artist')])
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'discogs')
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: client if s == 'discogs' else None)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 0 # wrong-artist art not accepted
assert context.findings == []
def test_search_skips_wrong_result_and_takes_matching_one(monkeypatch):
"""Given several results, take the first that actually matches title+artist."""
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None))
context = _make_context(conn)
client = _SearchClient([
SimpleNamespace(id='a', image_url='https://img/wrong', title='Other Record', artist='Someone'),
SimpleNamespace(id='b', image_url='https://img/right', title='Album', artist='Artist'),
])
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'discogs')
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: client if s == 'discogs' else None)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1
assert context.findings[0]['details']['found_artwork_url'] == 'https://img/right'
def test_result_matches_unit():
m = mca.MissingCoverArtJob._result_matches
# exact + deluxe variant + featuring all accepted when artist matches
assert m({'title': 'Album', 'artist': 'Artist'}, 'Album', 'Artist')
assert m({'title': 'Album (Deluxe Edition)', 'artist': 'Artist'}, 'Album', 'Artist')
assert m({'title': 'Album', 'artist': 'The Artist'}, 'Album', 'Artist') # stopword 'the'
# wrong artist / wrong title rejected
assert not m({'title': 'Album', 'artist': 'Nope'}, 'Album', 'Artist')
assert not m({'title': 'Totally Other', 'artist': 'Artist'}, 'Album', 'Artist')
# no artist on result → require exact title
assert m({'title': 'Album'}, 'Album', 'Artist')
assert not m({'title': 'Album Deluxe'}, 'Album', 'Artist')