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, 'file_has_embedded_art', lambda p: checked.update(path=p) or True) monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: True) # has cover.jpg too result = mca.MissingCoverArtJob().scan(context) assert checked.get('path') == '/resolved/song.flac' # resolved, not raw assert result.findings_created == 0 # embedded + cover.jpg → 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, 'file_has_embedded_art', lambda p: called.append(p) or False) monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: called.append(d) 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 def test_local_album_with_embedded_and_sidecar_not_flagged(monkeypatch): # Has BOTH embedded art AND a cover.jpg — nothing missing, even with an # empty DB thumb cache. (Boulder: don't flag albums that already have art.) conn = _make_db((1, 'Album', 1, '', None, None, None, None, None)) # empty thumb _add_track(conn, '/music/Album/01.flac') context = _make_context(conn) monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw) monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True) monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: True) result = mca.MissingCoverArtJob().scan(context) assert result.findings_created == 0 # has both → not "missing" assert result.skipped == 1 def test_embedded_art_but_no_cover_jpg_is_flagged(monkeypatch): # Sokhi: files HAVE embedded art but no cover.jpg sidecar. With cover.jpg # enabled (default), it's flagged so the filler writes the sidecar — even # when the API finds NO art (the apply extracts the embedded art). conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None)) _add_track(conn, '/music/Album/01.flac') context = _make_context(conn) monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw) monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True) # embedded present monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: False) # but no cover.jpg monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify') monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient()) # API finds nothing result = mca.MissingCoverArtJob().scan(context) assert result.findings_created == 1 # flagged for the missing sidecar assert context.findings[0]['details']['sidecar_from_embedded'] is True def test_local_album_without_file_art_still_flagged(monkeypatch): # Local album whose files genuinely lack art → still flagged (real case). # Give it a source id + findable art so a finding is created when flagged. conn = _make_db((1, 'Album', 1, '', 'sp-album', None, None, None, None)) _add_track(conn, '/music/Album/01.flac') context = _make_context(conn) monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw) monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: False) # no embedded art monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: False) monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify') monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient(album_image='https://img/x')) result = mca.MissingCoverArtJob().scan(context) assert result.findings_created == 1 # files lack art → flagged def test_media_server_only_album_empty_thumb_still_flagged(monkeypatch): # No local files (media-server-only) + empty thumb → DB thumb is the only # art, so still flag it. conn = _make_db((1, 'Album', 1, '', 'sp-album', None, None, None, None)) # no track added context = _make_context(conn) monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify') monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient(album_image='https://img/x')) result = mca.MissingCoverArtJob().scan(context) assert result.findings_created == 1 # media-server-only + empty thumb → flagged def test_unresolved_path_falls_back_to_raw_when_file_exists(tmp_path, monkeypatch): # Docker case (Sokhi): the path-mapping layer returns None, but the raw DB # path is already a real file in the container. The scan must use it as-is — # like the apply does (`_resolve_file_path(...) or p`) — so the album's # folder is actually checked instead of the album being skipped. track = tmp_path / 'Album' / '01.flac' track.parent.mkdir() track.write_bytes(b'') conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None)) _add_track(conn, str(track)) context = _make_context(conn) monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None) # mapping fails monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True) # files have art # real folder has no cover.jpg → should flag for the sidecar monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify') monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient()) # API finds nothing result = mca.MissingCoverArtJob().scan(context) assert result.findings_created == 1 # raw path used → folder checked → flagged assert context.findings[0]['details']['sidecar_from_embedded'] is True def test_unresolved_path_with_no_real_file_still_skips(tmp_path, monkeypatch): # Guard: the raw-path fallback must NOT fire for a path that isn't a real # file (no false "local" on a genuinely media-server-only album). conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None)) _add_track(conn, '/does/not/exist/01.flac') context = _make_context(conn) monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None) called = [] monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: called.append(p) or True) result = mca.MissingCoverArtJob().scan(context) assert result.findings_created == 0 # no real file, db has thumb → skipped assert called == [] # never treated as local