diff --git a/core/metadata_service.py b/core/metadata_service.py index 1a2f6b3b..04013a49 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -1747,6 +1747,66 @@ def get_musicmap_similar_artists( } +def _get_artist_image_from_source(source: str, artist_id: str) -> Optional[str]: + client = get_client_for_source(source) + if not client: + return None + + try: + if source == 'spotify': + artist_data = client.get_artist(artist_id, allow_fallback=False) + else: + artist_data = client.get_artist(artist_id) + except Exception as exc: + logger.debug("Could not fetch artist image for %s on %s: %s", artist_id, source, exc) + artist_data = None + + image_url = _extract_artist_image_url(artist_data) + if image_url: + return image_url + + if hasattr(client, '_get_artist_image_from_albums'): + try: + return client._get_artist_image_from_albums(artist_id) + except Exception as exc: + logger.debug("Could not fetch artist album art for %s on %s: %s", artist_id, source, exc) + + return None + + +def get_artist_image_url( + artist_id: str, + source_override: Optional[str] = None, + plugin: Optional[str] = None, +) -> Optional[str]: + """Resolve an artist image URL using the configured source priority.""" + if not artist_id: + return None + + if artist_id.startswith('soul_'): + return None + + source_override = (source_override or '').strip().lower() + plugin = (plugin or '').strip().lower() + + if source_override == 'hydrabase': + if plugin in ('deezer', 'itunes'): + return _get_artist_image_from_source(plugin, artist_id) + if artist_id.isdigit(): + return _get_artist_image_from_source('itunes', artist_id) + return None + + if source_override: + return _get_artist_image_from_source(source_override, artist_id) + + for source in get_source_priority(get_primary_source()): + image_url = _get_artist_image_from_source(source, artist_id) + if image_url: + return image_url + + return None + + def get_deezer_client(): """Get cached Deezer client. diff --git a/tests/test_metadata_service_artist_image.py b/tests/test_metadata_service_artist_image.py new file mode 100644 index 00000000..c33be2b0 --- /dev/null +++ b/tests/test_metadata_service_artist_image.py @@ -0,0 +1,152 @@ +import sys +import types + + +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + + class _DummySpotify: + def __init__(self, *args, **kwargs): + pass + + oauth2 = types.ModuleType("spotipy.oauth2") + + class _DummyOAuth: + def __init__(self, *args, **kwargs): + 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_pkg = types.ModuleType("config") + settings_mod = types.ModuleType("config.settings") + + class _DummyConfigManager: + def get(self, key, default=None): + return default + + settings_mod.config_manager = _DummyConfigManager() + config_pkg.settings = settings_mod + sys.modules["config"] = config_pkg + sys.modules["config.settings"] = settings_mod + +from core import metadata_service + + +class _FakeSpotifyClient: + def __init__(self, image_url="https://spotify.example/artist.jpg"): + self.image_url = image_url + self.calls = [] + + def is_spotify_authenticated(self): + return True + + def get_artist(self, artist_id, allow_fallback=True): + self.calls.append((artist_id, allow_fallback)) + return { + "id": artist_id, + "name": "Spotify Artist", + "images": [{"url": self.image_url}], + "genres": ["rock"], + "popularity": 80, + } + + +class _FakeDeezerClient: + def __init__(self, image_url="https://deezer.example/artist.jpg"): + self.image_url = image_url + self.calls = [] + + def get_artist(self, artist_id): + self.calls.append(artist_id) + return types.SimpleNamespace( + id=artist_id, + name="Deezer Artist", + image_url=self.image_url, + genres=["indie"], + popularity=0, + ) + + +class _FakeItunesClient: + def __init__(self, album_art_url="https://itunes.example/artist.jpg"): + self.album_art_url = album_art_url + self.calls = [] + self.album_art_calls = [] + + def get_artist(self, artist_id): + self.calls.append(artist_id) + return { + "id": artist_id, + "name": "iTunes Artist", + "images": [], + "genres": ["alt"], + "popularity": 0, + } + + def _get_artist_image_from_albums(self, artist_id): + self.album_art_calls.append(artist_id) + return self.album_art_url + + +def test_get_artist_image_url_uses_primary_source_priority(monkeypatch): + deezer = _FakeDeezerClient() + spotify = _FakeSpotifyClient() + + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "spotify"]) + monkeypatch.setattr( + metadata_service, + "get_client_for_source", + lambda source: {"deezer": deezer, "spotify": spotify}.get(source), + ) + + image_url = metadata_service.get_artist_image_url("artist-1") + + assert image_url == "https://deezer.example/artist.jpg" + assert deezer.calls == ["artist-1"] + assert spotify.calls == [] + + +def test_get_artist_image_url_uses_itunes_album_art_for_explicit_override(monkeypatch): + itunes = _FakeItunesClient() + spotify = _FakeSpotifyClient() + + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "spotify") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "itunes"]) + monkeypatch.setattr( + metadata_service, + "get_client_for_source", + lambda source: {"itunes": itunes, "spotify": spotify}.get(source), + ) + + image_url = metadata_service.get_artist_image_url("12345", source_override="itunes") + + assert image_url == "https://itunes.example/artist.jpg" + assert itunes.calls == ["12345"] + assert itunes.album_art_calls == ["12345"] + assert spotify.calls == [] + + +def test_get_artist_image_url_handles_hydrabase_plugin(monkeypatch): + deezer = _FakeDeezerClient("https://deezer.example/hydra.jpg") + spotify = _FakeSpotifyClient() + + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "spotify") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "deezer"]) + monkeypatch.setattr( + metadata_service, + "get_client_for_source", + lambda source: {"deezer": deezer, "spotify": spotify}.get(source), + ) + + image_url = metadata_service.get_artist_image_url("artist-1", source_override="hydrabase", plugin="deezer") + + assert image_url == "https://deezer.example/hydra.jpg" + assert deezer.calls == ["artist-1"] + assert spotify.calls == [] diff --git a/web_server.py b/web_server.py index d7f58b42..b9c69e4d 100644 --- a/web_server.py +++ b/web_server.py @@ -11067,57 +11067,18 @@ def get_similar_artists(artist_name): @app.route('/api/artist//image', methods=['GET']) def get_artist_image(artist_id): - """Get artist image URL - used for lazy loading in search results. - - For iTunes, this fetches the artist's first album artwork as a fallback. - For Spotify, returns the artist's image directly. - """ + """Get an artist image URL using source-aware metadata resolution.""" try: - # Soul IDs from Hydrabase can't be looked up on Spotify/iTunes - if artist_id.startswith('soul_'): - return jsonify({"success": True, "image_url": None}) + from core.metadata_service import get_artist_image_url as _get_artist_image_url - # Source override from multi-source search tabs - source_override = request.args.get('source', '') - - if source_override == 'itunes': - client = _get_itunes_client() - image_url = client._get_artist_image_from_albums(artist_id) - return jsonify({"success": True, "image_url": image_url}) - elif source_override == 'deezer': - client = _get_deezer_client() - image_url = client._get_artist_image_from_albums(artist_id) - return jsonify({"success": True, "image_url": image_url}) - elif source_override == 'discogs': - client = _get_discogs_client() - image_url = client._get_artist_image_from_albums(artist_id) - return jsonify({"success": True, "image_url": image_url}) - elif source_override == 'hydrabase': - # Route to the plugin that sourced the data - plugin = request.args.get('plugin', '').lower() - if plugin == 'deezer': - client = _get_deezer_client() - image_url = client._get_artist_image_from_albums(artist_id) - elif plugin == 'itunes' or artist_id.isdigit(): - client = _get_itunes_client() - image_url = client._get_artist_image_from_albums(artist_id) - else: - image_url = None - return jsonify({"success": True, "image_url": image_url}) - elif spotify_client and spotify_client.is_spotify_authenticated() and source_override != 'itunes': - # Use Spotify directly - from core.api_call_tracker import api_call_tracker - api_call_tracker.record_call('spotify', endpoint='artist') - artist_data = spotify_client.sp.artist(artist_id) - if artist_data and artist_data.get('images'): - image_url = artist_data['images'][0]['url'] if artist_data['images'] else None - return jsonify({"success": True, "image_url": image_url}) - return jsonify({"success": True, "image_url": None}) - else: - # Use fallback source - fetch album art - fallback = _get_metadata_fallback_client() - image_url = fallback._get_artist_image_from_albums(artist_id) - return jsonify({"success": True, "image_url": image_url}) + source_override = request.args.get('source', '').strip().lower() or None + plugin = request.args.get('plugin', '').strip().lower() or None + image_url = _get_artist_image_url( + artist_id, + source_override=source_override, + plugin=plugin, + ) + return jsonify({"success": True, "image_url": image_url}) except Exception as e: logger.error(f"Error fetching artist image: {e}") return jsonify({"success": False, "image_url": None, "error": str(e)}) diff --git a/webui/static/script.js b/webui/static/script.js index 4fe98c47..5b997db3 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -36306,10 +36306,20 @@ async function lazyLoadSimilarArtistImages(container) { await Promise.all(batch.map(async (bubble) => { const artistId = bubble.getAttribute('data-artist-id'); + const artistSource = bubble.getAttribute('data-artist-source') || ''; + const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; if (!artistId) return; try { - const response = await fetch(`/api/artist/${artistId}/image`); + const params = new URLSearchParams(); + if (artistSource) params.set('source', artistSource); + if (artistPlugin) params.set('plugin', artistPlugin); + + const imageUrl = params.toString() + ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` + : `/api/artist/${encodeURIComponent(artistId)}/image`; + + const response = await fetch(imageUrl); const data = await response.json(); if (data.success && data.image_url) { @@ -36390,6 +36400,10 @@ function createSimilarArtistBubble(artist) { const bubble = document.createElement('div'); bubble.className = 'similar-artist-bubble'; bubble.setAttribute('data-artist-id', artist.id); + bubble.setAttribute('data-artist-source', artist.source || ''); + if (artist.plugin) { + bubble.setAttribute('data-artist-plugin', artist.plugin); + } // Track if image needs lazy loading const hasImage = artist.image_url && artist.image_url.trim() !== ''; @@ -36441,7 +36455,10 @@ function createSimilarArtistBubble(artist) { bubble.addEventListener('click', () => { console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); // Navigate to this artist's detail page (same as clicking from search results) - selectArtistForDetail(artist); + selectArtistForDetail( + artist, + artist.source ? { source: artist.source, plugin: artist.plugin } : {} + ); }); return bubble;