From b022a909972910e8854e803c899e8fa5dfc37208 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 21 Apr 2026 20:27:59 +0300 Subject: [PATCH 1/2] Move MusicMap similar artist matching into metadata service - Relocate the streamed MusicMap similar-artist flow out of web_server.py and into core.metadata_service. - Match similar artists through the configured source-priority chain instead of assuming Spotify first. - Add iTunes artwork fallback so streamed artist payloads still carry image_url when search results are sparse. - Cover the new service behavior with tests. --- core/metadata_service.py | 332 ++++++++++++++++++++++++ tests/test_metadata_service_musicmap.py | 232 +++++++++++++++++ web_server.py | 291 ++------------------- 3 files changed, 583 insertions(+), 272 deletions(-) create mode 100644 tests/test_metadata_service_musicmap.py diff --git a/core/metadata_service.py b/core/metadata_service.py index efe4c982..1a2f6b3b 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -10,6 +10,7 @@ auth checks, or source-fallback behavior. import threading from dataclasses import dataclass from typing import List, Optional, Dict, Any, Literal +import requests from core.spotify_client import SpotifyClient from core.itunes_client import iTunesClient from utils.logging_config import get_logger @@ -1415,6 +1416,337 @@ def check_artist_discography_completion( } +def _fetch_musicmap_similar_artist_names(artist_name: str) -> List[str]: + """Fetch similar artist names from MusicMap.""" + if not (artist_name or '').strip(): + raise ValueError('Artist name is required') + + from bs4 import BeautifulSoup + from urllib.parse import quote_plus + + url_artist = quote_plus(artist_name.strip()) + musicmap_url = f'https://www.music-map.com/{url_artist}' + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + } + + logger.debug("Fetching MusicMap: %s", musicmap_url) + response = requests.get(musicmap_url, headers=headers, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'html.parser') + gnod_map = soup.find(id='gnodMap') + if not gnod_map: + raise ValueError('Could not find artist map on MusicMap') + + searched_artist_lower = _normalize_artist_name(artist_name) + similar_artist_names: List[str] = [] + seen_names = set() + + for anchor in gnod_map.find_all('a'): + artist_text = anchor.get_text(strip=True) + normalized_name = _normalize_artist_name(artist_text) + if not normalized_name or normalized_name == searched_artist_lower or normalized_name in seen_names: + continue + seen_names.add(normalized_name) + similar_artist_names.append(artist_text) + + logger.debug("Found %s similar artists from MusicMap", len(similar_artist_names)) + return similar_artist_names + + +def _extract_artist_image_url(artist_data: Any) -> Optional[str]: + if not artist_data: + return None + + images = _extract_lookup_value(artist_data, 'images', default=[]) or [] + if not isinstance(images, list): + try: + images = list(images) + except TypeError: + images = [] + + if images: + first_image = images[0] + image_url = _extract_lookup_value(first_image, 'url') + if image_url: + return image_url + + return _extract_lookup_value( + artist_data, + 'image_url', + 'thumb_url', + 'cover_image', + 'picture_xl', + 'picture_big', + 'picture_medium', + ) + + +def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dict[str, Any]]: + artist_id = _extract_lookup_value(artist_data, 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id') + if not artist_id: + return None + + if isinstance(artist_data, dict): + name = artist_data.get('name') or artist_data.get('artist_name') or artist_data.get('title') + genres = artist_data.get('genres') or [] + popularity = artist_data.get('popularity') or artist_data.get('rank') or 0 + else: + name = ( + getattr(artist_data, 'name', None) + or getattr(artist_data, 'artist_name', None) + or getattr(artist_data, 'title', None) + ) + genres = getattr(artist_data, 'genres', None) or [] + popularity = getattr(artist_data, 'popularity', None) or getattr(artist_data, 'rank', None) or 0 + + if isinstance(genres, str): + genres = [genres] + elif not isinstance(genres, list): + try: + genres = list(genres) + except TypeError: + genres = [] + + try: + popularity = int(popularity or 0) + except Exception: + popularity = 0 + + return { + 'id': str(artist_id), + 'name': str(name or artist_id), + 'image_url': _extract_artist_image_url(artist_data), + 'genres': genres, + 'popularity': popularity, + 'source': source, + } + + +def _resolve_musicmap_artist_source_ids(artist_name: str, source_chain: List[str]) -> Dict[str, Optional[str]]: + searched_source_ids: Dict[str, Optional[str]] = {} + + for source in source_chain: + client = get_client_for_source(source) + if not client: + searched_source_ids[source] = None + continue + + search_results = _search_artists_for_source(source, client, artist_name, limit=1) + searched_source_ids[source] = _extract_lookup_value(search_results[0], 'id', 'artist_id') if search_results else None + + return searched_source_ids + + +def _match_musicmap_similar_artist( + candidate_name: str, + source_chain: List[str], + searched_artist_name: str, + searched_source_ids: Dict[str, Optional[str]], +) -> tuple[Optional[str], Optional[Dict[str, Any]]]: + target_name = _normalize_artist_name(candidate_name) + searched_name = _normalize_artist_name(searched_artist_name) + + for source in source_chain: + client = get_client_for_source(source) + if not client: + continue + + search_results = _search_artists_for_source(source, client, candidate_name, limit=1) + if not search_results: + continue + + matched_artist = _pick_best_artist_match(search_results, candidate_name) + if not matched_artist: + continue + + matched_name = _normalize_artist_name( + _extract_lookup_value(matched_artist, 'name', 'artist_name', 'title') + ) + if matched_name and matched_name == searched_name: + continue + + matched_id = _extract_lookup_value(matched_artist, 'id', 'artist_id') + if not matched_id: + continue + + if str(matched_id) == str(searched_source_ids.get(source) or ''): + continue + + payload = _build_similar_artist_payload(matched_artist, source) + if not payload: + continue + + if source == 'itunes' and not payload.get('image_url') and hasattr(client, 'get_artist'): + try: + full_artist = client.get_artist(str(matched_id)) + image_url = _extract_artist_image_url(full_artist) + if image_url: + payload['image_url'] = image_url + elif hasattr(client, '_get_artist_image_from_albums'): + album_image_url = client._get_artist_image_from_albums(str(matched_id)) + if album_image_url: + payload['image_url'] = album_image_url + except Exception as exc: + logger.debug("Could not enrich iTunes image for %s: %s", matched_id, exc) + + if target_name and _normalize_artist_name(payload['name']) == searched_name: + continue + + return source, payload + + return None, None + + +def iter_musicmap_similar_artist_events( + artist_name: str, + limit: int = 20, + source_override: Optional[str] = None, +): + """Yield MusicMap similar-artist events using source priority.""" + try: + source_chain = _get_source_chain_for_lookup( + MetadataLookupOptions(source_override=source_override, allow_fallback=True) + ) + available_sources = [source for source in source_chain if get_client_for_source(source)] + if not available_sources: + yield { + 'type': 'error', + 'error': 'No metadata providers available for similar artist matching', + 'status_code': 503, + } + return + + similar_artist_names = _fetch_musicmap_similar_artist_names(artist_name) + searched_source_ids = _resolve_musicmap_artist_source_ids(artist_name, source_chain) + + yield { + 'type': 'start', + 'artist_name': artist_name, + 'total_found': len(similar_artist_names), + 'source_priority': source_chain, + } + + matched_count = 0 + seen_names = set() + seen_ids = set() + + for candidate_name in similar_artist_names[:limit]: + normalized_candidate = _normalize_artist_name(candidate_name) + if not normalized_candidate or normalized_candidate in seen_names: + continue + + source, payload = _match_musicmap_similar_artist( + candidate_name, + source_chain, + artist_name, + searched_source_ids, + ) + if not payload: + continue + + payload_id = str(payload.get('id') or '') + if payload_id in seen_ids: + continue + + seen_names.add(normalized_candidate) + seen_ids.add(payload_id) + matched_count += 1 + + yield { + 'type': 'artist', + 'artist': payload, + 'source': source, + } + + yield { + 'type': 'complete', + 'complete': True, + 'total': matched_count, + 'total_found': len(similar_artist_names), + 'artist_name': artist_name, + 'source_priority': source_chain, + } + + except requests.exceptions.RequestException as exc: + logger.debug("Error fetching MusicMap for %s: %s", artist_name, exc) + yield { + 'type': 'error', + 'error': f'Failed to fetch from MusicMap: {exc}', + 'status_code': 502, + } + except ValueError as exc: + status_code = 404 if 'Could not find artist map on MusicMap' in str(exc) else 400 + yield { + 'type': 'error', + 'error': str(exc), + 'status_code': status_code, + } + except Exception as exc: + logger.error("Error streaming similar artists for %s: %s", artist_name, exc) + yield { + 'type': 'error', + 'error': str(exc), + 'status_code': 500, + } + + +def get_musicmap_similar_artists( + artist_name: str, + limit: int = 20, + source_override: Optional[str] = None, +) -> Dict[str, Any]: + """Return matched MusicMap similar artists as a single payload.""" + artists: List[Dict[str, Any]] = [] + total_found = 0 + error_message = None + status_code = 500 + source_priority: List[str] = [] + + for event in iter_musicmap_similar_artist_events( + artist_name, + limit=limit, + source_override=source_override, + ): + if event.get('type') == 'start': + total_found = event.get('total_found', 0) + source_priority = event.get('source_priority', []) + elif event.get('type') == 'artist' and event.get('artist'): + artists.append(event['artist']) + elif event.get('type') == 'complete': + total_found = event.get('total_found', total_found) + source_priority = event.get('source_priority', source_priority) + elif event.get('type') == 'error': + error_message = event.get('error', 'Unknown error') + status_code = int(event.get('status_code') or status_code or 500) + break + + if error_message: + return { + 'success': False, + 'error': error_message, + 'status_code': status_code, + 'artist': artist_name, + 'similar_artists': [], + 'total_found': total_found, + 'total_matched': 0, + 'source_priority': source_priority, + } + + return { + 'success': True, + 'artist': artist_name, + 'similar_artists': artists, + 'total_found': total_found, + 'total_matched': len(artists), + 'source_priority': source_priority, + } + + def get_deezer_client(): """Get cached Deezer client. diff --git a/tests/test_metadata_service_musicmap.py b/tests/test_metadata_service_musicmap.py new file mode 100644 index 00000000..4f0db0f6 --- /dev/null +++ b/tests/test_metadata_service_musicmap.py @@ -0,0 +1,232 @@ +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 + +import types as pytypes + +from core import metadata_service + + +class _FakeMusicMapResponse: + def __init__(self, text): + self.text = text + + def raise_for_status(self): + return None + + +class _FakeSourceClient: + def __init__(self, results_by_query): + self.results_by_query = results_by_query + self.search_calls = [] + + def search_artists(self, query, **kwargs): + self.search_calls.append((query, dict(kwargs))) + return list(self.results_by_query.get(query, [])) + + +def test_iter_musicmap_similar_artist_events_uses_source_priority(monkeypatch): + html = """ + + +
+ Artist One + Similar Artist +
+ + + """ + + deezer = _FakeSourceClient({ + "Artist One": [pytypes.SimpleNamespace(id="dz-seed", name="Artist One")], + "Similar Artist": [], + }) + itunes = _FakeSourceClient({ + "Artist One": [pytypes.SimpleNamespace(id="it-seed", name="Artist One")], + "Similar Artist": [ + pytypes.SimpleNamespace( + id="it-match", + name="iTunes Canonical", + image_url="https://itunes.example/it-match.jpg", + genres=["indie", "alt"], + popularity=77, + ) + ], + }) + spotify = _FakeSourceClient({}) + + monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html)) + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "itunes", "spotify"]) + monkeypatch.setattr( + metadata_service, + "get_client_for_source", + lambda source: {"deezer": deezer, "itunes": itunes, "spotify": spotify}.get(source), + ) + + events = list(metadata_service.iter_musicmap_similar_artist_events("Artist One", limit=5)) + + assert events[0]["type"] == "start" + assert events[0]["source_priority"] == ["deezer", "itunes", "spotify"] + assert events[1]["type"] == "artist" + assert events[1]["artist"]["id"] == "it-match" + assert events[1]["artist"]["name"] == "iTunes Canonical" + assert events[1]["artist"]["image_url"] == "https://itunes.example/it-match.jpg" + assert events[1]["artist"]["genres"] == ["indie", "alt"] + assert events[1]["artist"]["popularity"] == 77 + assert events[1]["artist"]["source"] == "itunes" + assert events[-1]["type"] == "complete" + assert events[-1]["complete"] is True + assert events[-1]["total"] == 1 + assert events[-1]["total_found"] == 1 + + assert [call[0] for call in deezer.search_calls] == ["Artist One", "Similar Artist"] + assert [call[0] for call in itunes.search_calls] == ["Artist One", "Similar Artist"] + assert spotify.search_calls == [("Artist One", {"limit": 1, "allow_fallback": False})] + + +def test_iter_musicmap_similar_artist_events_enriches_itunes_images(monkeypatch): + html = """ + + +
+ Similar Artist +
+ + + """ + + class _ItunesClient(_FakeSourceClient): + def __init__(self): + super().__init__({ + "Artist One": [pytypes.SimpleNamespace(id="it-seed", name="Artist One")], + "Similar Artist": [pytypes.SimpleNamespace(id="it-match", name="iTunes Canonical")], + }) + self.get_artist_calls = [] + + def get_artist(self, artist_id): + self.get_artist_calls.append(artist_id) + return { + "id": artist_id, + "name": "iTunes Canonical", + "images": [{"url": "https://itunes.example/full-art.jpg"}], + "genres": ["indie"], + "popularity": 0, + } + + itunes = _ItunesClient() + monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html)) + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "itunes") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary]) + monkeypatch.setattr(metadata_service, "get_client_for_source", lambda source: itunes if source == "itunes" else None) + + events = list(metadata_service.iter_musicmap_similar_artist_events("Artist One", limit=5)) + + artist_events = [event for event in events if event.get("type") == "artist"] + assert len(artist_events) == 1 + assert artist_events[0]["artist"]["image_url"] == "https://itunes.example/full-art.jpg" + assert itunes.get_artist_calls == ["it-match"] + + +def test_iter_musicmap_similar_artist_events_falls_back_to_itunes_album_art(monkeypatch): + html = """ + + +
+ Similar Artist +
+ + + """ + + class _ItunesClient(_FakeSourceClient): + def __init__(self): + super().__init__({ + "Artist One": [pytypes.SimpleNamespace(id="it-seed", name="Artist One")], + "Similar Artist": [pytypes.SimpleNamespace(id="it-match", name="iTunes Canonical")], + }) + self.get_artist_calls = [] + self.album_art_calls = [] + + def get_artist(self, artist_id): + self.get_artist_calls.append(artist_id) + return { + "id": artist_id, + "name": "iTunes Canonical", + "images": [], + "genres": ["indie"], + "popularity": 0, + } + + def _get_artist_image_from_albums(self, artist_id): + self.album_art_calls.append(artist_id) + return "https://itunes.example/album-art.jpg" + + itunes = _ItunesClient() + monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html)) + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "itunes") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary]) + monkeypatch.setattr(metadata_service, "get_client_for_source", lambda source: itunes if source == "itunes" else None) + + events = list(metadata_service.iter_musicmap_similar_artist_events("Artist One", limit=5)) + + artist_events = [event for event in events if event.get("type") == "artist"] + assert len(artist_events) == 1 + assert artist_events[0]["artist"]["image_url"] == "https://itunes.example/album-art.jpg" + assert itunes.get_artist_calls == ["it-match"] + assert itunes.album_art_calls == ["it-match"] + + +def test_get_musicmap_similar_artists_returns_not_found_when_musicmap_missing(monkeypatch): + html = """ + + +
Nothing here
+ + + """ + + monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html)) + monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer") + monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "itunes"]) + monkeypatch.setattr(metadata_service, "get_client_for_source", lambda source: object()) + + result = metadata_service.get_musicmap_similar_artists("Artist One", limit=5) + + assert result["success"] is False + assert result["status_code"] == 404 + assert "Could not find artist map" in result["error"] + assert result["similar_artists"] == [] diff --git a/web_server.py b/web_server.py index bbc600f5..d7f58b42 100644 --- a/web_server.py +++ b/web_server.py @@ -11019,296 +11019,43 @@ def debug_library_photos(): @app.route('/api/artist/similar//stream') def get_similar_artists_stream(artist_name): - """ - Stream similar artists from MusicMap and match them to Spotify one by one + """Stream MusicMap similar artists using source-priority metadata matching.""" + from core.metadata_service import iter_musicmap_similar_artist_events - Args: - artist_name: The artist name to find similar artists for - - Returns: - Server-Sent Events stream with each matched artist - """ def generate(): - try: - logger.info(f"Streaming similar artists for: {artist_name}") - - # Import required libraries - from bs4 import BeautifulSoup - - # Construct MusicMap URL - url_artist = artist_name.lower().replace(' ', '+') - musicmap_url = f'https://www.music-map.com/{url_artist}' - - logger.debug(f"Fetching MusicMap: {musicmap_url}") - - # Set headers to mimic a browser - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - } - - # Fetch MusicMap page - response = requests.get(musicmap_url, headers=headers, timeout=10) - response.raise_for_status() - - # Parse HTML - soup = BeautifulSoup(response.text, 'html.parser') - gnod_map = soup.find(id='gnodMap') - - if not gnod_map: - yield f"data: {json.dumps({'error': 'Could not find artist map on MusicMap'})}\n\n" - return - - # Extract similar artist names - all_anchors = gnod_map.find_all('a') - searched_artist_lower = artist_name.lower().strip() - - similar_artist_names = [] - for anchor in all_anchors: - artist_text = anchor.get_text(strip=True) - - # Skip if this is the searched artist - if artist_text.lower() == searched_artist_lower: - continue - - similar_artist_names.append(artist_text) - - logger.debug(f"Found {len(similar_artist_names)} similar artists from MusicMap") - - # Determine metadata source - use_hydrabase = _is_hydrabase_active() - - if not use_hydrabase: - if not spotify_client or not spotify_client.is_authenticated(): - yield f"data: {json.dumps({'error': 'Spotify not authenticated'})}\n\n" - return - - # Get the searched artist's ID to exclude them - searched_artist_id = None - try: - if use_hydrabase: - searched_results = hydrabase_client.search_artists(artist_name, limit=1) - else: - searched_results = spotify_client.search_artists(artist_name, limit=1) - if searched_results and len(searched_results) > 0: - searched_artist_id = searched_results[0].id - logger.info(f"Searched artist ID: {searched_artist_id}") - except Exception as e: - logger.error(f"Could not get searched artist ID: {e}") - - # Match each artist one by one and stream results - max_artists = 20 - matched_count = 0 - seen_artist_ids = set() # Track seen artist IDs to prevent duplicates - - for artist_name_to_match in similar_artist_names[:max_artists]: - try: - logger.info(f"Matching: {artist_name_to_match}") - - # Search for the artist via active metadata source - if use_hydrabase: - results = hydrabase_client.search_artists(artist_name_to_match, limit=1) - else: - results = spotify_client.search_artists(artist_name_to_match, limit=1) - - if results and len(results) > 0: - spotify_artist = results[0] - - # Skip if this is the searched artist - if spotify_artist.id == searched_artist_id: - logger.info(f"Skipping searched artist: {spotify_artist.name}") - continue - - # Skip if we've already seen this artist ID (deduplication) - if spotify_artist.id in seen_artist_ids: - logger.warning(f"Skipping duplicate artist: {spotify_artist.name}") - continue - - seen_artist_ids.add(spotify_artist.id) - - artist_data = { - 'id': spotify_artist.id, - 'name': spotify_artist.name, - 'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None, - 'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [], - 'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 - } - - # Stream this matched artist immediately - yield f"data: {json.dumps({'artist': artist_data})}\n\n" - matched_count += 1 - - logger.info(f"Matched and streamed: {spotify_artist.name}") - else: - logger.warning(f"No Spotify match found for: {artist_name_to_match}") - - except Exception as match_error: - logger.error(f"Error matching {artist_name_to_match}: {match_error}") - continue - - # Send completion message - yield f"data: {json.dumps({'complete': True, 'total': matched_count})}\n\n" - logger.info(f"Streaming complete: {matched_count} artists matched") - - except requests.exceptions.RequestException as e: - logger.debug(f"Error fetching MusicMap: {e}") - yield f"data: {json.dumps({'error': f'Failed to fetch from MusicMap: {str(e)}'})}\n\n" - - except Exception as e: - logger.error(f"Error streaming similar artists: {e}") - import traceback - traceback.print_exc() - yield f"data: {json.dumps({'error': str(e)})}\n\n" + logger.info(f"Streaming similar artists for: {artist_name}") + for event in iter_musicmap_similar_artist_events(artist_name, limit=20): + yield f"data: {json.dumps(event)}\n\n" + if event.get('artist'): + time.sleep(0.1) return Response(generate(), mimetype='text/event-stream') @app.route('/api/artist/similar/') def get_similar_artists(artist_name): - """ - Get similar artists from MusicMap and match them to Spotify (legacy batch endpoint) + """Get MusicMap similar artists using source-priority metadata matching.""" + from core.metadata_service import get_musicmap_similar_artists - Args: - artist_name: The artist name to find similar artists for - - Returns: - JSON with similar artists matched to Spotify data - """ try: logger.info(f"Getting similar artists for: {artist_name}") - - # Import required libraries - from bs4 import BeautifulSoup - - # Construct MusicMap URL - url_artist = artist_name.lower().replace(' ', '+') - musicmap_url = f'https://www.music-map.com/{url_artist}' - - logger.debug(f"Fetching MusicMap: {musicmap_url}") - - # Set headers to mimic a browser - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - } - - # Fetch MusicMap page - response = requests.get(musicmap_url, headers=headers, timeout=10) - response.raise_for_status() - - # Parse HTML - soup = BeautifulSoup(response.text, 'html.parser') - gnod_map = soup.find(id='gnodMap') - - if not gnod_map: + result = get_musicmap_similar_artists(artist_name, limit=20) + if not result.get('success'): + error = result.get('error', 'Failed to fetch similar artists') + status_code = int(result.get('status_code') or 500) return jsonify({ "success": False, - "error": "Could not find artist map on MusicMap" - }), 404 - - # Extract similar artist names - all_anchors = gnod_map.find_all('a') - searched_artist_lower = artist_name.lower().strip() - - similar_artist_names = [] - for anchor in all_anchors: - artist_text = anchor.get_text(strip=True) - - # Skip if this is the searched artist - if artist_text.lower() == searched_artist_lower: - continue - - similar_artist_names.append(artist_text) - - logger.debug(f"Found {len(similar_artist_names)} similar artists from MusicMap") - - # Determine metadata source - use_hydrabase = _is_hydrabase_active() - - if not use_hydrabase: - if not spotify_client or not spotify_client.is_authenticated(): - return jsonify({ - "success": False, - "error": "Spotify not authenticated" - }), 401 - - # Get the searched artist's ID to exclude them - searched_artist_id = None - try: - if use_hydrabase: - searched_results = hydrabase_client.search_artists(artist_name, limit=1) - else: - searched_results = spotify_client.search_artists(artist_name, limit=1) - if searched_results and len(searched_results) > 0: - searched_artist_id = searched_results[0].id - logger.info(f"Searched artist ID: {searched_artist_id}") - except Exception as e: - logger.error(f"Could not get searched artist ID: {e}") - - # Match each artist (limit to first 20 for performance) - matched_artists = [] - max_artists = 20 - seen_artist_ids = set() # Track seen artist IDs to prevent duplicates - - for artist_name_to_match in similar_artist_names[:max_artists]: - try: - logger.info(f"Matching: {artist_name_to_match}") - - # Search for the artist via active metadata source - if use_hydrabase: - results = hydrabase_client.search_artists(artist_name_to_match, limit=1) - else: - results = spotify_client.search_artists(artist_name_to_match, limit=1) - - if results and len(results) > 0: - spotify_artist = results[0] - - # Skip if this is the searched artist - if spotify_artist.id == searched_artist_id: - logger.info(f"Skipping searched artist: {spotify_artist.name}") - continue - - # Skip if we've already seen this artist ID (deduplication) - if spotify_artist.id in seen_artist_ids: - logger.warning(f"Skipping duplicate artist: {spotify_artist.name}") - continue - - seen_artist_ids.add(spotify_artist.id) - - matched_artists.append({ - 'id': spotify_artist.id, - 'name': spotify_artist.name, - 'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None, - 'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [], - 'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 - }) - - logger.info(f"Matched: {spotify_artist.name}") - else: - logger.warning(f"No Spotify match found for: {artist_name_to_match}") - - except Exception as match_error: - logger.error(f"Error matching {artist_name_to_match}: {match_error}") - continue - - logger.info(f"Successfully matched {len(matched_artists)} artists to Spotify") + "error": error + }), status_code return jsonify({ "success": True, "artist": artist_name, - "similar_artists": matched_artists, - "total_found": len(similar_artist_names), - "total_matched": len(matched_artists) + "similar_artists": result.get('similar_artists', []), + "total_found": result.get('total_found', 0), + "total_matched": result.get('total_matched', 0), + "source_priority": result.get('source_priority', []), }) - except requests.exceptions.RequestException as e: - logger.debug(f"Error fetching MusicMap: {e}") - return jsonify({ - "success": False, - "error": f"Failed to fetch from MusicMap: {str(e)}" - }), 500 - except Exception as e: logger.error(f"Error getting similar artists: {e}") import traceback From 6f79214439ff2f75dbce2db6aeec2fae2b59f9bf Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 21 Apr 2026 20:57:50 +0300 Subject: [PATCH 2/2] Route artist image lookup through metadata service - Move /api/artist//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. --- core/metadata_service.py | 60 ++++++++ tests/test_metadata_service_artist_image.py | 152 ++++++++++++++++++++ web_server.py | 59 ++------ webui/static/script.js | 21 ++- 4 files changed, 241 insertions(+), 51 deletions(-) create mode 100644 tests/test_metadata_service_artist_image.py 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;