From b022a909972910e8854e803c899e8fa5dfc37208 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 21 Apr 2026 20:27:59 +0300 Subject: [PATCH] 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