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:
parent
9863c947dc
commit
b022a90997
3 changed files with 583 additions and 272 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
232
tests/test_metadata_service_musicmap.py
Normal file
232
tests/test_metadata_service_musicmap.py
Normal 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"] == []
|
||||||
291
web_server.py
291
web_server.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue