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 import threading
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Dict, Any, Literal from typing import List, Optional, Dict, Any, Literal
import requests
from core.spotify_client import SpotifyClient from core.spotify_client import SpotifyClient
from core.itunes_client import iTunesClient from core.itunes_client import iTunesClient
from utils.logging_config import get_logger 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(): def get_deezer_client():
"""Get cached 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') @app.route('/api/artist/similar/<path:artist_name>/stream')
def get_similar_artists_stream(artist_name): def get_similar_artists_stream(artist_name):
""" """Stream MusicMap similar artists using source-priority metadata matching."""
Stream similar artists from MusicMap and match them to Spotify one by one 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(): def generate():
try: logger.info(f"Streaming similar artists for: {artist_name}")
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"
# Import required libraries if event.get('artist'):
from bs4 import BeautifulSoup time.sleep(0.1)
# 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"
return Response(generate(), mimetype='text/event-stream') return Response(generate(), mimetype='text/event-stream')
@app.route('/api/artist/similar/<path:artist_name>') @app.route('/api/artist/similar/<path:artist_name>')
def get_similar_artists(artist_name): def get_similar_artists(artist_name):
""" """Get MusicMap similar artists using source-priority metadata matching."""
Get similar artists from MusicMap and match them to Spotify (legacy batch endpoint) 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: try:
logger.info(f"Getting similar artists for: {artist_name}") logger.info(f"Getting similar artists for: {artist_name}")
result = get_musicmap_similar_artists(artist_name, limit=20)
# Import required libraries if not result.get('success'):
from bs4 import BeautifulSoup error = result.get('error', 'Failed to fetch similar artists')
status_code = int(result.get('status_code') or 500)
# 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:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": "Could not find artist map on MusicMap" "error": error
}), 404 }), status_code
# 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")
return jsonify({ return jsonify({
"success": True, "success": True,
"artist": artist_name, "artist": artist_name,
"similar_artists": matched_artists, "similar_artists": result.get('similar_artists', []),
"total_found": len(similar_artist_names), "total_found": result.get('total_found', 0),
"total_matched": len(matched_artists) "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: except Exception as e:
logger.error(f"Error getting similar artists: {e}") logger.error(f"Error getting similar artists: {e}")
import traceback import traceback