soulsync/tests/test_missing_cover_art.py
BoulderBadgeDad 8277385051 Cover Art Filler: resolve the rep path before the disk-art check (flags-every-album)
The scan checked album_has_art_on_disk() on the RAW DB track path, while the
apply (_fix_missing_cover_art) resolves the path first. On any path-mapped
setup (docker mounts, a Plex/SoulSync path mismatch) the raw path isn't found,
disk art reads as "missing", and EVERY album gets flagged — then the apply
resolves the path, finds the art already there, and reports "already present".
Scan and apply disagreed purely because only the apply resolved paths.

Now the scan resolves the representative path the same way (resolve_library_
file_path, same transfer/download/config inputs the retag job uses) before
checking disk art. Unresolvable → treated as no-local-file (not claimed
disk-missing) so we never false-flag a file we simply can't reach.

Tests: disk check runs on the resolved path (thumb+art → not flagged);
unresolvable path → not flagged + art never checked on None.
2026-06-08 09:52:17 -07:00

328 lines
13 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')
# ── disk-art check must run on the RESOLVED path (flags-every-album bug) ──
def _add_track(conn, path):
conn.execute(
"INSERT INTO tracks (id, album_id, file_path, disc_number, track_number) "
"VALUES (1, 1, ?, 1, 1)", (path,))
conn.commit()
def test_scan_checks_disk_art_on_resolved_path(monkeypatch):
# Album already has a DB thumb (db not missing) and a track whose DB path
# only resolves via mapping. The disk-art check must run on the RESOLVED
# path — checking the raw path would fail on path-mapped setups and flag
# the whole library while the apply (which resolves) finds art present.
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, '/plex/raw/song.flac')
context = _make_context(conn)
checked = {}
monkeypatch.setattr(mca, 'resolve_library_file_path',
lambda raw, **k: '/resolved/song.flac' if raw == '/plex/raw/song.flac' else None)
monkeypatch.setattr(mca, 'album_has_art_on_disk',
lambda p: checked.update(path=p) or True)
result = mca.MissingCoverArtJob().scan(context)
assert checked.get('path') == '/resolved/song.flac' # resolved, not raw
assert result.findings_created == 0 # thumb + disk art → not flagged
def test_scan_unresolvable_path_not_flagged_disk_missing(monkeypatch):
# An unreachable file (resolve → None) must NOT be claimed as "missing disk
# art" — we can't know, so don't false-flag. (Album has a thumb already.)
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, '/gone/song.flac')
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None)
called = []
monkeypatch.setattr(mca, 'album_has_art_on_disk', lambda p: called.append(p) or False)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 0 # thumb present, disk unknown → not flagged
assert called == [] # never checked art on a None path