diff --git a/web_server.py b/web_server.py index f269ce5c..2ca75d47 100644 --- a/web_server.py +++ b/web_server.py @@ -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': , 'ts': } +_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""" diff --git a/webui/index.html b/webui/index.html index a817d2cf..eae61d20 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3166,7 +3166,16 @@ - +
+ + +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 50757983..27ddd288 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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() {
${notes.map(h => { + if (h.date) return `
${h.date}
`; const hasTarget = !!(h.selector || h.page); const linkText = h.selector ? 'Show me β†’' : h.page ? 'Go to page β†’' : ''; return ` diff --git a/webui/static/script.js b/webui/static/script.js index c7119dbd..2a897fa6 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) { : ``; footerEl.innerHTML = ` ${watchBtn} + + +
+ + `; + 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, diff --git a/webui/static/style.css b/webui/static/style.css index d854fc6a..e41ec092 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;