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:
parent
b022a90997
commit
6f79214439
4 changed files with 241 additions and 51 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
152
tests/test_metadata_service_artist_image.py
Normal file
152
tests/test_metadata_service_artist_image.py
Normal 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 == []
|
||||
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue