Route artist image lookup through metadata service

- Move /api/artist/<artist_id>/image resolution into core.metadata_service.
- Resolve artist artwork through source priority, with explicit source/plugin overrides preserved.
- Keep Spotify call tracking inside the client layer to avoid double counting.
- Update similar-artist lazy loading to pass source context and add service coverage.
This commit is contained in:
Antti Kettunen 2026-04-21 20:57:50 +03:00
parent b022a90997
commit 6f79214439
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
4 changed files with 241 additions and 51 deletions

View file

@ -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.

View file

@ -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 == []

View file

@ -11067,57 +11067,18 @@ def get_similar_artists(artist_name):
@app.route('/api/artist/<artist_id>/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)})

View file

@ -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;