Artist Map: image proxy, caching, on-the-fly explorer, genre uncap, dated changelogs

- Image proxy endpoint (/api/image-proxy) for canvas CORS — allowlisted CDNs,
  browser-like UA for Deezer, 24h cache headers. Direct CORS first, proxy fallback.
- Server-side 5-min cache on all artist map endpoints with auto-invalidation
  on watchlist add/remove, scan complete, and new MusicMap discoveries.
- Explorer fetches similar artists from MusicMap on-the-fly when none stored,
  saves to DB for instant future visits. Validates artist names against
  Spotify/iTunes API before loading map — rejects gibberish with 404.
- Genre map per-genre cap removed (was 300 backend, 400 frontend).
- Center node in Explorer uses type 'center' not 'watchlist' — no longer
  misidentified as a watchlist artist.
- Error overlay auto-dismisses after 2.5s and returns to Discover page.
- Helper What's New restructured with dated sections (April 4/3/2/1, March),
  trimmed from ~80 to ~38 entries, date headers styled as purple dividers.
- Version modal updated with Artist Map section and recent fixes.
This commit is contained in:
Broque Thomas 2026-04-04 21:18:08 -07:00
parent f348b6a7cb
commit 48a9de8861
5 changed files with 1758 additions and 107 deletions

View file

@ -20502,6 +20502,25 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "🗺️ Artist Map — Visualize Your Music Universe",
"description": "Three interactive canvas-based visualization modes on the Discover page",
"features": [
"• Watchlist Constellation — your watched artists as large nodes with similar artists orbiting around them",
"• Genre Map — browse all artists by genre with a sidebar picker, ring-packed clusters, no artist cap",
"• Artist Explorer — deep-dive any artist, ring 1 (direct similar) + ring 2 (extended network)",
"• On-the-fly discovery — exploring an unknown artist fetches similar artists from MusicMap in real-time and caches them",
"• Invalid artist names validated against Spotify/iTunes before loading the map",
"• Offscreen canvas buffer rendering with LOD — handles 1000+ nodes smoothly",
"• Image proxy endpoint solves CORS for canvas — Deezer, Last.fm, Discogs images now render on bubbles",
"• Direct CORS fetch first (zero server load), proxy only as fallback for non-CORS CDNs",
"• Server-side 5-minute cache on all map endpoints — switching genres and reopening is instant",
"• Cache auto-invalidates on watchlist changes, scans, and new similar artist discoveries",
"• Keyboard shortcuts (?, F for fit, S for search), mouse wheel zoom, click-to-explore",
"• Hover constellation effect with fade animation, rich tooltips with genre tags"
],
"usage_note": "Navigate to Discover and click the Artist Map section. Choose Watchlist, Genre, or Explorer mode."
},
{
"title": "⚡ Wing It — Download or Sync Without Discovery",
"description": "Bypass metadata discovery and use raw track names directly",
@ -20577,6 +20596,9 @@ def get_version_info():
"title": "🔧 Additional Fixes",
"description": "Bug fixes and quality-of-life improvements",
"features": [
"• $discnum template variable — unpadded disc number for multi-disc album path templates",
"• Media player no longer collapses in sidebar on short viewports and mobile",
"• Playlist Explorer controls redesigned — prominent Explore button, icons, polish",
"• YouTube '- Topic' suffix stripped from auto-generated channel names (#231)",
"• Cover Art Archive album art now opt-in via Settings toggle (#232)",
"• cover.jpg now correctly uses Cover Art Archive when enabled (was silently failing)",
@ -37125,6 +37147,7 @@ def add_to_watchlist():
})
except Exception:
pass
_artmap_cache_invalidate(get_current_profile_id())
return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"})
else:
return jsonify({"success": False, "error": "Failed to add artist to watchlist"}), 500
@ -37161,6 +37184,7 @@ def remove_from_watchlist():
})
except Exception:
pass
_artmap_cache_invalidate(get_current_profile_id())
return jsonify({"success": True, "message": "Removed artist from watchlist"})
else:
return jsonify({"success": False, "error": "Failed to remove artist from watchlist"}), 500
@ -37726,6 +37750,7 @@ def start_watchlist_scan():
watchlist_scan_state['status'] = 'completed'
watchlist_scan_state['results'] = scan_results
watchlist_scan_state['completed_at'] = datetime.now()
_artmap_cache_invalidate(scan_profile_id)
watchlist_scan_state['current_phase'] = 'completed'
# Calculate summary
@ -39688,7 +39713,7 @@ def get_discover_genre_new_releases():
if not genre_names:
return jsonify({'success': True, 'albums': []})
allowed = _get_genre_allowed_sources()
albums = cache.get_genre_new_releases(genre_names, source=allowed, limit=20)
albums = cache.get_genre_new_releases(genre_names, sources=allowed, limit=20)
return jsonify({'success': True, 'albums': albums})
except Exception as e:
logger.error(f"Genre new releases endpoint error: {e}")
@ -41008,10 +41033,10 @@ def get_your_artist_info(artist_id):
except Exception as e:
logger.debug(f"Spotify artist lookup failed for {artist_id}: {e}")
# 4. Last.fm: bio, listeners, playcount (always try — has the best artist bios)
# 4. Last.fm: bio, listeners, playcount (skip if name is too short/generic)
try:
_lfm_name = result.get('name') or artist_name
if _lfm_name and lastfm_worker and lastfm_worker.client:
if _lfm_name and len(_lfm_name) > 1 and lastfm_worker and lastfm_worker.client:
lfm_info = lastfm_worker.client.get_artist_info(_lfm_name)
if lfm_info:
bio = lfm_info.get('bio', {})
@ -41041,6 +41066,75 @@ def get_your_artist_info(artist_id):
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/image-proxy', methods=['GET'])
def image_proxy():
"""Proxy external images to avoid CORS issues for canvas rendering."""
url = request.args.get('url', '')
if not url or not url.startswith('http'):
return '', 400
# Only allow known image CDNs
from urllib.parse import urlparse
host = urlparse(url).hostname or ''
allowed_hosts = [
'i.scdn.co', 'mosaic.scdn.co', # Spotify
'lastfm.freetls.fastly.net', 'lastfm-img2.akamaized.net', # Last.fm
'e-cdns-images.dzcdn.net', 'cdns-images.dzcdn.net', 'api.deezer.com', # Deezer
'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com', 'is3-ssl.mzstatic.com',
'is4-ssl.mzstatic.com', 'is5-ssl.mzstatic.com', # iTunes/Apple
'img.discogs.com', 'i.discogs.com', # Discogs
]
if not any(host == h or host.endswith('.' + h) for h in allowed_hosts):
return '', 403
try:
resp = requests.get(url, timeout=10, stream=True, 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',
'Referer': 'https://www.deezer.com/',
})
if resp.status_code != 200:
return '', resp.status_code
content_type = resp.headers.get('Content-Type', 'image/jpeg')
if not content_type.startswith('image/'):
return '', 400
return Response(
resp.content,
content_type=content_type,
headers={
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
}
)
except Exception:
return '', 502
# 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)
@app.route('/api/discover/artist-map', methods=['GET'])
def get_artist_map_data():
"""Get watchlist artists + their similar artists for the force-directed artist map."""
@ -41048,6 +41142,10 @@ def get_artist_map_data():
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()
@ -41221,16 +41319,44 @@ def get_artist_map_data():
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:
pass
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}")
return jsonify({
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
@ -41238,6 +41364,681 @@ def get_artist_map_data():
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/artist-map/genre-list', methods=['GET'])
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:
pass
# 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
@app.route('/api/discover/artist-map/genres', methods=['GET'])
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, 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': '',
'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 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:
pass
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_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, 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:
pass
_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'], 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:
pass
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'],
'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:
pass
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'))
print(f"[Genre Map] {len(nodes)} artists, {len(sorted_genres)} genres")
print(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]
print(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
@app.route('/api/discover/artist-map/explore', methods=['GET'])
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': ''}
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'}
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:
pass
# 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 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')]:
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:
from core.itunes_client import iTunesClient
ic = iTunesClient()
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:
print(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'}
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:
pass
# 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,
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.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:
print(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')
)
except Exception:
pass
# 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,
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,
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()
print(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:
print(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:
pass
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 '',
'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, 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:
pass
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 '',
'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:
pass
# Harvest missing IDs from cache
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_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():
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:
pass
# 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']
print(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
@app.route('/api/discover/build-playlist/search-artists', methods=['GET'])
def search_artists_for_playlist():
"""Search for artists to use as seeds for custom playlist building"""

View file

@ -3166,7 +3166,16 @@
</button>
</div>
</div>
<canvas id="artist-map-canvas"></canvas>
<div class="artmap-content-row">
<div class="artmap-genre-sidebar" id="artmap-genre-sidebar" style="display:none;">
<div class="artmap-genre-sidebar-header">
<span>Genres</span>
<input type="text" class="artmap-genre-sidebar-search" placeholder="Filter..." oninput="_filterGenreSidebar(this.value)">
</div>
<div class="artmap-genre-sidebar-list" id="artmap-genre-sidebar-list"></div>
</div>
<canvas id="artist-map-canvas"></canvas>
</div>
<div class="artist-map-tooltip" id="artist-map-tooltip"></div>
<div class="artist-map-search-results" id="artist-map-search-results"></div>
</div>

View file

@ -3402,78 +3402,53 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.2': [
// Newest features first
// --- April 4, 2026 ---
{ date: 'April 4, 2026' },
{ title: 'Artist Map — Visualize Your Music Universe', desc: 'Three interactive canvas modes: Watchlist Constellation (your artists + similar), Genre Map (browse by genre with sidebar), and Artist Explorer (deep-dive any artist). Offscreen buffer rendering handles 1000+ nodes', page: 'discover' },
{ title: 'Artist Explorer — On-the-Fly Discovery', desc: 'Explore any artist even if not in your library — fetches similar artists from MusicMap in real-time, stores results for instant future visits. Invalid names validated against Spotify/iTunes', page: 'discover' },
{ title: 'Genre Map — Full Artist Counts', desc: 'Genre map now shows all artists per genre (no caps). Ring packing layout handles large genres instantly. Genre sidebar for quick switching', page: 'discover' },
{ title: 'Artist Map Caching', desc: 'Server-side 5-minute cache on all artist map endpoints — switching genres and reopening maps is instant. Auto-invalidates on watchlist changes and scans' },
{ title: 'Image Proxy for Canvas Rendering', desc: 'Server-side image proxy solves CORS issues for canvas — Deezer, Last.fm, and Discogs images now render on Artist Map bubbles' },
// --- April 3, 2026 ---
{ date: 'April 3, 2026' },
{ title: 'Your Artists on Discover', desc: 'Aggregates liked/followed artists from Spotify, Tidal, Last.fm, and Deezer. Auto-matched to all metadata sources. Click for artist info modal with bio, genres, stats, and watchlist toggle', page: 'discover' },
{ title: 'Deezer OAuth', desc: 'Full Deezer OAuth integration for user favorites and playlists. Configure in Settings → Connections' },
{ title: 'Failed MB Lookups Manager', desc: 'Browse, search, and manually match failed MusicBrainz lookups from the Cache Health modal. Search MusicBrainz directly and save matches' },
{ title: 'Explorer: Discover + Badges + Auto-Refresh', desc: 'Trigger discovery directly from Explorer, status badges (explored/wishlisted/downloaded), auto-refresh when discovery completes', page: 'playlist-explorer' },
{ title: 'Fix Album Folder Splitting', desc: 'Collab albums and artist name changes no longer scatter tracks across multiple folders — $albumartist now uses album-level artist consistently' },
{ title: 'Fix Watchlist Rate Limiting', desc: 'Watchlist scans now fetch only newest albums instead of full discography (~90% fewer API calls). Configurable API interval in settings. Better Retry-After header extraction' },
{ title: 'Discogs Integration', desc: 'New metadata source — enrichment worker, fallback source, enhanced search tab, watchlist support, cache browser. Genres, styles, labels, bios, ratings from 400+ taxonomy', page: 'dashboard' },
{ title: 'Webhook THEN Action', desc: 'Send HTTP POST to any URL when automations complete — integrate with Gotify, Home Assistant, Slack, n8n. Configurable headers and message template', page: 'automations' },
{ title: 'API Rate Monitor', desc: 'Real-time speedometer gauges for all enrichment services on the Dashboard. Click any gauge for 24h history chart. Spotify shows per-endpoint breakdown', page: 'dashboard' },
{ title: 'Configurable Concurrent Downloads', desc: 'Set max simultaneous downloads per batch (1-10) in Settings. Soulseek albums stay at 1 for source reuse. Higher values speed up playlists and wishlists' },
{ title: 'Streaming Search Sources', desc: 'Apple Music and other slow sources now stream results progressively — see artists, albums, tracks as each loads instead of waiting for all 3' },
{ title: 'Global Search Bar', desc: 'Spotlight-style search from any page — press / or Ctrl+K. Full enhanced search with source tabs, library badges, and playback' },
{ title: 'Block Artists from Discovery', desc: 'Block artists you never want to see in discovery playlists — hover any track and click ✕, or use the 🚫 button on the Discover hero to search and manage blocked artists', page: 'discover' },
{ title: 'MusicBrainz Cache in Browser', desc: 'MusicBrainz cache now visible in Cache Browser — browse, clear all, or clear failed lookups only. Cache Health shows MB alongside other sources' },
{ title: 'Wing It Mode', desc: 'Download or sync playlists without metadata discovery — uses raw track names directly. Great for obscure tracks not on Spotify/iTunes' },
{ title: 'Redesigned Notifications', desc: 'Compact pill toasts, notification bell with unread badge, history panel with last 50 notifications and Learn More links' },
{ title: 'Track Redownload & Source Info', desc: 'Fix mismatched downloads — search all metadata and download sources in columns, pick the right version, auto-replace. Source Info shows where tracks came from with blacklist option', page: 'library' },
{ title: 'Fix Spotify Pagination Rate Limits', desc: 'Paginated API calls (get_artist_albums, playlist tracks) now throttled — were bypassing rate limiter and causing 429 bans during watchlist scans' },
{ title: 'Fix Track Source Info / Redownload 404', desc: 'Source info and redownload endpoints now accept string IDs (Jellyfin GUIDs) — were rejecting non-integer IDs (#237)' },
{ title: 'Clear Matched IDs', desc: 'New "Clear Match" button in the manual match modal lets you undo wrong matches and revert to Not Found (#236)' },
{ title: 'Fix spotify_public Discovery Overwrite', desc: 'Playlist refresh no longer overwrites discovery data for public Spotify playlists — uses full API when authenticated' },
{ title: 'Fix Track Provenance Through Transcoding', desc: 'Download source info preserved when Blasphemy Mode converts FLAC to lossy — bit depth, sample rate, bitrate stored (#245)' },
{ title: 'Fix Watchlist Cross-Provider Backfill', desc: 'All metadata sources (Spotify, iTunes, Deezer, Discogs) backfilled at start of every watchlist scan' },
{ title: 'Fix Import Not Triggering DB Update', desc: 'Import now emits batch_complete through automation engine — server scan + DB update chain works like normal downloads' },
{ title: 'Fix Tidal Auth Crash', desc: 'Tidal download auth no longer crashes — download orchestrator hardened with per-client isolation' },
{ title: 'Fix Hybrid Download Mode', desc: 'Hybrid mode now tries fallback sources when primary source results all fail quality filtering (#235)' },
{ title: 'Fix Discovery Progress Display', desc: 'Discovery modals (YouTube, Tidal, ListenBrainz, Beatport) now show live progress instead of staying on Pending' },
{ title: 'Spotify API Rate Limit Improvements', desc: 'Cached get_artist_albums, eliminated duplicate search calls, auth probe reduced 66%, enrichment workers auto-pause during downloads' },
{ title: 'Server Playlist Manager', desc: 'Compare source playlists against your media server — find missing tracks, swap wrong matches, remove extras with a dual-column editor', page: 'sync' },
{ title: 'Sync History Dashboard', desc: 'Dashboard shows recent syncs as cards — click for per-track match details with confidence scores and album art' },
{ title: 'Fix Japanese/CJK Soulseek Searches', desc: 'Japanese kanji no longer mangled into Chinese pinyin — searches now use original characters' },
{ title: 'Fix Partial Title Matching', desc: '"Believe" no longer falsely matches "Believe In Me" — length ratio penalty prevents prefix false positives' },
{ title: 'Fix Pipeline Blocking on Discovery Fail', desc: 'Playlist sync no longer drops tracks that failed metadata discovery — continues with original name/artist for download' },
{ title: 'Playlist Explorer', desc: 'New page: expand playlists into visual discovery trees of albums and discographies — select and add to wishlist', page: 'playlist-explorer' },
{ title: 'Fix Invalid .LRC Lyrics Files', desc: 'Plain lyrics now saved as .txt — only synced (timestamped) lyrics get the .lrc extension' },
{ title: 'Fix Collab Artist on Singles', desc: 'Single/playlist path templates now respect First Listed Artist setting — $albumartist available for all template types' },
{ title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found — uses stored ID for direct lookup' },
{ title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' },
{ title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services with live API call counts (1h/24h), Spotify budget bar, and click-to-configure', page: 'dashboard' },
{ title: 'Qobuz on Connections Tab', desc: 'Qobuz credentials now on Settings → Connections for metadata enrichment without needing it as download source' },
{ title: 'Fix Enrichment Status Widget', desc: 'Enrichment tooltip now shows Rate Limited or Daily Limit instead of stuck on Running' },
{ title: 'Cache Maintenance', desc: 'Cache evictor now cleans junk entities, orphaned searches, and stale MusicBrainz nulls' },
{ title: 'Fix Wishlist Download Selection', desc: 'Download Selection now only downloads checked tracks instead of the entire category' },
{ title: 'Fix Tidal OAuth in Docker', desc: 'Tidal redirect URI now uses configured setting instead of Docker container hostname' },
{ title: 'High-Res Cover Art', desc: 'Album art now sourced from Cover Art Archive (1200x1200+) when MusicBrainz release ID is available' },
{ title: 'Embedded Lyrics', desc: 'Lyrics now embedded directly in audio file tags — Navidrome, Jellyfin, and Plex can display them' },
{ title: 'Fix AcoustID False Positives', desc: 'High-confidence fingerprints no longer quarantine correct files when titles are in different languages' },
{ title: 'Fix Junk Tags Surviving', desc: 'Soulseek source tags are now wiped to disk immediately — no more album fragmentation from partial metadata' },
{ title: 'Watch All Preview Modal', desc: 'Watch All Unwatched now opens a modal showing which artists will be added before confirming', page: 'library', selector: '#library-watchlist-all-btn' },
{ title: 'Fix Watch All Unwatched', desc: 'Watch All Unwatched now works for Deezer users — was silently skipping artists with only Deezer IDs' },
{ title: 'Fix Path Mismatch Fixes', desc: 'Library Maintenance path fixes now use fresh config and show error reasons in the toast' },
{ title: 'Fix Wrong Spotify IDs', desc: 'Manual match no longer stores iTunes IDs as Spotify IDs — detects actual provider from results' },
{ title: 'Spotify Enrichment Budget', desc: 'Background enrichment worker caps at 3,000 items/day to prevent rate limit bans — resets at midnight' },
{ title: 'Per-Artist Library Sync', desc: 'Validate files and clean stale entries per artist from the enhanced library view', page: 'library', selector: '.library-controls' },
{ title: 'Collaborative Album Handling', desc: 'Smart folder naming for multi-artist albums — uses first listed artist', page: 'settings', selector: '#collab-artist-mode' },
{ title: 'Accurate Completion Badges', desc: 'Exact track counts, deduplication, multi-artist and censored title matching', page: 'artists', selector: '#artists-search-input' },
{ title: 'Stream Source Setting', desc: 'Choose YouTube or active download source for track previews', page: 'settings', selector: '#stream-source' },
{ title: 'YouTube Download Fix', desc: 'Fixed format errors, cookie fallback, auto-update yt-dlp in Docker' },
{ title: 'Launch PIN Lock Screen', desc: 'Protect SoulSync with a PIN on every page load — toggle in Settings → Advanced', page: 'settings', selector: '.stg-tab[data-tab="advanced"]' },
{ title: 'Interactive Help System', desc: 'Guided tours, search, shortcuts, setup tracking, troubleshooting — all from the ? button', selector: '#helper-float-btn' },
{ title: 'Rich Artist Profiles', desc: 'Full-bleed hero section with bio, stats, genres, and service links', page: 'artists', selector: '#artists-search-input' },
{ title: 'In Library Badges', desc: 'Search results show "In Library" badges for albums and tracks you already own', page: 'downloads', selector: '.enhanced-search-input-wrapper' },
{ title: 'Enhanced Library Manager', desc: 'Inline tag editing, bulk operations, and write-to-file from the library', page: 'library', selector: '.library-controls' },
{ title: 'Genre Explorer', desc: 'Browse music by genre across all metadata sources (Spotify, iTunes, Deezer)', page: 'discover', selector: '#genre-tabs' },
{ title: 'Multi-Source Search Tabs', desc: 'Compare results from Spotify, iTunes, and Deezer side by side', page: 'downloads', selector: '.search-mode-toggle' },
{ title: 'Automation Signals', desc: 'Chain automations together using fire/receive signals', page: 'automations', selector: '#auto-section-hub' },
{ title: 'FLAC Bit Depth Control', desc: 'Quality profiles now enforce 16-bit vs 24-bit preference with fallback' },
{ title: 'Deezer Download Source', desc: 'Deezer added as 5th download source with quality fallback' },
{ title: 'Personalized Discovery', desc: 'Daily Mixes, Hidden Gems, Forgotten Favorites, Build a Playlist, and more', page: 'discover' },
{ title: 'ListenBrainz Integration', desc: 'Algorithmic playlists from your listening history', page: 'discover' },
{ title: 'Listening Stats', desc: 'Charts, rankings, and library health metrics from your media server', page: 'stats', selector: '#stats-overview' },
{ title: 'Explorer Controls Redesign', desc: 'Playlist Explorer controls redesigned with prominent Explore button, icons, status badges, auto-refresh, and discover from Explorer', page: 'playlist-explorer' },
{ title: '$discnum Template Variable', desc: 'Unpadded disc number for multi-disc album path templates — e.g. Disc 1, Disc 2' },
{ title: 'Fix Album Folder Splitting', desc: 'Collab albums no longer scatter tracks across multiple folders — $albumartist uses album-level artist consistently' },
{ title: 'Fix Watchlist Rate Limiting', desc: 'Watchlist scans fetch only newest albums (~90% fewer API calls). Configurable API interval. Better Retry-After extraction' },
{ title: 'Fix Media Player Collapsing', desc: 'Media player no longer collapses in the sidebar on short viewports and mobile devices' },
// --- April 2, 2026 ---
{ date: 'April 2, 2026' },
{ title: 'Discogs Integration', desc: 'New metadata source — enrichment worker, fallback source, enhanced search tab, watchlist support, cache browser. 400+ genre/style taxonomy', page: 'dashboard' },
{ title: 'Webhook THEN Action', desc: 'Send HTTP POST to any URL when automations complete — Gotify, Home Assistant, Slack, n8n', page: 'automations' },
{ title: 'API Rate Monitor', desc: 'Real-time speedometer gauges for all enrichment services on Dashboard. Click any gauge for 24h history', page: 'dashboard' },
{ title: 'Configurable Concurrent Downloads', desc: 'Set max simultaneous downloads (1-10) in Settings. Soulseek albums stay at 1 for source reuse' },
{ title: 'Streaming Search Sources', desc: 'Apple Music results stream progressively instead of blocking for 9+ seconds' },
{ title: 'Track Provenance Through Transcoding', desc: 'Download source info preserved when Blasphemy Mode converts FLAC to lossy (#245)' },
// --- April 1, 2026 ---
{ date: 'April 1, 2026' },
{ title: 'Wing It Mode', desc: 'Download or sync playlists without metadata discovery — uses raw track names directly' },
{ title: 'Global Search Bar', desc: 'Spotlight-style search from any page — press / or Ctrl+K. Full enhanced search with source tabs', page: 'downloads' },
{ title: 'Redesigned Notifications', desc: 'Compact pill toasts, notification bell with unread badge, history panel with last 50 notifications' },
{ title: 'Track Redownload & Source Info', desc: 'Fix mismatched downloads from the enhanced library view. Source Info shows download provenance with blacklist option', page: 'library' },
{ title: 'Block Artists from Discovery', desc: 'Permanently exclude artists from all discovery playlists — hover any track and click ✕', page: 'discover' },
{ title: 'MusicBrainz Cache in Browser', desc: 'MusicBrainz cache now visible in Cache Browser with clear and clear-failed-only options' },
// --- Earlier in v2.2 ---
{ date: 'March 2026' },
{ title: 'Server Playlist Manager', desc: 'Compare source playlists against your media server — find missing tracks, swap wrong matches, remove extras', page: 'sync' },
{ title: 'Sync History Dashboard', desc: 'Recent syncs as cards on Dashboard — click for per-track match details with confidence scores' },
{ title: 'Playlist Explorer', desc: 'Expand playlists into visual discovery trees of albums and discographies', page: 'playlist-explorer' },
{ title: 'Enhanced Library Manager', desc: 'Inline tag editing, bulk operations, write-to-file, and per-artist library sync', page: 'library' },
{ title: 'Automation Signals', desc: 'Chain automations together using fire/receive signals with cycle detection', page: 'automations' },
{ title: 'Multi-Source Search Tabs', desc: 'Compare results from Spotify, iTunes, and Deezer side by side', page: 'downloads' },
{ title: 'Rich Artist Profiles', desc: 'Full-bleed hero section with bio, stats, genres, and service links', page: 'artists' },
{ title: 'Spotify API Rate Limit Improvements', desc: 'Cached discography lookups, eliminated duplicate calls, enrichment workers auto-pause during downloads' },
],
};
@ -3513,6 +3488,7 @@ function openWhatsNew() {
</div>
<div class="helper-whats-new-list">
${notes.map(h => {
if (h.date) return `<div class="helper-whats-new-date">${h.date}</div>`;
const hasTarget = !!(h.selector || h.page);
const linkText = h.selector ? 'Show me →' : h.page ? 'Go to page →' : '';
return `

View file

@ -55153,9 +55153,13 @@ async function openYourArtistInfoModal(poolId) {
`;
document.body.appendChild(overlay);
// Fetch enrichment data
// Fetch enrichment data (with timeout)
try {
const resp = await fetch(`/api/discover/your-artists/info/${artistId}?name=${encodeURIComponent(artistName)}`);
if (!artistId) throw new Error('No source ID');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const resp = await fetch(`/api/discover/your-artists/info/${artistId}?name=${encodeURIComponent(artistName)}`, { signal: controller.signal });
clearTimeout(timeout);
const artist = resp.ok ? await resp.json() : {};
const bodyEl = document.getElementById('ya-info-body');
const footerEl = document.getElementById('ya-info-footer');
@ -55235,6 +55239,10 @@ async function openYourArtistInfoModal(poolId) {
: `<button class="ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Added!'; this.disabled=true">Add to Watchlist</button>`;
footerEl.innerHTML = `
${watchBtn}
<button class="ya-header-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); openArtistMapExplorerDirect('${escapeForInlineJs(artistName)}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Explore</span>
</button>
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artistId)}', name:'${escapeForInlineJs(artistName)}', image_url:'${escapeForInlineJs(imageUrl)}'}, {source:'${escapeForInlineJs(pool.active_source || '')}'}), 200)">
<span>View Discography</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
@ -55686,11 +55694,8 @@ async function openArtistMap() {
const n = imgNodes[idx++];
if (_artMap.images[n.id]) { loaded++; continue; }
batch.push(
fetch(n.image_url, { mode: 'cors' })
.then(r => r.ok ? r.blob() : null)
.then(blob => blob ? createImageBitmap(blob) : null)
_artMapLoadImage(n.image_url)
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.catch(() => {})
.finally(() => {
loaded++;
if (loadingText && loaded % 50 === 0) {
@ -55768,6 +55773,8 @@ function _artMapAnimateTo(targetZoom, targetOX, targetOY) {
_artMap._animating = requestAnimationFrame(step);
} else {
_artMap._animating = null;
_artMap.dirty = true;
_artMapRender(); // rebuild at final zoom level
}
}
_artMap._animating = requestAnimationFrame(step);
@ -55776,6 +55783,8 @@ function _artMapAnimateTo(targetZoom, targetOX, targetOY) {
function closeArtistMap() {
const container = document.getElementById('artist-map-container');
if (container) container.style.display = 'none';
const sidebar = document.getElementById('artmap-genre-sidebar');
if (sidebar) sidebar.style.display = 'none';
if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame);
if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler);
_artMapHideContextMenu();
@ -55807,8 +55816,9 @@ function _artMapRebuildBuffer() {
const bw = maxX - minX;
const bh = maxY - minY;
// Fixed scale — high enough for reasonable quality, capped for memory
const scale = Math.min(0.5, 8192 / Math.max(bw, bh));
// Scale based on zoom — higher zoom = higher res buffer, capped for memory
const z = _artMap.zoom || 0.1;
const scale = Math.min(z * 2, 1.0, 10240 / Math.max(bw, bh));
if (!_artMap.offscreen) _artMap.offscreen = document.createElement('canvas');
const oc = _artMap.offscreen;
@ -55822,25 +55832,100 @@ function _artMapRebuildBuffer() {
octx.scale(scale, scale);
octx.translate(-minX, -minY);
// Draw edges (only between visible nodes)
// Build node lookup
if (!_artMap._nodeById) {
_artMap._nodeById = {};
placed.forEach(n => { _artMap._nodeById[n.id] = n; });
}
// Draw ALL nodes — similar first (background), watchlist on top
// Draw edges (connection lines between related nodes)
if (_artMap.edges && _artMap.edges.length > 0) {
octx.lineWidth = 1;
octx.strokeStyle = 'rgba(138,43,226,0.08)';
octx.beginPath();
for (const edge of _artMap.edges) {
const s = _artMap._nodeById[edge.source];
const t = _artMap._nodeById[edge.target];
if (!s || !t || (s.opacity || 0) < 0.05 || (t.opacity || 0) < 0.05) continue;
octx.moveTo(s.x, s.y);
octx.lineTo(t.x, t.y);
}
octx.stroke();
}
// Draw ALL nodes — genre labels first, similar next, watchlist on top
const hideSimilar = _artMap._hideSimilar || false;
for (let pass = 0; pass < 2; pass++) {
const isWatchlistPass = pass === 1;
// Pass 0: genre labels, Pass 1: similar/ring2, Pass 2: watchlist/center/ring1
for (let pass = 0; pass < 3; pass++) {
for (const n of visible) {
if ((n.type === 'watchlist') !== isWatchlistPass) continue;
if (hideSimilar && n.type !== 'watchlist') continue;
if (pass === 0 && n._isLabel) { /* draw */ }
else if (pass === 1 && !n._isLabel && n.type !== 'watchlist' && n.type !== 'center' && n.ring !== 1) { /* draw */ }
else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ }
else continue;
if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue;
const op = n.opacity || 0;
if (op < 0.01) continue;
const r = n.radius;
const isW = n.type === 'watchlist';
const isW = n.type === 'watchlist' || n.type === 'center';
octx.globalAlpha = op;
// Watchlist glow ring
// Genre label node — transparent circle with large text
if (n._isLabel) {
octx.globalAlpha = 0.6;
octx.beginPath();
octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
octx.fillStyle = 'rgba(138,43,226,0.04)';
octx.fill();
octx.strokeStyle = 'rgba(138,43,226,0.08)';
octx.lineWidth = 1;
octx.stroke();
const labelSize = Math.max(12, n.radius * 0.25);
octx.font = `800 ${labelSize}px system-ui`;
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillStyle = 'rgba(138,43,226,0.35)';
octx.fillText(n.name, n.x, n.y - labelSize * 0.3);
octx.font = `500 ${labelSize * 0.5}px system-ui`;
octx.fillStyle = 'rgba(255,255,255,0.15)';
octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5);
octx.globalAlpha = 1;
continue;
}
// Render quality based on node size in buffer pixels
const rScaled = r * scale;
const isSmall = rScaled < 8;
const isTiny = rScaled < 3;
// Tiny nodes: just a colored dot (no clip, no image, no text)
if (isTiny) {
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
octx.fillStyle = isW ? '#6b21a8' : '#2a2a40';
octx.fill();
continue;
}
// Small nodes: filled circle + border, no image clip
if (isSmall) {
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
const img = _artMap.images[n.id];
if (img) {
octx.save(); octx.clip();
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
octx.restore();
} else {
octx.fillStyle = isW ? '#1a0a30' : '#141420';
octx.fill();
}
octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)';
octx.lineWidth = isW ? 1.5 : 0.5;
octx.stroke();
continue;
}
// Full quality: glow + clip + image + text
if (isW) {
octx.beginPath();
octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2);
@ -55849,7 +55934,6 @@ function _artMapRebuildBuffer() {
octx.stroke();
}
// Circle clip + image
octx.save();
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
@ -55859,7 +55943,6 @@ function _artMapRebuildBuffer() {
const img = _artMap.images[n.id];
if (img) {
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
// Dark overlay for text readability
octx.fillStyle = 'rgba(0,0,0,0.45)';
octx.fillRect(n.x - r, n.y - r, r * 2, r * 2);
} else {
@ -55868,14 +55951,12 @@ function _artMapRebuildBuffer() {
}
octx.restore();
// Border
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)';
octx.lineWidth = isW ? 2 : 0.5;
octx.stroke();
// Name — CENTERED in bubble, shown on ALL nodes
const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3);
octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`;
octx.textAlign = 'center';
@ -56200,12 +56281,654 @@ function artMapShowShortcuts() {
document.body.appendChild(overlay);
}
function openArtistMapGenre() {
showToast('Genre Map coming soon', 'info');
async function openArtistMapGenre() {
// Show picker immediately — uses lightweight genre list endpoint
const genre = await _showGenrePickerModal();
if (!genre) return;
_openGenreMapWithSelection(genre);
}
function openArtistMapExplorer() {
showToast('Artist Explorer coming soon', 'info');
async function _showGenrePickerModal() {
return new Promise(resolve => {
const existing = document.getElementById('artmap-genre-picker');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'artmap-genre-picker';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
overlay.innerHTML = `
<div class="artmap-genre-picker-modal">
<div class="artmap-genre-picker-header">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><line x1="2" y1="12" x2="22" y2="12"/>
</svg>
<div>
<h3>Select a Genre</h3>
<p>Choose a genre to explore its artists</p>
</div>
</div>
<input type="text" class="artmap-genre-picker-search" placeholder="Filter genres..." oninput="_filterGenrePicker(this.value)">
<div class="artmap-genre-picker-list" id="artmap-genre-picker-list">
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading genres...</div></div>
</div>
</div>
`;
document.body.appendChild(overlay);
// Use cached data or fetch
const renderGenreList = (data) => {
if (!data?.success || !data?.genres?.length) {
document.getElementById('artmap-genre-picker-list').innerHTML = '<div class="ya-info-empty">No genres found</div>';
return;
}
const list = document.getElementById('artmap-genre-picker-list');
list.innerHTML = data.genres.map(g => `
<div class="artmap-genre-picker-item" data-genre="${escapeHtml(g.name)}" onclick="document.getElementById('artmap-genre-picker')._resolve('${escapeForInlineJs(g.name)}')">
<div class="artmap-genre-picker-name">${escapeHtml(g.name)}</div>
<div class="artmap-genre-picker-count">${g.count} artists</div>
</div>
`).join('');
};
if (window._artMapGenreList) {
renderGenreList(window._artMapGenreList);
} else {
fetch('/api/discover/artist-map/genre-list')
.then(r => r.json())
.then(data => { window._artMapGenreList = data; renderGenreList(data); })
.catch(() => { document.getElementById('artmap-genre-picker-list').innerHTML = '<div class="ya-info-empty">Error loading genres</div>'; });
}
overlay._resolve = (genre) => { overlay.remove(); resolve(genre); };
});
}
function _switchGenre(genre) {
_artMap._skipSectionToggle = true;
_openGenreMapWithSelection(genre);
}
function _filterGenreSidebar(query) {
const q = query.toLowerCase();
document.querySelectorAll('.artmap-genre-sidebar-item').forEach(el => {
el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none';
});
}
async function _changeGenre() {
const genre = await _showGenrePickerModal();
if (!genre) return;
_artMap._skipSectionToggle = true;
_openGenreMapWithSelection(genre);
}
function _filterGenrePicker(query) {
const q = query.toLowerCase();
document.querySelectorAll('.artmap-genre-picker-item').forEach(el => {
el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none';
});
}
async function _openGenreMapWithSelection(selectedGenre) {
const container = document.getElementById('artist-map-container');
if (!container) return;
const skipToggle = _artMap._skipSectionToggle;
_artMap._skipSectionToggle = false;
if (!skipToggle) {
document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => {
el._prevDisplay = el.style.display;
el.style.display = 'none';
});
}
container.style.display = 'flex';
// Show + populate genre sidebar
const sidebar = document.getElementById('artmap-genre-sidebar');
const genreListData = window._artMapGenreList || window._artMapGenreData;
if (sidebar && genreListData?.genres) {
sidebar.style.display = 'flex';
const list = document.getElementById('artmap-genre-sidebar-list');
if (list) {
list.innerHTML = genreListData.genres.map(g => `
<div class="artmap-genre-sidebar-item ${g.name === selectedGenre ? 'active' : ''}"
data-genre="${escapeHtml(g.name)}"
onclick="_switchGenre('${escapeForInlineJs(g.name)}')">
<span class="artmap-genre-sidebar-name">${escapeHtml(g.name)}</span>
<span class="artmap-genre-sidebar-count">${g.count}</span>
</div>
`).join('');
}
}
const canvas = document.getElementById('artist-map-canvas');
const contentRow = canvas.parentElement;
_artMap.canvas = canvas;
_artMap.ctx = canvas.getContext('2d');
_artMap.width = canvas.clientWidth || (container.clientWidth - (sidebar?.offsetWidth || 0));
_artMap.height = contentRow?.clientHeight || (container.clientHeight - 50);
canvas.width = _artMap.width * window.devicePixelRatio;
canvas.height = _artMap.height * window.devicePixelRatio;
canvas.style.width = _artMap.width + 'px';
canvas.style.height = _artMap.height + 'px';
_artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
_artMap.offsetX = _artMap.width / 2;
_artMap.offsetY = _artMap.height / 2;
_artMap.placed = [];
_artMap.edges = [];
_artMap.images = {};
_artMap._nodeById = null;
_artMap.dirty = true;
// Show loading
const loadingEl = document.createElement('div');
loadingEl.id = 'artist-map-loading';
loadingEl.innerHTML = `<div class="artist-map-loading-content"><div class="watch-all-loading-spinner"></div><div class="artist-map-loading-text" id="artmap-genre-loading-text">Loading genre map...</div></div>`;
container.appendChild(loadingEl);
// Update toolbar
document.querySelector('.artmap-brand-text').textContent = 'Genre Map';
document.getElementById('artist-map-stats').textContent = 'Loading...';
try {
// Use cached data from picker or fetch fresh
const data = window._artMapGenreData || await fetch('/api/discover/artist-map/genres').then(r => r.json());
const loadingText = document.getElementById('artmap-genre-loading-text');
if (!data.success || !data.nodes.length) {
if (loadingText) loadingText.textContent = 'No artists with genre data found.';
return;
}
// Find the selected genre + closely related genres (high artist overlap)
const allGenres = data.genres;
const primary = allGenres.find(g => g.name === selectedGenre);
if (!primary) {
if (loadingText) loadingText.textContent = `Genre "${selectedGenre}" not found.`;
return;
}
const primarySet = new Set(primary.artist_ids);
// Find up to 4 related genres by artist overlap
const related = allGenres
.filter(g => g.name !== selectedGenre)
.map(g => {
const overlap = g.artist_ids.filter(id => primarySet.has(id)).length;
return { ...g, overlap };
})
.filter(g => g.overlap > primarySet.size * 0.1) // At least 10% overlap
.sort((a, b) => b.overlap - a.overlap)
.slice(0, 4);
const genres = [primary, ...related];
const totalArtists = genres.reduce((sum, g) => sum + g.artist_ids.length, 0);
document.getElementById('artist-map-stats').innerHTML =
`<span class="artmap-genre-change" onclick="event.stopPropagation(); _changeGenre()" title="Change genre">${escapeHtml(selectedGenre)} ▾</span> · ${genres.length} genre${genres.length > 1 ? 's' : ''} · ${totalArtists} artists`;
const WR = _artMap.WATCHLIST_R;
const BUF = _artMap.BUFFER;
const maxPerGenre = 500;
const nodeR = WR * 0.2;
// Calculate actual cluster radius for each genre based on ring count
function getClusterRadius(artistCount) {
const count = Math.min(artistCount, maxPerGenre);
let ringDist = WR + nodeR * 2 + BUF;
let placed = 0;
while (placed < count) {
const circ = 2 * Math.PI * ringDist;
const inRing = Math.max(1, Math.floor(circ / (nodeR * 2 + BUF)));
placed += Math.min(inRing, count - placed);
ringDist += nodeR * 2 + BUF;
}
return ringDist;
}
// Pre-compute cluster radii
genres.forEach(g => { g._clusterR = getClusterRadius(g.artist_ids.length); });
// Golden spiral placement
genres.forEach((g, i) => {
if (i === 0) { g._cx = 0; g._cy = 0; }
else {
const angle = i * 2.399963;
const r = g._clusterR * Math.sqrt(i) * 0.9;
g._cx = Math.cos(angle) * r;
g._cy = Math.sin(angle) * r;
}
});
// Push apart using actual cluster radii — no overlap possible
for (let pass = 0; pass < 80; pass++) {
let moved = false;
for (let i = 0; i < genres.length; i++) {
for (let j = i + 1; j < genres.length; j++) {
const dx = genres[j]._cx - genres[i]._cx;
const dy = genres[j]._cy - genres[i]._cy;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const minDist = genres[i]._clusterR + genres[j]._clusterR + BUF * 4;
if (dist < minDist) {
const push = (minDist - dist) / 2 + 1;
genres[i]._cx -= (dx / dist) * push; genres[i]._cy -= (dy / dist) * push;
genres[j]._cx += (dx / dist) * push; genres[j]._cy += (dy / dist) * push;
moved = true;
}
}
}
if (!moved) break;
}
let placedCount = 0;
// Place genre labels as big watchlist-style bubbles
for (const g of genres) {
_artMap.placed.push({
id: `genre_${g.name}`, name: g.name.toUpperCase(),
x: g._cx, y: g._cy, radius: WR, opacity: 1,
type: 'genre_label', image_url: '', genres: [g.name],
_isLabel: true, _count: g.count
});
}
// Place artists in concentric rings — deterministic O(1) per node, handles 10K+ instantly
let genreIdx = 0;
async function placeGenreArtists() {
for (; genreIdx < genres.length; genreIdx++) {
const genre = genres[genreIdx];
const artists = genre.artist_ids.slice(0, maxPerGenre);
const sorted = artists.map(nid => data.nodes[nid]).filter(Boolean).sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
let ringDist = WR + nodeR * 2 + BUF;
let ringNum = 0;
let placed = 0;
while (placed < sorted.length) {
const circumference = 2 * Math.PI * ringDist;
const nodesInRing = Math.max(1, Math.floor(circumference / (nodeR * 2 + BUF)));
const count = Math.min(nodesInRing, sorted.length - placed);
const angleStep = (2 * Math.PI) / nodesInRing;
const angleOffset = ringNum * 0.618;
for (let i = 0; i < count; i++) {
const n = sorted[placed + i];
if (!n) continue;
const isW = n.type === 'watchlist' || n.type === 'center';
const r = isW ? nodeR * 1.5 : nodeR;
const angle = angleOffset + i * angleStep;
_artMap.placed.push({
id: placedCount + 1000, _origId: n.id, name: n.name,
x: genre._cx + Math.cos(angle) * ringDist,
y: genre._cy + Math.sin(angle) * ringDist,
radius: r, opacity: 1,
type: isW ? 'watchlist' : 'similar',
image_url: n.image_url || '', genres: n.genres || [],
spotify_id: n.spotify_id || '', itunes_id: n.itunes_id || '',
deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '',
});
placedCount++;
}
placed += count;
ringDist += nodeR * 2 + BUF;
ringNum++;
}
if (loadingText) loadingText.textContent = `Placing artists... ${genreIdx + 1}/${genres.length} genres (${placedCount} placed)`;
if (genreIdx % 5 === 0) await new Promise(r => setTimeout(r, 0));
}
}
await placeGenreArtists();
// Build edges: connect artists that appear in multiple genre clusters
_artMap.edges = [];
const artistNodes = {};
_artMap.placed.forEach(n => {
if (n._origId != null) {
if (!artistNodes[n._origId]) artistNodes[n._origId] = [];
artistNodes[n._origId].push(n.id);
}
});
Object.values(artistNodes).forEach(ids => {
if (ids.length > 1) {
for (let i = 0; i < ids.length - 1; i++) {
_artMap.edges.push({ source: ids[i], target: ids[i + 1], weight: 5 });
}
}
});
_artMap._nodeById = {};
_artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; });
// Auto-zoom
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
_artMap.placed.forEach(n => {
minX = Math.min(minX, n.x - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
minY = Math.min(minY, n.y - n.radius);
maxY = Math.max(maxY, n.y + n.radius);
});
const mapW = maxX - minX + 200, mapH = maxY - minY + 200;
_artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
_artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom;
_artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom;
_artMapSetupInteraction(canvas);
// Load images + render
if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`;
setTimeout(async () => {
const imgNodes = _artMap.placed.filter(n => n.image_url && !n._isLabel);
let loaded = 0;
const CONCURRENT = 20;
let idx = 0;
async function loadBatch() {
const batch = [];
while (idx < imgNodes.length && batch.length < CONCURRENT) {
const n = imgNodes[idx++];
batch.push(_artMapLoadImage(n.image_url)
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.finally(() => { loaded++; }));
}
if (batch.length) await Promise.all(batch);
if (idx < imgNodes.length) return loadBatch();
}
await loadBatch();
_artMap.dirty = true;
_artMapRender();
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
}, 50);
_artMap.dirty = true;
_artMapRender();
} catch (err) {
console.error('Genre map error:', err);
const lt = container.querySelector('.artist-map-loading-text');
if (lt) lt.textContent = 'Error loading genre map';
}
}
function openArtistMapExplorerDirect(name) {
if (!name) return;
// Already in map — just reload with new data, don't re-hide sections
_artMap._skipSectionToggle = true;
_openArtistMapExplorerWithName(name);
}
async function openArtistMapExplorer() {
const name = await _showArtistMapSearchPrompt();
if (!name) return;
_openArtistMapExplorerWithName(name);
}
async function _openArtistMapExplorerWithName(name) {
const container = document.getElementById('artist-map-container');
if (!container) return;
const skipToggle = _artMap._skipSectionToggle;
_artMap._skipSectionToggle = false;
if (!skipToggle) {
document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => {
el._prevDisplay = el.style.display;
el.style.display = 'none';
});
}
container.style.display = 'flex';
const canvas = document.getElementById('artist-map-canvas');
_artMap.canvas = canvas;
_artMap.ctx = canvas.getContext('2d');
_artMap.width = container.clientWidth;
_artMap.height = container.clientHeight - 50;
canvas.width = _artMap.width * window.devicePixelRatio;
canvas.height = _artMap.height * window.devicePixelRatio;
canvas.style.width = _artMap.width + 'px';
canvas.style.height = _artMap.height + 'px';
_artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
_artMap.offsetX = _artMap.width / 2;
_artMap.offsetY = _artMap.height / 2;
_artMap.placed = [];
_artMap.edges = [];
_artMap.images = {};
_artMap._nodeById = null;
_artMap.dirty = true;
const loadingEl = document.createElement('div');
loadingEl.id = 'artist-map-loading';
loadingEl.innerHTML = `<div class="artist-map-loading-content"><div class="watch-all-loading-spinner"></div><div class="artist-map-loading-text">Exploring ${escapeHtml(name)}...</div></div>`;
container.appendChild(loadingEl);
document.querySelector('.artmap-brand-text').textContent = 'Artist Explorer';
try {
const resp = await fetch(`/api/discover/artist-map/explore?name=${encodeURIComponent(name.trim())}`);
const data = await resp.json();
if (!data.success || !data.nodes.length) {
const lt = document.querySelector('.artist-map-loading-text');
if (lt) {
lt.textContent = resp.status === 404
? `"${name}" doesn't appear to be a real artist. Try a different name.`
: `No data found for "${name}". Try a different artist.`;
}
setTimeout(() => {
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
closeArtistMap();
}, 2500);
return;
}
const ring1Count = data.nodes.filter(n => n.ring === 1).length;
const ring2Count = data.nodes.filter(n => n.ring === 2).length;
document.getElementById('artist-map-stats').textContent =
`${data.center} · ${ring1Count} similar · ${ring2Count} extended`;
_artMap.edges = data.edges;
const WR = _artMap.WATCHLIST_R;
const BUF = _artMap.BUFFER;
// Layout: center node at origin, ring 1 in circle around it, ring 2 around ring 1
const centerNode = data.nodes[0];
centerNode.x = 0; centerNode.y = 0;
centerNode.radius = WR * 1.2; // Extra large center
centerNode.opacity = 1;
centerNode.type = 'center';
_artMap.placed.push(centerNode);
const CELL = WR * 2 + BUF * 2;
const grid = {};
function _gk(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; }
function _ga(n) { const k = _gk(n.x, n.y); if (!grid[k]) grid[k] = []; grid[k].push(n); }
function _gc(x, y, r) {
const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL);
for (let dx = -3; dx <= 3; dx++) for (let dy = -3; dy <= 3; dy++) {
const cell = grid[`${cx+dx},${cy+dy}`];
if (!cell) continue;
for (const p of cell) {
const ddx = x-p.x, ddy = y-p.y;
if (ddx*ddx+ddy*ddy < (r+p.radius+BUF)*(r+p.radius+BUF)) return true;
}
}
return false;
}
_ga(centerNode);
// Place ring 1 in a circle
const ring1 = data.nodes.filter(n => n.ring === 1);
const ring1Dist = WR * 2.5;
ring1.forEach((n, i) => {
const angle = (i / ring1.length) * Math.PI * 2;
const rank = n.rank || 5;
n.radius = WR * 0.4 + (10 - rank) * WR * 0.03;
n.opacity = 1;
// Find non-colliding position near ideal
let placed = false;
for (let dist = ring1Dist; dist < ring1Dist + WR * 3; dist += n.radius * 0.5) {
for (let ao = 0; ao < 6; ao++) {
const a = angle + (ao * 0.1 * (ao % 2 ? 1 : -1));
const tx = Math.cos(a) * dist;
const ty = Math.sin(a) * dist;
if (!_gc(tx, ty, n.radius)) {
n.x = tx; n.y = ty;
_artMap.placed.push(n);
_ga(n);
placed = true;
break;
}
}
if (placed) break;
}
});
// Place ring 2 around their ring 1 sources
const ring2 = data.nodes.filter(n => n.ring === 2);
const nodeById = {};
_artMap.placed.forEach(n => { nodeById[n.id] = n; });
ring2.forEach(n => {
// Find the ring 1 node that connects to this
const edge = data.edges.find(e => e.target === n.id);
const src = edge ? nodeById[edge.source] : null;
const cx = src ? src.x : 0;
const cy = src ? src.y : 0;
n.radius = WR * 0.2 + (n.popularity || 0) / 100 * WR * 0.1;
n.opacity = 1;
const startDist = (src ? src.radius : WR) + n.radius + BUF;
let placed = false;
for (let dist = startDist; dist < startDist + WR * 2; dist += n.radius * 0.5) {
const steps = Math.max(8, Math.floor(dist * 0.08));
const off = Math.random() * Math.PI * 2;
for (let a = 0; a < steps; a++) {
const angle = off + (a / steps) * Math.PI * 2;
const tx = cx + Math.cos(angle) * dist;
const ty = cy + Math.sin(angle) * dist;
if (!_gc(tx, ty, n.radius)) {
n.x = tx; n.y = ty;
_artMap.placed.push(n);
_ga(n);
placed = true;
break;
}
}
if (placed) break;
}
});
// Build node lookup for edges
_artMap._nodeById = {};
_artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; });
// Auto-zoom
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
_artMap.placed.forEach(n => {
minX = Math.min(minX, n.x - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
minY = Math.min(minY, n.y - n.radius);
maxY = Math.max(maxY, n.y + n.radius);
});
const mapW = maxX - minX + 200, mapH = maxY - minY + 200;
_artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
_artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom;
_artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom;
_artMapSetupInteraction(canvas);
// Load images
const loadingText = container.querySelector('.artist-map-loading-text');
if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`;
setTimeout(async () => {
const imgNodes = _artMap.placed.filter(n => n.image_url);
let loaded = 0;
const CONCURRENT = 20;
let idx = 0;
async function loadBatch() {
const batch = [];
while (idx < imgNodes.length && batch.length < CONCURRENT) {
const n = imgNodes[idx++];
batch.push(_artMapLoadImage(n.image_url)
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.finally(() => { loaded++; }));
}
if (batch.length) await Promise.all(batch);
if (idx < imgNodes.length) return loadBatch();
}
await loadBatch();
_artMap.dirty = true;
_artMapRender();
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
}, 50);
_artMap.dirty = true;
_artMapRender();
} catch (err) {
console.error('Artist explorer error:', err);
const lt = container.querySelector('.artist-map-loading-text');
if (lt) lt.textContent = 'Error loading explorer';
}
}
function _showArtistMapSearchPrompt() {
return new Promise(resolve => {
const existing = document.getElementById('artmap-search-prompt');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'artmap-search-prompt';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
overlay.innerHTML = `
<div class="artmap-search-prompt-modal">
<div class="artmap-search-prompt-header">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/>
</svg>
<div>
<h3>Artist Explorer</h3>
<p>Enter an artist to explore their connections</p>
</div>
</div>
<input type="text" id="artmap-explore-input" class="artmap-explore-input" placeholder="Artist name..." autofocus>
<div class="artmap-search-prompt-actions">
<button class="ya-header-btn" onclick="document.getElementById('artmap-search-prompt').remove()">Cancel</button>
<button class="ya-header-btn ya-viewall-btn" id="artmap-explore-go">
<span>Explore</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#artmap-explore-input');
const goBtn = overlay.querySelector('#artmap-explore-go');
const submit = () => {
const val = input.value.trim();
overlay.remove();
resolve(val || null);
};
goBtn.onclick = submit;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
setTimeout(() => input.focus(), 50);
});
}
function artMapToggleSimilar() {
@ -56217,12 +56940,30 @@ function artMapToggleSimilar() {
showToast(_artMap._hideSimilar ? 'Showing watchlist only' : 'Showing all artists', 'info', 1500);
}
function _artMapLoadImage(url) {
// Try direct CORS fetch first (zero server load, works for Spotify/iTunes/Discogs)
return fetch(url, { mode: 'cors' })
.then(r => r.ok ? r.blob() : Promise.reject('not ok'))
.then(b => createImageBitmap(b))
.catch(() => {
// Fallback: server proxy for CDNs without CORS headers
return fetch('/api/image-proxy?url=' + encodeURIComponent(url))
.then(r => r.ok ? r.blob() : null)
.then(b => b ? createImageBitmap(b) : null)
.catch(() => null);
});
}
function _artMapHideContextMenu() {
const m = document.getElementById('artist-map-context');
if (m) m.style.display = 'none';
}
function _artMapSetupInteraction(canvas) {
// Prevent stacking listeners on repeated opens
if (canvas._artMapListenersAttached) return;
canvas._artMapListenersAttached = true;
let isPanning = false, panStartX = 0, panStartY = 0;
canvas.addEventListener('wheel', (e) => {
@ -56236,7 +56977,10 @@ function _artMapSetupInteraction(canvas) {
_artMap.offsetX = mx - (mx - _artMap.offsetX) * (newZoom / _artMap.zoom);
_artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom);
_artMap.zoom = newZoom;
_artMapRender();
_artMapRender(); // fast blit
// Debounce hi-res rebuild after zoom settles
clearTimeout(_artMap._zoomRebuild);
_artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300);
}, { passive: false });
let clickStart = null;
@ -56269,7 +57013,7 @@ function _artMapSetupInteraction(canvas) {
e.preventDefault();
const { nx, ny } = _artMapScreenToWorld(e, canvas);
const node = _artMapHitTest(nx, ny);
if (!node) { _artMapHideContextMenu(); return; }
if (!node || node._isLabel) { _artMapHideContextMenu(); return; }
const menu = document.getElementById('artist-map-context') || (() => {
const m = document.createElement('div');
@ -56484,14 +57228,20 @@ async function openYourArtistInfoModal_direct(node) {
if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; }
}
// Gather connected artists from the map edges
// Gather ALL connected artists from map edges (both directions)
const related = [];
const relatedIds = new Set();
const nById = _artMap._nodeById || {};
if (node.type === 'watchlist') {
_artMap.edges.forEach(e => { if (e.source === node.id && nById[e.target]) related.push(nById[e.target]); });
} else {
_artMap.edges.forEach(e => { if (e.target === node.id && nById[e.source]) related.push(nById[e.source]); });
}
_artMap.edges.forEach(e => {
if (e.source === node.id && nById[e.target] && !relatedIds.has(e.target)) {
related.push(nById[e.target]);
relatedIds.add(e.target);
}
if (e.target === node.id && nById[e.source] && !relatedIds.has(e.source)) {
related.push(nById[e.source]);
relatedIds.add(e.source);
}
});
const poolEntry = {
id: node.id,

View file

@ -2270,6 +2270,23 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border-color: rgba(var(--accent-rgb), 0.15);
}
.helper-whats-new-date {
font-size: 11px;
font-weight: 600;
color: rgba(138, 43, 226, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 10px 0 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.helper-whats-new-date:first-child {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.helper-whats-new-title {
font-size: 13px;
font-weight: 600;
@ -29796,6 +29813,48 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.artmap-brand-icon { color: rgba(138,43,226,0.7); }
.artmap-brand-text { font-size: 15px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
.artmap-stats { font-size: 11px; color: rgba(255,255,255,0.25); font-weight: 500; }
/* Genre sidebar */
.artmap-genre-sidebar {
width: 200px; flex-shrink: 0; flex-direction: column;
background: rgba(14,14,22,0.98); border-right: 1px solid rgba(255,255,255,0.06);
z-index: 2;
}
.artmap-genre-sidebar-header {
padding: 12px 14px 8px; display: flex; flex-direction: column; gap: 8px;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.3);
text-transform: uppercase; letter-spacing: 0.8px;
}
.artmap-genre-sidebar-search {
padding: 6px 10px; border-radius: 7px; font-size: 11px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
color: #fff; outline: none; width: 100%; box-sizing: border-box;
}
.artmap-genre-sidebar-search::placeholder { color: rgba(255,255,255,0.2); }
.artmap-genre-sidebar-search:focus { border-color: rgba(138,43,226,0.3); }
.artmap-genre-sidebar-list {
flex: 1; overflow-y: auto; padding: 4px 6px;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.06) transparent;
}
.artmap-genre-sidebar-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 10px; border-radius: 8px; cursor: pointer;
transition: background 0.12s; margin-bottom: 1px;
}
.artmap-genre-sidebar-item:hover { background: rgba(255,255,255,0.04); }
.artmap-genre-sidebar-item.active { background: rgba(138,43,226,0.12); }
.artmap-genre-sidebar-name {
font-size: 12px; font-weight: 500; color: rgba(255,255,255,0.6);
text-transform: capitalize; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.artmap-genre-sidebar-item.active .artmap-genre-sidebar-name { color: rgba(138,43,226,0.9); font-weight: 600; }
.artmap-genre-sidebar-count { font-size: 10px; color: rgba(255,255,255,0.15); flex-shrink: 0; }
.artmap-genre-change {
color: rgba(138,43,226,0.7); cursor: pointer; font-weight: 600;
transition: color 0.15s; text-transform: capitalize;
}
.artmap-genre-change:hover { color: rgba(138,43,226,1); }
/* Center: search */
.artmap-nav-center { flex: 1; display: flex; justify-content: center; max-width: 320px; margin: 0 auto; }
@ -29887,6 +29946,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
text-transform: capitalize;
}
.artmap-content-row { display: flex; flex: 1; overflow: hidden; }
#artist-map-canvas { flex: 1; display: block; cursor: grab; }
#artist-map-canvas:active { cursor: grabbing; }
#artist-map-loading {
@ -29916,6 +29976,61 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.artmap-ctx-item:hover { background: rgba(138,43,226,0.12); color: #fff; }
.artmap-ctx-item span { font-size: 14px; width: 18px; text-align: center; }
/* Explorer search prompt */
.artmap-search-prompt-modal {
background: linear-gradient(165deg, #1e1e32 0%, #181828 100%);
border-radius: 16px; width: 400px; max-width: 90vw; padding: 28px;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06);
}
.artmap-search-prompt-header {
display: flex; align-items: center; gap: 14px; margin-bottom: 20px;
color: rgba(138,43,226,0.7);
}
.artmap-search-prompt-header h3 { font-size: 17px; font-weight: 700; color: #fff; margin: 0; }
.artmap-search-prompt-header p { font-size: 12px; color: rgba(255,255,255,0.35); margin: 3px 0 0; }
.artmap-explore-input {
width: 100%; padding: 12px 16px; border-radius: 12px; font-size: 15px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: #fff; outline: none; margin-bottom: 18px; transition: border-color 0.2s;
box-sizing: border-box;
}
.artmap-explore-input:focus { border-color: rgba(138,43,226,0.4); }
.artmap-explore-input::placeholder { color: rgba(255,255,255,0.2); }
.artmap-search-prompt-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* Genre picker modal */
.artmap-genre-picker-modal {
background: linear-gradient(165deg, #1e1e32 0%, #181828 100%);
border-radius: 16px; width: 420px; max-width: 90vw; max-height: 75vh;
display: flex; flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06);
}
.artmap-genre-picker-header {
display: flex; align-items: center; gap: 14px; padding: 24px 24px 16px;
color: rgba(138,43,226,0.7); border-bottom: 1px solid rgba(255,255,255,0.05);
}
.artmap-genre-picker-header h3 { font-size: 17px; font-weight: 700; color: #fff; margin: 0; }
.artmap-genre-picker-header p { font-size: 12px; color: rgba(255,255,255,0.35); margin: 3px 0 0; }
.artmap-genre-picker-search {
margin: 12px 20px 0; padding: 10px 14px; border-radius: 10px; font-size: 13px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07);
color: #fff; outline: none; box-sizing: border-box;
}
.artmap-genre-picker-search::placeholder { color: rgba(255,255,255,0.2); }
.artmap-genre-picker-search:focus { border-color: rgba(138,43,226,0.4); }
.artmap-genre-picker-list {
flex: 1; overflow-y: auto; padding: 8px 12px 16px;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
}
.artmap-genre-picker-item {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 14px; border-radius: 10px; cursor: pointer;
transition: background 0.15s;
}
.artmap-genre-picker-item:hover { background: rgba(138,43,226,0.08); }
.artmap-genre-picker-name { font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.8); text-transform: capitalize; }
.artmap-genre-picker-count { font-size: 11px; color: rgba(255,255,255,0.25); }
/* Loading state */
.ya-loading {
display: flex; align-items: center; gap: 12px; padding: 30px 20px;