From 2bc8e8a27ba5fe2618e2b9f5c82835a913b5725b Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 30 Apr 2026 21:42:16 +0300 Subject: [PATCH] Preserve artwork in quality scanner wishlist handoff - carry track-level album art through the quality scanner normalization path - preserve artist artwork when provider results expose it - keep album.image_url and album.images populated so the wishlist UI can render the cover consistently - add a regression test covering provider payloads with image_url on both the track and artist --- core/discovery/quality_scanner.py | 81 +++++++++++++++++-- .../test_discovery_quality_scanner.py | 36 +++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/core/discovery/quality_scanner.py b/core/discovery/quality_scanner.py index 2d450293..6c213c80 100644 --- a/core/discovery/quality_scanner.py +++ b/core/discovery/quality_scanner.py @@ -87,7 +87,15 @@ def _normalize_track_artists(track_item: Any) -> list[dict]: if not artist_name and isinstance(artist, (str, bytes)): artist_name = artist if artist_name: - normalized.append({'name': str(artist_name)}) + artist_data = {'name': str(artist_name)} + artist_images = _normalize_image_entries(_extract_lookup_value(artist, 'images', default=[])) + artist_image_url = _extract_lookup_value(artist, 'image_url', 'artist_image_url', default=None) + if artist_image_url and not artist_images: + artist_images = [{'url': str(artist_image_url)}] + if artist_images: + artist_data['images'] = artist_images + artist_data['image_url'] = artist_images[0].get('url') + normalized.append(artist_data) if not normalized: normalized.append({'name': 'Unknown Artist'}) @@ -95,6 +103,43 @@ def _normalize_track_artists(track_item: Any) -> list[dict]: return normalized +def _normalize_image_entries(image_value: Any) -> list[dict]: + if not image_value: + return [] + + if isinstance(image_value, dict): + image_value = [image_value] + elif isinstance(image_value, (str, bytes)): + image_value = [image_value] + else: + try: + image_value = list(image_value) + except TypeError: + return [] + + normalized = [] + seen_urls = set() + for image in image_value: + if isinstance(image, dict): + image_url = image.get('url') or image.get('image_url') + if not image_url: + continue + image_dict = dict(image) + image_dict['url'] = str(image_url) + elif isinstance(image, (str, bytes)): + image_dict = {'url': str(image)} + else: + continue + + if image_dict['url'] in seen_urls: + continue + + seen_urls.add(image_dict['url']) + normalized.append(image_dict) + + return normalized + + def _normalize_track_album(track_item: Any) -> dict: album = _extract_lookup_value(track_item, 'album', default={}) if isinstance(album, dict): @@ -102,7 +147,6 @@ def _normalize_track_album(track_item: Any) -> dict: else: album_data = { 'name': _extract_lookup_value(album, 'name', 'title', default=str(album) if album else '') or '', - 'images': _extract_lookup_value(album, 'images', default=[]) or [], 'album_type': _extract_lookup_value(album, 'album_type', default='album') or 'album', 'total_tracks': _extract_lookup_value(album, 'total_tracks', 'track_count', default=0) or 0, 'release_date': _extract_lookup_value(album, 'release_date', default='') or '', @@ -112,10 +156,30 @@ def _normalize_track_album(track_item: Any) -> dict: album_data.setdefault('album_type', _extract_lookup_value(track_item, 'album_type', default='album') or 'album') album_data.setdefault('total_tracks', _extract_lookup_value(track_item, 'total_tracks', 'track_count', default=0) or 0) album_data.setdefault('release_date', _extract_lookup_value(track_item, 'release_date', default='') or '') - if isinstance(album, dict): - album_data.setdefault('images', album.get('images', []) or []) + + album_images = _normalize_image_entries(album_data.get('images')) + if not album_images and isinstance(album, dict): + album_images = _normalize_image_entries( + album.get('images') + or album.get('image_url') + or album.get('album_cover_url') + or album.get('cover_url') + ) + + if not album_images: + album_images = _normalize_image_entries( + _extract_lookup_value(track_item, 'images', default=None) + or _extract_lookup_value(track_item, 'image_url', default=None) + or _extract_lookup_value(track_item, 'album_cover_url', default=None) + or _extract_lookup_value(track_item, 'cover_url', default=None) + ) + + if album_images: + album_data['images'] = album_images + album_data.setdefault('image_url', album_images[0].get('url')) else: - album_data.setdefault('images', []) + album_data['images'] = [] + album_data.setdefault('artists', _normalize_track_artists(track_item)) return album_data @@ -126,6 +190,7 @@ def _normalize_track_match(track_item: Any, provider: str) -> dict: 'name': _extract_lookup_value(track_item, 'name', 'title', default='Unknown Track') or 'Unknown Track', 'artists': _normalize_track_artists(track_item), 'album': _normalize_track_album(track_item), + 'image_url': _extract_lookup_value(track_item, 'image_url', 'album_cover_url', default=None), 'duration_ms': _extract_lookup_value(track_item, 'duration_ms', default=0) or 0, 'track_number': _extract_lookup_value(track_item, 'track_number', default=1) or 1, 'disc_number': _extract_lookup_value(track_item, 'disc_number', default=1) or 1, @@ -135,6 +200,12 @@ def _normalize_track_match(track_item: Any, provider: str) -> dict: 'provider': provider, 'source': provider, } + if not track_data['image_url']: + album_images = track_data['album'].get('images') if isinstance(track_data['album'], dict) else [] + if isinstance(album_images, list) and album_images: + first_image = album_images[0] + if isinstance(first_image, dict): + track_data['image_url'] = first_image.get('url') return ensure_wishlist_track_format(track_data) diff --git a/tests/discovery/test_discovery_quality_scanner.py b/tests/discovery/test_discovery_quality_scanner.py index 9a383c0f..f3395ad4 100644 --- a/tests/discovery/test_discovery_quality_scanner.py +++ b/tests/discovery/test_discovery_quality_scanner.py @@ -357,6 +357,42 @@ def test_match_adds_to_wishlist(mock_db_and_wishlist): assert add_args['source_context']['original_file_path'] == '/x.mp3' +def test_match_preserves_album_and_artist_images(mock_db_and_wishlist): + """Image metadata from the provider payload should survive the wishlist handoff.""" + db, ws = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('Artist')] + db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)] + state = {} + match = { + 'id': 'sp-1', + 'name': 'Track', + 'artists': [{'name': 'Artist', 'image_url': 'https://example.test/artist.jpg'}], + 'album': 'Album', + 'image_url': 'https://example.test/cover.jpg', + 'duration_ms': 200000, + 'popularity': 50, + 'external_urls': {}, + 'album_type': 'album', + 'release_date': '2024-01-01', + } + deps = _build_deps( + state=state, + quality_tier_result=('low_lossy', 4), + source_clients={'spotify': _FakeMetadataClient(results=[match])}, + primary_source='spotify', + ) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['matched'] == 1 + assert len(ws.added) == 1 + add_args = ws.added[0] + assert add_args['track_data']['image_url'] == 'https://example.test/cover.jpg' + assert add_args['track_data']['album']['image_url'] == 'https://example.test/cover.jpg' + assert add_args['track_data']['album']['images'] == [{'url': 'https://example.test/cover.jpg'}] + assert add_args['track_data']['artists'][0]['image_url'] == 'https://example.test/artist.jpg' + + def test_no_match_no_wishlist_add(mock_db_and_wishlist): """No match found → no wishlist add, matched stays 0.""" db, ws = mock_db_and_wishlist