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.
This commit is contained in:
Antti Kettunen 2026-04-21 20:27:59 +03:00
parent 9863c947dc
commit b022a90997
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
3 changed files with 583 additions and 272 deletions

View file

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

View file

@ -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 = """
<html>
<body>
<div id="gnodMap">
<a href="/artist/seed">Artist One</a>
<a href="/artist/similar">Similar Artist</a>
</div>
</body>
</html>
"""
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 = """
<html>
<body>
<div id="gnodMap">
<a href="/artist/similar">Similar Artist</a>
</div>
</body>
</html>
"""
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 = """
<html>
<body>
<div id="gnodMap">
<a href="/artist/similar">Similar Artist</a>
</div>
</body>
</html>
"""
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 = """
<html>
<body>
<div class="no-map">Nothing here</div>
</body>
</html>
"""
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"] == []

View file

@ -11019,296 +11019,43 @@ def debug_library_photos():
@app.route('/api/artist/similar/<path:artist_name>/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/<path:artist_name>')
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