Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support. Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings. Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
994 lines
47 KiB
Python
994 lines
47 KiB
Python
"""Artist Map endpoints — lifted from web_server.py.
|
|
|
|
The four route bodies (``get_artist_map_data``, ``get_artist_map_genre_list``,
|
|
``get_artist_map_genres``, ``get_artist_map_explore``) plus their cache helpers
|
|
and the artist-map cache are byte-identical to the originals. Module-level
|
|
shims for ``get_current_profile_id``, ``_get_itunes_client``, and the
|
|
``spotify_client`` proxy let the bodies resolve their original names without
|
|
modification.
|
|
"""
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
from flask import g, jsonify, request
|
|
|
|
from database.music_database import get_database
|
|
from core.metadata.registry import get_itunes_client, get_spotify_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_current_profile_id() -> int:
|
|
"""Mirror of web_server.get_current_profile_id — uses Flask g.
|
|
|
|
Catches RuntimeError too because reading `g` outside a request
|
|
context raises that (not AttributeError) — happens when this is
|
|
called from background threads (sync, automation, scanners)."""
|
|
try:
|
|
return g.profile_id
|
|
except (AttributeError, RuntimeError):
|
|
return 1
|
|
|
|
|
|
def _get_itunes_client():
|
|
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
|
return get_itunes_client()
|
|
|
|
|
|
class _SpotifyClientProxy:
|
|
"""Resolves the global Spotify client lazily so a Spotify re-auth that
|
|
rebinds the cached client in core.metadata.registry is visible to the
|
|
lifted route bodies."""
|
|
|
|
def __getattr__(self, name):
|
|
client = get_spotify_client()
|
|
if client is None:
|
|
raise AttributeError(name)
|
|
return getattr(client, name)
|
|
|
|
def __bool__(self):
|
|
return get_spotify_client() is not None
|
|
|
|
|
|
spotify_client = _SpotifyClientProxy()
|
|
|
|
|
|
# Artist Map data cache — avoids re-querying 4+ tables on every request
|
|
# Keys: 'watchlist_{profile}', 'genres_{profile}', 'genre_list'
|
|
# Values: {'data': <json-ready dict>, 'ts': <timestamp>}
|
|
_artist_map_cache = {}
|
|
_ARTIST_MAP_CACHE_TTL = 300 # 5 minutes
|
|
|
|
|
|
def _artmap_cache_get(key):
|
|
"""Get cached artist map data if still fresh."""
|
|
entry = _artist_map_cache.get(key)
|
|
if entry and (time.time() - entry['ts']) < _ARTIST_MAP_CACHE_TTL:
|
|
return entry['data']
|
|
return None
|
|
|
|
|
|
def _artmap_cache_set(key, data):
|
|
"""Store artist map data in cache."""
|
|
_artist_map_cache[key] = {'data': data, 'ts': time.time()}
|
|
|
|
|
|
def _artmap_cache_invalidate(profile_id=None):
|
|
"""Invalidate artist map cache (call after watchlist changes, scans, etc.)."""
|
|
if profile_id:
|
|
_artist_map_cache.pop(f'watchlist_{profile_id}', None)
|
|
_artist_map_cache.pop(f'genres_{profile_id}', None)
|
|
_artist_map_cache.pop('genre_list', None)
|
|
|
|
|
|
def get_artist_map_data():
|
|
"""Get watchlist artists + their similar artists for the force-directed artist map."""
|
|
try:
|
|
database = get_database()
|
|
profile_id = get_current_profile_id()
|
|
|
|
cached = _artmap_cache_get(f'watchlist_{profile_id}')
|
|
if cached:
|
|
return jsonify(cached)
|
|
|
|
# Get all watchlist artists
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
|
|
discogs_artist_id, image_url
|
|
FROM watchlist_artists WHERE profile_id = ?
|
|
""", (profile_id,))
|
|
watchlist_rows = cursor.fetchall()
|
|
|
|
nodes = [] # {id, name, image_url, type: 'watchlist'|'similar', genres, size}
|
|
edges = [] # {source, target, weight}
|
|
seen_names = {} # normalized_name → node index
|
|
|
|
def _norm(name):
|
|
return (name or '').lower().strip()
|
|
|
|
# Add watchlist artists as anchor nodes
|
|
for wa in watchlist_rows:
|
|
w = dict(wa)
|
|
norm = _norm(w['artist_name'])
|
|
if norm in seen_names:
|
|
continue
|
|
idx = len(nodes)
|
|
seen_names[norm] = idx
|
|
# Get image — prefer HTTP URLs
|
|
img = w.get('image_url', '') or ''
|
|
if img and not img.startswith('http'):
|
|
img = ''
|
|
nodes.append({
|
|
'id': idx,
|
|
'name': w['artist_name'],
|
|
'image_url': img,
|
|
'type': 'watchlist',
|
|
'genres': [],
|
|
'spotify_id': w.get('spotify_artist_id') or '',
|
|
'itunes_id': w.get('itunes_artist_id') or '',
|
|
'deezer_id': w.get('deezer_artist_id') or '',
|
|
'discogs_id': w.get('discogs_artist_id') or '',
|
|
'source_db_id': str(w['id']),
|
|
})
|
|
|
|
# Get all similar artists for all watchlist artists
|
|
watchlist_ids = [dict(wa)['spotify_artist_id'] or dict(wa)['itunes_artist_id'] or str(dict(wa)['id']) for wa in watchlist_rows]
|
|
if watchlist_ids:
|
|
placeholders = ','.join(['?'] * len(watchlist_ids))
|
|
cursor.execute(f"""
|
|
SELECT source_artist_id, similar_artist_name, similar_artist_spotify_id,
|
|
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
similarity_rank, occurrence_count, image_url, genres, popularity
|
|
FROM similar_artists
|
|
WHERE profile_id = ? AND source_artist_id IN ({placeholders})
|
|
ORDER BY similarity_rank ASC
|
|
""", [profile_id] + watchlist_ids)
|
|
|
|
for row in cursor.fetchall():
|
|
r = dict(row)
|
|
sim_norm = _norm(r['similar_artist_name'])
|
|
|
|
# Find or create similar artist node
|
|
if sim_norm not in seen_names:
|
|
idx = len(nodes)
|
|
seen_names[sim_norm] = idx
|
|
img = r.get('image_url', '') or ''
|
|
if img and not img.startswith('http'):
|
|
img = ''
|
|
genres = []
|
|
if r.get('genres'):
|
|
try:
|
|
genres = json.loads(r['genres'])
|
|
except Exception as e:
|
|
logger.debug("similar node genres parse failed: %s", e)
|
|
nodes.append({
|
|
'id': idx,
|
|
'name': r['similar_artist_name'],
|
|
'image_url': img,
|
|
'type': 'similar',
|
|
'genres': genres,
|
|
'spotify_id': r.get('similar_artist_spotify_id') or '',
|
|
'itunes_id': r.get('similar_artist_itunes_id') or '',
|
|
'deezer_id': r.get('similar_artist_deezer_id') or '',
|
|
'musicbrainz_id': r.get('similar_artist_musicbrainz_id') or '',
|
|
'rank': r.get('similarity_rank', 5),
|
|
'occurrence': r.get('occurrence_count', 1),
|
|
'popularity': r.get('popularity', 0),
|
|
})
|
|
|
|
sim_idx = seen_names[sim_norm]
|
|
|
|
# Find the watchlist node that sourced this similar artist
|
|
source_norm = None
|
|
for wa in watchlist_rows:
|
|
w = dict(wa)
|
|
sid = w.get('spotify_artist_id') or w.get('itunes_artist_id') or str(w['id'])
|
|
if sid == r['source_artist_id']:
|
|
source_norm = _norm(w['artist_name'])
|
|
break
|
|
|
|
if source_norm and source_norm in seen_names:
|
|
source_idx = seen_names[source_norm]
|
|
# Weight: inverse of rank (rank 1 = strongest connection)
|
|
weight = max(1, 11 - (r.get('similarity_rank', 5)))
|
|
edges.append({
|
|
'source': source_idx,
|
|
'target': sim_idx,
|
|
'weight': weight,
|
|
})
|
|
|
|
# Also check if any similar artists ARE watchlist artists (cross-links)
|
|
# These create extra connections between watchlist nodes
|
|
for i, node in enumerate(nodes):
|
|
if node['type'] == 'similar':
|
|
# Check if this similar artist is also a watchlist artist
|
|
for j, wnode in enumerate(nodes):
|
|
if wnode['type'] == 'watchlist' and i != j:
|
|
if _norm(node['name']) == _norm(wnode['name']):
|
|
# Merge: upgrade the similar node to watchlist
|
|
node['type'] = 'watchlist'
|
|
break
|
|
|
|
# ── Backfill from metadata cache: batch-lookup all node names across all sources ──
|
|
# Single query to get ALL cached artist entries matching ANY node name
|
|
try:
|
|
all_names = list(set(_norm(n['name']) for n in nodes if n.get('name')))
|
|
if all_names:
|
|
# Build case-insensitive IN clause via temp matching
|
|
# Lightweight query — no raw_json (can be huge)
|
|
cursor.execute("""
|
|
SELECT entity_id, source, name, image_url, genres, popularity
|
|
FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist'
|
|
""")
|
|
cache_rows = cursor.fetchall()
|
|
|
|
# Index cache by normalized name → {source: {id, image_url, genres}}
|
|
cache_by_name = {}
|
|
for cr in cache_rows:
|
|
cn = _norm(cr['name'] or '')
|
|
if cn not in cache_by_name:
|
|
cache_by_name[cn] = {}
|
|
source = cr['source']
|
|
genres = []
|
|
if cr['genres']:
|
|
try:
|
|
genres = json.loads(cr['genres']) if isinstance(cr['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("backfill cache genres parse failed: %s", e)
|
|
cache_by_name[cn][source] = {
|
|
'id': cr['entity_id'],
|
|
'image_url': cr['image_url'] or '',
|
|
'genres': genres,
|
|
}
|
|
|
|
# Apply cache data to nodes
|
|
source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
for n in nodes:
|
|
nn = _norm(n['name'])
|
|
cached = cache_by_name.get(nn)
|
|
if not cached:
|
|
continue
|
|
for source, field in source_id_map.items():
|
|
if not n.get(field) and source in cached:
|
|
n[field] = cached[source]['id']
|
|
# Backfill image if missing or local path
|
|
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
|
for source in ('spotify', 'deezer', 'itunes'):
|
|
if source in cached and cached[source].get('image_url', '').startswith('http'):
|
|
n['image_url'] = cached[source]['image_url']
|
|
break
|
|
# Backfill genres if missing
|
|
if not n.get('genres') or len(n.get('genres', [])) == 0:
|
|
for source in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
|
|
if source in cached and cached[source].get('genres'):
|
|
n['genres'] = cached[source]['genres'][:5]
|
|
break
|
|
# Deezer direct URL fallback
|
|
for n in nodes:
|
|
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
|
if n.get('deezer_id'):
|
|
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
|
|
|
# Album art fallback (iTunes artists have no artist images)
|
|
_album_art = {}
|
|
try:
|
|
cursor.execute("""
|
|
SELECT artist_name, image_url FROM metadata_cache_entities
|
|
WHERE entity_type = 'album' AND image_url LIKE 'http%'
|
|
AND artist_name IS NOT NULL AND artist_name != ''
|
|
""")
|
|
for r in cursor.fetchall():
|
|
an = _norm(r['artist_name'])
|
|
if an and an not in _album_art:
|
|
_album_art[an] = r['image_url']
|
|
except Exception as e:
|
|
logger.debug("artist map album-art cache build failed: %s", e)
|
|
for n in nodes:
|
|
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
|
nn = _norm(n['name'])
|
|
if nn in _album_art:
|
|
n['image_url'] = _album_art[nn]
|
|
|
|
except Exception as cache_err:
|
|
logger.debug(f"Artist map cache backfill error: {cache_err}")
|
|
|
|
result = {
|
|
'success': True,
|
|
'nodes': nodes,
|
|
'edges': edges,
|
|
'watchlist_count': sum(1 for n in nodes if n['type'] == 'watchlist'),
|
|
'similar_count': sum(1 for n in nodes if n['type'] == 'similar'),
|
|
}
|
|
_artmap_cache_set(f'watchlist_{profile_id}', result)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist map data: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return jsonify({"success": False, "error": str(e)}), 500
|
|
|
|
|
|
def get_artist_map_genre_list():
|
|
"""Lightweight endpoint — just genre names + counts for the picker. No node data."""
|
|
try:
|
|
cached = _artmap_cache_get('genre_list')
|
|
if cached:
|
|
return jsonify(cached)
|
|
|
|
database = get_database()
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Fast query: just count artists per genre from cache
|
|
genre_counts = {}
|
|
cursor.execute("""
|
|
SELECT genres FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist' AND genres IS NOT NULL AND genres != '' AND genres != '[]'
|
|
""")
|
|
for r in cursor.fetchall():
|
|
try:
|
|
for g in json.loads(r['genres']):
|
|
if g and isinstance(g, str):
|
|
gl = g.lower().strip()
|
|
genre_counts[gl] = genre_counts.get(gl, 0) + 1
|
|
except Exception as e:
|
|
logger.debug("genre count row parse failed: %s", e)
|
|
|
|
# Sort by count descending
|
|
sorted_genres = sorted(genre_counts.items(), key=lambda x: -x[1])
|
|
|
|
result = {
|
|
'success': True,
|
|
'genres': [{'name': g, 'count': c} for g, c in sorted_genres],
|
|
'total': len(sorted_genres)
|
|
}
|
|
_artmap_cache_set('genre_list', result)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
return jsonify({"success": False, "error": str(e)}), 500
|
|
|
|
|
|
def get_artist_map_genres():
|
|
"""Get ALL artists from every data source, grouped by genre for the genre map."""
|
|
try:
|
|
database = get_database()
|
|
profile_id = get_current_profile_id()
|
|
|
|
cached = _artmap_cache_get(f'genres_{profile_id}')
|
|
if cached:
|
|
return jsonify(cached)
|
|
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
artists_by_name = {} # normalized_name → {name, image, genres[], sources, ids}
|
|
|
|
def _norm(n):
|
|
return (n or '').lower().strip()
|
|
|
|
def _add(name, image_url=None, genres=None, spotify_id=None, itunes_id=None, deezer_id=None, discogs_id=None, musicbrainz_id=None, source='unknown', popularity=0):
|
|
n = _norm(name)
|
|
if not n or len(n) < 2:
|
|
return
|
|
if n not in artists_by_name:
|
|
artists_by_name[n] = {
|
|
'name': name, 'image_url': '', 'genres': set(),
|
|
'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': '',
|
|
'sources': set(), 'popularity': 0
|
|
}
|
|
a = artists_by_name[n]
|
|
if image_url and image_url.startswith('http') and not a['image_url']:
|
|
a['image_url'] = image_url
|
|
if genres:
|
|
for g in (genres if isinstance(genres, list) else []):
|
|
if g and isinstance(g, str):
|
|
a['genres'].add(g.lower().strip())
|
|
if spotify_id and not a['spotify_id']:
|
|
a['spotify_id'] = str(spotify_id)
|
|
if itunes_id and not a['itunes_id']:
|
|
a['itunes_id'] = str(itunes_id)
|
|
if deezer_id and not a['deezer_id']:
|
|
a['deezer_id'] = str(deezer_id)
|
|
if discogs_id and not a['discogs_id']:
|
|
a['discogs_id'] = str(discogs_id)
|
|
if musicbrainz_id and not a['musicbrainz_id']:
|
|
a['musicbrainz_id'] = str(musicbrainz_id)
|
|
if popularity > a['popularity']:
|
|
a['popularity'] = popularity
|
|
a['sources'].add(source)
|
|
|
|
# 1. Metadata cache — biggest source
|
|
cursor.execute("""
|
|
SELECT name, entity_id, source, image_url, genres, popularity
|
|
FROM metadata_cache_entities WHERE entity_type = 'artist'
|
|
""")
|
|
for r in cursor.fetchall():
|
|
genres = []
|
|
if r['genres']:
|
|
try:
|
|
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("cache artist genres parse failed: %s", e)
|
|
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
kwargs = {src_map.get(r['source'], 'spotify_id'): r['entity_id']}
|
|
_add(r['name'], image_url=r['image_url'], genres=genres, source='cache', popularity=r['popularity'] or 0, **kwargs)
|
|
|
|
# 2. Similar artists
|
|
cursor.execute("""
|
|
SELECT similar_artist_name, similar_artist_spotify_id, similar_artist_itunes_id,
|
|
similar_artist_deezer_id, similar_artist_musicbrainz_id, image_url, genres, popularity
|
|
FROM similar_artists WHERE profile_id = ?
|
|
""", (profile_id,))
|
|
for r in cursor.fetchall():
|
|
genres = []
|
|
if r['genres']:
|
|
try:
|
|
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("similar artist genres parse failed: %s", e)
|
|
_add(r['similar_artist_name'], image_url=r['image_url'], genres=genres,
|
|
spotify_id=r['similar_artist_spotify_id'], itunes_id=r['similar_artist_itunes_id'],
|
|
deezer_id=r['similar_artist_deezer_id'],
|
|
musicbrainz_id=r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else None,
|
|
source='similar', popularity=r['popularity'] or 0)
|
|
|
|
# 3. Watchlist artists
|
|
cursor.execute("""
|
|
SELECT artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
|
|
discogs_artist_id, image_url
|
|
FROM watchlist_artists WHERE profile_id = ?
|
|
""", (profile_id,))
|
|
for r in cursor.fetchall():
|
|
_add(r['artist_name'], image_url=r['image_url'],
|
|
spotify_id=r['spotify_artist_id'], itunes_id=r['itunes_artist_id'],
|
|
deezer_id=r['deezer_artist_id'], discogs_id=r['discogs_artist_id'], source='watchlist')
|
|
|
|
# 4. Library artists
|
|
cursor.execute("SELECT name, thumb_url, genres FROM artists")
|
|
for r in cursor.fetchall():
|
|
genres = []
|
|
if r['genres']:
|
|
try:
|
|
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("library artist genres parse failed: %s", e)
|
|
img = r['thumb_url'] if r['thumb_url'] and r['thumb_url'].startswith('http') else None
|
|
_add(r['name'], image_url=img, genres=genres, source='library')
|
|
|
|
# Filter: only include artists that have at least one genre
|
|
genre_artists = {k: v for k, v in artists_by_name.items() if v['genres']}
|
|
|
|
# Build genre → artists map
|
|
genre_map = {} # genre_name → [artist_keys]
|
|
for key, a in genre_artists.items():
|
|
for g in a['genres']:
|
|
if g not in genre_map:
|
|
genre_map[g] = []
|
|
genre_map[g].append(key)
|
|
|
|
# Sort genres by artist count, take top genres
|
|
sorted_genres = sorted(genre_map.items(), key=lambda x: -len(x[1]))
|
|
|
|
# Build nodes
|
|
nodes = []
|
|
node_idx = {}
|
|
for key, a in genre_artists.items():
|
|
idx = len(nodes)
|
|
node_idx[key] = idx
|
|
nodes.append({
|
|
'id': idx,
|
|
'name': a['name'],
|
|
'image_url': a['image_url'],
|
|
'genres': list(a['genres'])[:5],
|
|
'spotify_id': a['spotify_id'],
|
|
'itunes_id': a['itunes_id'],
|
|
'deezer_id': a['deezer_id'],
|
|
'discogs_id': a['discogs_id'],
|
|
'musicbrainz_id': a['musicbrainz_id'],
|
|
'popularity': a['popularity'],
|
|
'type': 'watchlist' if 'watchlist' in a['sources'] else 'similar',
|
|
})
|
|
|
|
# Build genre clusters — allow artists in multiple genres
|
|
top_genres = sorted_genres[:40]
|
|
|
|
# Sort genres by co-occurrence so related genres are adjacent in the list.
|
|
# This makes the spiral layout place related genres near each other.
|
|
if len(top_genres) > 2:
|
|
genre_sets = {g: set(keys) for g, keys in top_genres}
|
|
ordered = [top_genres[0][0]] # Start with biggest genre
|
|
remaining = {g for g, _ in top_genres[1:]}
|
|
while remaining:
|
|
last = ordered[-1]
|
|
last_set = genre_sets.get(last, set())
|
|
# Find most similar remaining genre (highest artist overlap)
|
|
best = None
|
|
best_overlap = -1
|
|
for g in remaining:
|
|
overlap = len(last_set & genre_sets.get(g, set()))
|
|
if overlap > best_overlap:
|
|
best_overlap = overlap
|
|
best = g
|
|
ordered.append(best)
|
|
remaining.remove(best)
|
|
# Rebuild top_genres in the ordered sequence
|
|
genre_dict = dict(top_genres)
|
|
top_genres = [(g, genre_dict[g]) for g in ordered if g in genre_dict]
|
|
|
|
genres_out = []
|
|
for genre, artist_keys in top_genres:
|
|
genres_out.append({
|
|
'name': genre,
|
|
'count': len(artist_keys),
|
|
'artist_ids': [node_idx[k] for k in artist_keys if k in node_idx],
|
|
})
|
|
|
|
# Image cleanup + multi-source fallback
|
|
# Build two lookups: name→image_url AND name→deezer_entity_id
|
|
_img_cache = {}
|
|
_deezer_id_cache = {}
|
|
_album_art_cache = {} # artist_name → album image (iTunes fallback)
|
|
try:
|
|
# Artist images + Deezer IDs
|
|
cursor.execute("""
|
|
SELECT name, entity_id, source, image_url FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist'
|
|
AND ((image_url IS NOT NULL AND image_url != '' AND image_url LIKE 'http%')
|
|
OR source = 'deezer')
|
|
""")
|
|
for r in cursor.fetchall():
|
|
nn = (r['name'] or '').lower().strip()
|
|
if not nn:
|
|
continue
|
|
if r['image_url'] and r['image_url'].startswith('http') and nn not in _img_cache:
|
|
_img_cache[nn] = r['image_url']
|
|
if r['source'] == 'deezer' and r['entity_id'] and nn not in _deezer_id_cache:
|
|
_deezer_id_cache[nn] = r['entity_id']
|
|
|
|
# Album art by artist name (for iTunes artists with no artist image)
|
|
cursor.execute("""
|
|
SELECT artist_name, image_url FROM metadata_cache_entities
|
|
WHERE entity_type = 'album'
|
|
AND image_url IS NOT NULL AND image_url != '' AND image_url LIKE 'http%'
|
|
AND artist_name IS NOT NULL AND artist_name != ''
|
|
""")
|
|
for r in cursor.fetchall():
|
|
nn = (r['artist_name'] or '').lower().strip()
|
|
if nn and nn not in _album_art_cache:
|
|
_album_art_cache[nn] = r['image_url']
|
|
except Exception as e:
|
|
logger.debug("genre map cache build failed: %s", e)
|
|
|
|
for n in nodes:
|
|
img = n.get('image_url', '')
|
|
if img in ('None', 'null', '') or (img and not img.startswith('http')):
|
|
n['image_url'] = ''
|
|
nn = n['name'].lower().strip()
|
|
if not n['image_url']:
|
|
# Try cache image by name
|
|
n['image_url'] = _img_cache.get(nn, '')
|
|
if not n['image_url'] and n.get('deezer_id'):
|
|
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
|
if not n['image_url']:
|
|
# Try Deezer ID from cache by name
|
|
did = _deezer_id_cache.get(nn)
|
|
if did:
|
|
n['deezer_id'] = did
|
|
n['image_url'] = f"https://api.deezer.com/artist/{did}/image?size=big"
|
|
if not n['image_url']:
|
|
# Try album art by artist name (iTunes artists have no artist images)
|
|
n['image_url'] = _album_art_cache.get(nn, '')
|
|
|
|
_img_count = sum(1 for n in nodes if n.get('image_url'))
|
|
_deezer_count = sum(1 for n in nodes if n.get('image_url', '').startswith('https://api.deezer'))
|
|
_none_count = sum(1 for n in nodes if not n.get('image_url'))
|
|
logger.info(f"[Genre Map] {len(nodes)} artists, {len(sorted_genres)} genres")
|
|
logger.warning(f"[Genre Map] Images: {_img_count} have URLs, {_deezer_count} Deezer fallback, {_none_count} missing")
|
|
if _none_count > 0:
|
|
samples = [n['name'] for n in nodes if not n.get('image_url')][:5]
|
|
logger.warning(f"[Genre Map] Missing image samples: {samples}")
|
|
|
|
result = {
|
|
'success': True,
|
|
'nodes': nodes,
|
|
'genres': genres_out,
|
|
'total_artists': len(nodes),
|
|
'total_genres': len(sorted_genres),
|
|
}
|
|
_artmap_cache_set(f'genres_{profile_id}', result)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting genre map data: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return jsonify({"success": False, "error": str(e)}), 500
|
|
|
|
|
|
def get_artist_map_explore():
|
|
"""Build an exploration map outward from a single artist."""
|
|
try:
|
|
artist_name = request.args.get('name', '').strip()
|
|
artist_id = request.args.get('id', '').strip()
|
|
|
|
if not artist_name and not artist_id:
|
|
return jsonify({"success": False, "error": "Provide artist name or id"}), 400
|
|
|
|
database = get_database()
|
|
profile_id = get_current_profile_id()
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
def _norm(n):
|
|
return (n or '').lower().strip()
|
|
|
|
nodes = []
|
|
edges = []
|
|
seen = {} # norm_name → node index
|
|
|
|
# Find the center artist
|
|
center_name = artist_name
|
|
center_image = ''
|
|
center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': ''}
|
|
center_genres = []
|
|
|
|
# Search metadata cache for the center artist
|
|
if artist_id:
|
|
cursor.execute("""
|
|
SELECT name, entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist' AND entity_id = ? LIMIT 1
|
|
""", (artist_id,))
|
|
else:
|
|
cursor.execute("""
|
|
SELECT name, entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE LIMIT 1
|
|
""", (artist_name,))
|
|
|
|
row = cursor.fetchone()
|
|
artist_found = False
|
|
if row:
|
|
artist_found = True
|
|
center_name = row['name']
|
|
if row['image_url'] and row['image_url'].startswith('http'):
|
|
center_image = row['image_url']
|
|
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
k = src_map.get(row['source'], 'spotify_id')
|
|
center_ids[k] = row['entity_id']
|
|
if row['genres']:
|
|
try:
|
|
center_genres = json.loads(row['genres']) if isinstance(row['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("initial center genres parse failed: %s", e)
|
|
|
|
# Check watchlist + library if not in cache
|
|
if not artist_found and not artist_id:
|
|
cursor.execute("SELECT artist_name, image_url, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
|
|
wr = cursor.fetchone()
|
|
if wr:
|
|
artist_found = True
|
|
center_name = wr['artist_name']
|
|
if wr['image_url'] and str(wr['image_url']).startswith('http'):
|
|
center_image = wr['image_url']
|
|
for k, col in [('spotify_id', 'spotify_artist_id'), ('itunes_id', 'itunes_artist_id'), ('deezer_id', 'deezer_artist_id'), ('discogs_id', 'discogs_artist_id'), ('musicbrainz_id', 'musicbrainz_artist_id')]:
|
|
if wr[col]:
|
|
center_ids[k] = str(wr[col])
|
|
else:
|
|
cursor.execute("SELECT name, thumb_url FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
|
|
lr = cursor.fetchone()
|
|
if lr:
|
|
artist_found = True
|
|
center_name = lr['name']
|
|
if lr['thumb_url'] and str(lr['thumb_url']).startswith('http'):
|
|
center_image = lr['thumb_url']
|
|
|
|
# If not found locally, validate via metadata API search
|
|
if not artist_found and not artist_id:
|
|
try:
|
|
api_match = None
|
|
if spotify_client and spotify_client.is_spotify_authenticated():
|
|
results = spotify_client.search_artists(artist_name, limit=1)
|
|
if results and len(results) > 0:
|
|
sa = results[0]
|
|
if sa.name.lower().strip() == artist_name.lower().strip() or \
|
|
artist_name.lower().strip() in sa.name.lower().strip():
|
|
api_match = sa
|
|
center_name = sa.name
|
|
center_ids['spotify_id'] = sa.id
|
|
center_image = sa.image_url if hasattr(sa, 'image_url') else ''
|
|
center_genres = sa.genres if hasattr(sa, 'genres') else []
|
|
artist_found = True
|
|
if not artist_found:
|
|
ic = _get_itunes_client()
|
|
results = ic.search_artists(artist_name, limit=1)
|
|
if results and len(results) > 0:
|
|
ia = results[0]
|
|
if ia.name.lower().strip() == artist_name.lower().strip() or \
|
|
artist_name.lower().strip() in ia.name.lower().strip():
|
|
center_name = ia.name
|
|
center_ids['itunes_id'] = str(ia.id)
|
|
center_image = ia.image_url if hasattr(ia, 'image_url') else ''
|
|
artist_found = True
|
|
except Exception as e:
|
|
logger.debug(f"[Artist Explorer] API validation failed for '{artist_name}': {e}")
|
|
|
|
if not artist_found:
|
|
return jsonify({"success": False, "error": f"Artist '{artist_name}' not found"}), 404
|
|
|
|
# Also check cache for other source IDs
|
|
cursor.execute("""
|
|
SELECT entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
|
|
""", (center_name,))
|
|
for r in cursor.fetchall():
|
|
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
k = src_map.get(r['source'], 'spotify_id')
|
|
if not center_ids.get(k):
|
|
center_ids[k] = r['entity_id']
|
|
if r['image_url'] and r['image_url'].startswith('http') and not center_image:
|
|
center_image = r['image_url']
|
|
if r['genres'] and not center_genres:
|
|
try:
|
|
center_genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("center genres parse failed: %s", e)
|
|
|
|
# Add center node
|
|
center_idx = 0
|
|
seen[_norm(center_name)] = center_idx
|
|
nodes.append({
|
|
'id': 0, 'name': center_name, 'image_url': center_image,
|
|
'type': 'center', 'genres': center_genres[:5],
|
|
**center_ids, 'ring': 0
|
|
})
|
|
|
|
# Ring 1: Direct similar artists from similar_artists table
|
|
# Search by all known IDs
|
|
id_values = [v for v in center_ids.values() if v]
|
|
ring1_artists = []
|
|
if id_values:
|
|
placeholders = ','.join(['?'] * len(id_values))
|
|
cursor.execute(f"""
|
|
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
|
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
image_url, genres, popularity, similarity_rank
|
|
FROM similar_artists
|
|
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
|
ORDER BY similarity_rank ASC
|
|
""", id_values + [profile_id])
|
|
ring1_artists = cursor.fetchall()
|
|
|
|
# Also search by name (the center artist might be a watchlist source)
|
|
cursor.execute("""
|
|
SELECT DISTINCT sa.similar_artist_name, sa.similar_artist_spotify_id,
|
|
sa.similar_artist_itunes_id, sa.similar_artist_deezer_id, sa.similar_artist_musicbrainz_id,
|
|
sa.image_url, sa.genres, sa.popularity, sa.similarity_rank
|
|
FROM similar_artists sa
|
|
JOIN watchlist_artists wa ON sa.source_artist_id = COALESCE(wa.spotify_artist_id, wa.itunes_artist_id, CAST(wa.id AS TEXT))
|
|
WHERE wa.artist_name = ? COLLATE NOCASE AND sa.profile_id = ?
|
|
ORDER BY sa.similarity_rank ASC
|
|
""", (center_name, profile_id))
|
|
ring1_artists.extend(cursor.fetchall())
|
|
|
|
# If no similar artists in DB, fetch from MusicMap on-the-fly
|
|
if not ring1_artists:
|
|
try:
|
|
logger.debug(f"[Artist Explorer] No stored similar artists for '{center_name}', fetching from MusicMap...")
|
|
from core.watchlist_scanner import WatchlistScanner
|
|
scanner = WatchlistScanner(spotify_client=spotify_client) if spotify_client else None
|
|
if scanner:
|
|
similar = scanner._fetch_similar_artists_from_musicmap(center_name, limit=15)
|
|
if similar:
|
|
source_artist_id = center_ids.get('spotify_id') or center_ids.get('itunes_id') or center_name
|
|
# Store in DB for future use
|
|
for rank, sa in enumerate(similar, 1):
|
|
try:
|
|
database.add_or_update_similar_artist(
|
|
source_artist_id=source_artist_id,
|
|
similar_artist_name=sa['name'],
|
|
similar_artist_spotify_id=sa.get('spotify_id'),
|
|
similar_artist_itunes_id=sa.get('itunes_id'),
|
|
similarity_rank=rank,
|
|
profile_id=profile_id,
|
|
image_url=sa.get('image_url'),
|
|
genres=sa.get('genres'),
|
|
popularity=sa.get('popularity', 0),
|
|
similar_artist_deezer_id=sa.get('deezer_id'),
|
|
similar_artist_musicbrainz_id=sa.get('musicbrainz_id'),
|
|
)
|
|
except Exception as e:
|
|
logger.debug("similar artist insert failed: %s", e)
|
|
# Re-query from DB to get consistent format
|
|
if id_values:
|
|
placeholders = ','.join(['?'] * len(id_values))
|
|
cursor.execute(f"""
|
|
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
|
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
image_url, genres, popularity, similarity_rank
|
|
FROM similar_artists
|
|
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
|
ORDER BY similarity_rank ASC
|
|
""", id_values + [profile_id])
|
|
ring1_artists = cursor.fetchall()
|
|
if not ring1_artists:
|
|
# Fallback: query by name-based source ID
|
|
cursor.execute("""
|
|
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
|
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
image_url, genres, popularity, similarity_rank
|
|
FROM similar_artists
|
|
WHERE source_artist_id = ? AND profile_id = ?
|
|
ORDER BY similarity_rank ASC
|
|
""", (source_artist_id, profile_id))
|
|
ring1_artists = cursor.fetchall()
|
|
logger.debug(f"[Artist Explorer] Fetched {len(ring1_artists)} similar artists from MusicMap for '{center_name}'")
|
|
_artmap_cache_invalidate(profile_id) # New similar artists added
|
|
except Exception as e:
|
|
logger.debug(f"[Artist Explorer] MusicMap fetch failed for '{center_name}': {e}")
|
|
|
|
# Deduplicate ring 1
|
|
for r in ring1_artists:
|
|
nn = _norm(r['similar_artist_name'])
|
|
if nn in seen:
|
|
continue
|
|
idx = len(nodes)
|
|
seen[nn] = idx
|
|
genres = []
|
|
if r['genres']:
|
|
try:
|
|
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("ring1 genres parse failed: %s", e)
|
|
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
|
nodes.append({
|
|
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
|
'type': 'ring1', 'genres': genres[:5],
|
|
'spotify_id': r['similar_artist_spotify_id'] or '',
|
|
'itunes_id': r['similar_artist_itunes_id'] or '',
|
|
'deezer_id': r['similar_artist_deezer_id'] or '',
|
|
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
|
|
'discogs_id': '',
|
|
'popularity': r['popularity'] or 0,
|
|
'rank': r['similarity_rank'] or 5,
|
|
'ring': 1,
|
|
})
|
|
weight = max(1, 11 - (r['similarity_rank'] or 5))
|
|
edges.append({'source': center_idx, 'target': idx, 'weight': weight})
|
|
|
|
# Ring 2: Similar artists of ring 1 artists (from similar_artists table)
|
|
ring1_ids = []
|
|
for n in nodes[1:]: # skip center
|
|
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
|
if sid:
|
|
ring1_ids.append(sid)
|
|
|
|
if ring1_ids:
|
|
placeholders = ','.join(['?'] * len(ring1_ids))
|
|
cursor.execute(f"""
|
|
SELECT DISTINCT source_artist_id, similar_artist_name,
|
|
similar_artist_spotify_id, similar_artist_itunes_id,
|
|
similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
image_url, genres, popularity, similarity_rank
|
|
FROM similar_artists
|
|
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
|
ORDER BY similarity_rank ASC
|
|
""", ring1_ids + [profile_id])
|
|
|
|
for r in cursor.fetchall():
|
|
nn = _norm(r['similar_artist_name'])
|
|
if nn in seen:
|
|
# Create edge to existing node if not center
|
|
existing_idx = seen[nn]
|
|
# Find the ring1 node that sourced this
|
|
source_norm = None
|
|
for n in nodes[1:]:
|
|
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
|
if sid == r['source_artist_id']:
|
|
source_norm = _norm(n['name'])
|
|
break
|
|
if source_norm:
|
|
break
|
|
if source_norm and source_norm in seen and existing_idx != seen[source_norm]:
|
|
edges.append({'source': seen[source_norm], 'target': existing_idx, 'weight': 3})
|
|
continue
|
|
|
|
idx = len(nodes)
|
|
if idx >= 500: # Cap at 500 nodes for performance
|
|
break
|
|
seen[nn] = idx
|
|
genres = []
|
|
if r['genres']:
|
|
try:
|
|
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("ring2 genres parse failed: %s", e)
|
|
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
|
nodes.append({
|
|
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
|
'type': 'ring2', 'genres': genres[:5],
|
|
'spotify_id': r['similar_artist_spotify_id'] or '',
|
|
'itunes_id': r['similar_artist_itunes_id'] or '',
|
|
'deezer_id': r['similar_artist_deezer_id'] or '',
|
|
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
|
|
'discogs_id': '',
|
|
'popularity': r['popularity'] or 0,
|
|
'rank': r['similarity_rank'] or 5,
|
|
'ring': 2,
|
|
})
|
|
# Find the ring1 source
|
|
for n in nodes[1:]:
|
|
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
|
if sid == r['source_artist_id']:
|
|
edges.append({'source': n['id'], 'target': idx, 'weight': max(1, 11 - (r['similarity_rank'] or 5))})
|
|
break
|
|
|
|
# Backfill images/genres from ALL cache sources + Deezer fallback
|
|
for n in nodes:
|
|
# Clean up string "None" stored as image URL
|
|
if n['image_url'] in ('None', 'null', ''):
|
|
n['image_url'] = ''
|
|
if n['image_url'] and n['genres']:
|
|
continue
|
|
# Check all cache entries for this artist (multiple sources)
|
|
cursor.execute("""
|
|
SELECT entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
|
|
""", (n['name'],))
|
|
for cr in cursor.fetchall():
|
|
if not n['image_url'] and cr['image_url'] and cr['image_url'].startswith('http'):
|
|
n['image_url'] = cr['image_url']
|
|
if not n['genres'] and cr['genres']:
|
|
try:
|
|
n['genres'] = json.loads(cr['genres'])[:5] if isinstance(cr['genres'], str) else []
|
|
except Exception as e:
|
|
logger.debug("explorer node genres parse failed: %s", e)
|
|
# Harvest missing IDs from cache
|
|
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
k = src_map.get(cr['source'])
|
|
if k and not n.get(k):
|
|
n[k] = cr['entity_id']
|
|
|
|
# Deezer image fallback — construct URL directly from ID
|
|
if not n['image_url'] and n.get('deezer_id'):
|
|
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
|
|
|
# Spotify image fallback — try API if authenticated
|
|
if not n['image_url'] and n.get('spotify_id'):
|
|
try:
|
|
if spotify_client and spotify_client.is_spotify_authenticated():
|
|
from core.api_call_tracker import api_call_tracker
|
|
api_call_tracker.record_call('spotify', endpoint='artist')
|
|
artist_data = spotify_client.sp.artist(n['spotify_id'])
|
|
if artist_data and artist_data.get('images'):
|
|
n['image_url'] = artist_data['images'][0]['url']
|
|
if not n['genres'] and artist_data.get('genres'):
|
|
n['genres'] = artist_data['genres'][:5]
|
|
except Exception as e:
|
|
logger.debug("spotify artist image fallback failed: %s", e)
|
|
|
|
# Album art fallback (iTunes artists have no artist images)
|
|
if not n['image_url']:
|
|
cursor.execute("""
|
|
SELECT image_url FROM metadata_cache_entities
|
|
WHERE entity_type = 'album' AND image_url LIKE 'http%'
|
|
AND artist_name = ? COLLATE NOCASE LIMIT 1
|
|
""", (n['name'],))
|
|
alb = cursor.fetchone()
|
|
if alb:
|
|
n['image_url'] = alb['image_url']
|
|
|
|
logger.info(f"[Artist Explorer] Center: {center_name}, Ring 1: {sum(1 for n in nodes if n.get('ring')==1)}, Ring 2: {sum(1 for n in nodes if n.get('ring')==2)}, Edges: {len(edges)}")
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'nodes': nodes,
|
|
'edges': edges,
|
|
'center': center_name,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist explorer data: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return jsonify({"success": False, "error": str(e)}), 500
|