diff --git a/web_server.py b/web_server.py index 40196a9a..22ffa0cf 100644 --- a/web_server.py +++ b/web_server.py @@ -41178,6 +41178,64 @@ def get_artist_map_data(): node['type'] = 'watchlist' break + # ── Backfill from metadata cache: batch-lookup all node names across all sources ── + # Single query to get ALL cached artist entries matching ANY node name + try: + all_names = list(set(_norm(n['name']) for n in nodes if n.get('name'))) + if all_names: + # Build case-insensitive IN clause via temp matching + # Lightweight query — no raw_json (can be huge) + cursor.execute(""" + SELECT entity_id, source, name, image_url, genres, popularity + FROM metadata_cache_entities + WHERE entity_type = 'artist' + """) + cache_rows = cursor.fetchall() + + # Index cache by normalized name → {source: {id, image_url, genres}} + cache_by_name = {} + for cr in cache_rows: + cn = _norm(cr['name'] or '') + if cn not in cache_by_name: + cache_by_name[cn] = {} + source = cr['source'] + genres = [] + if cr['genres']: + try: + genres = json.loads(cr['genres']) if isinstance(cr['genres'], str) else [] + except Exception: + pass + cache_by_name[cn][source] = { + 'id': cr['entity_id'], + 'image_url': cr['image_url'] or '', + 'genres': genres, + } + + # Apply cache data to nodes + source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'} + for n in nodes: + nn = _norm(n['name']) + cached = cache_by_name.get(nn) + if not cached: + continue + for source, field in source_id_map.items(): + if not n.get(field) and source in cached: + n[field] = cached[source]['id'] + # Backfill image if missing or local path + if not n.get('image_url') or not n['image_url'].startswith('http'): + for source in ('spotify', 'deezer', 'itunes'): + if source in cached and cached[source].get('image_url', '').startswith('http'): + n['image_url'] = cached[source]['image_url'] + break + # Backfill genres if missing + if not n.get('genres') or len(n.get('genres', [])) == 0: + for source in ('spotify', 'deezer', 'itunes', 'discogs'): + if source in cached and cached[source].get('genres'): + n['genres'] = cached[source]['genres'][:5] + break + except Exception as cache_err: + logger.debug(f"Artist map cache backfill error: {cache_err}") + return jsonify({ 'success': True, 'nodes': nodes, diff --git a/webui/static/script.js b/webui/static/script.js index 0f1d071f..b4d57f28 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -55009,6 +55009,7 @@ async function loadYourArtists() { // Store for modal access and render carousel cards window._yaArtists = {}; + window._yaActiveSource = data.active_source || 'spotify'; data.artists.forEach(a => { window._yaArtists[a.id] = a; }); carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join(''); @@ -55194,6 +55195,36 @@ async function openYourArtistInfoModal(poolId) { } } + // Related artists from map connections + const related = pool._related || []; + if (related.length > 0) { + const relLabel = pool.on_watchlist ? 'Similar Artists' : 'Connected To'; + bodyHTML += `
+
${relLabel}
+ +
`; + } + if (!bodyHTML) bodyHTML = '
No additional info available
'; if (bodyEl) bodyEl.innerHTML = bodyHTML; @@ -55204,7 +55235,7 @@ async function openYourArtistInfoModal(poolId) { : ``; footerEl.innerHTML = ` ${watchBtn} - @@ -55888,19 +55919,167 @@ function _artMapRender() { oc.height * z / s ); - // Draw hover highlight on main canvas (only 1 node, very fast) - if (_artMap.hoveredNode) { + // ── Interactive overlay (drawn on main canvas, not buffer) ── + const cFade = _artMap._constellationFade || 0; + if (cFade > 0 && (_artMap.hoveredNode || _artMap._constellationCache)) { + const n = _artMap.hoveredNode || (_artMap._constellationCache ? (_artMap._nodeById || {})[_artMap._constellationCache.nodeId] : null); + if (!n) { _artMap._constellationFade = 0; _artMap._constellationCache = null; } + if (n) { + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); + + // Cache connected node lookup (don't recompute every frame) + if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) { + const connectedIds = new Set(); + if (n.type === 'watchlist') { + for (const e of _artMap.edges) { + if (e.source === n.id) connectedIds.add(e.target); + } + } else { + const sourceIds = new Set(); + for (const e of _artMap.edges) { + if (e.target === n.id) sourceIds.add(e.source); + } + for (const sid of sourceIds) { + connectedIds.add(sid); + for (const e of _artMap.edges) { + if (e.source === sid) connectedIds.add(e.target); + } + } + } + const nById = _artMap._nodeById || {}; + _artMap._constellationCache = { + nodeId: n.id, + nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)], + }; + } + + const highlightNodes = _artMap._constellationCache.nodes; + + if (highlightNodes.length > 1) { + // Semi-transparent dark overlay on entire visible area + ctx.save(); + ctx.resetTransform(); + ctx.globalAlpha = 0.6 * cFade; + ctx.fillStyle = '#0a0a14'; + ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height); + ctx.globalAlpha = 1; + ctx.restore(); + + // Draw glowing connection lines + for (const cn of highlightNodes) { + if (cn === n) continue; + ctx.beginPath(); + ctx.moveTo(n.x, n.y); + ctx.lineTo(cn.x, cn.y); + // Gradient line + const lineGrad = ctx.createLinearGradient(n.x, n.y, cn.x, cn.y); + lineGrad.addColorStop(0, `rgba(138,43,226,${0.5 * cFade})`); + lineGrad.addColorStop(1, `rgba(138,43,226,${0.15 * cFade})`); + ctx.strokeStyle = lineGrad; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Redraw highlighted nodes on top + ctx.globalAlpha = cFade; + for (const hn of highlightNodes) { + const r = hn.radius; + const isW = hn.type === 'watchlist'; + const isHov = hn === n; + + // Glow + if (isHov) { + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(138,43,226,0.4)'; + ctx.lineWidth = 6; + ctx.stroke(); + } + + // Circle + image + ctx.save(); + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + const img = _artMap.images[hn.id]; + if (img) { + ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + } else { + ctx.fillStyle = isW ? '#1a0a30' : '#141420'; + ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + } + ctx.restore(); + + // Border + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)'; + ctx.lineWidth = isHov ? 3 : 1.5; + ctx.stroke(); + + // Name + const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3); + ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#fff'; + const maxC = isW ? 20 : 12; + const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + '…' : hn.name; + ctx.fillText(label, hn.x, hn.y); + } + ctx.globalAlpha = 1; + } else { + // Single node, no connections + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 3; + ctx.stroke(); + } + + ctx.restore(); + } // end if(n) + } else if (_artMap.hoveredNode && !_artMap._constellationActive) { + // Pre-constellation: just show a simple highlight ring (instant, no delay) const n = _artMap.hoveredNode; ctx.save(); ctx.translate(_artMap.offsetX, _artMap.offsetY); ctx.scale(z, z); ctx.beginPath(); - ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - ctx.lineWidth = 3; + ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); } + + // Click ripple animation + if (_artMap._ripple) { + const rip = _artMap._ripple; + const elapsed = performance.now() - rip.start; + const progress = elapsed / 400; // 400ms duration + if (progress < 1) { + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); + const ripR = rip.radius + rip.radius * progress * 0.5; + ctx.beginPath(); + ctx.arc(rip.x, rip.y, ripR, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(138,43,226,${0.5 * (1 - progress)})`; + ctx.lineWidth = 3 * (1 - progress); + ctx.stroke(); + ctx.restore(); + requestAnimationFrame(() => _artMapRender()); + } else { + _artMap._ripple = null; + } + } } function artMapSearch(query) { @@ -55969,6 +56148,22 @@ function _artMapShowTooltip(e, node) { tip.style.top = y + 'px'; } +function _artMapAnimateConstellation() { + if (_artMap._constellationActive && _artMap._constellationFade < 1) { + _artMap._constellationFade = Math.min(1, (_artMap._constellationFade || 0) + 0.08); + _artMapRender(); + requestAnimationFrame(_artMapAnimateConstellation); + } else if (!_artMap._constellationActive && _artMap._constellationFade > 0) { + _artMap._constellationFade = Math.max(0, _artMap._constellationFade - 0.1); + _artMapRender(); + if (_artMap._constellationFade > 0) { + requestAnimationFrame(_artMapAnimateConstellation); + } else { + _artMap._constellationCache = null; + } + } +} + function _artMapSetupInteraction(canvas) { let isPanning = false, panStartX = 0, panStartY = 0; @@ -55989,25 +56184,14 @@ function _artMapSetupInteraction(canvas) { let clickStart = null; canvas.addEventListener('mousedown', (e) => { - const { nx, ny } = _artMapScreenToWorld(e, canvas); - const node = _artMapHitTest(nx, ny); clickStart = { x: e.clientX, y: e.clientY, time: Date.now() }; - if (node) { - _artMap.dragNode = node; - } else { - isPanning = true; - panStartX = e.clientX; - panStartY = e.clientY; - } + isPanning = true; + panStartX = e.clientX; + panStartY = e.clientY; }); canvas.addEventListener('mousemove', (e) => { - if (_artMap.dragNode) { - const { nx, ny } = _artMapScreenToWorld(e, canvas); - _artMap.dragNode.x = nx; - _artMap.dragNode.y = ny; - _artMapRender(); - } else if (isPanning) { + if (isPanning) { _artMap.offsetX += e.clientX - panStartX; _artMap.offsetY += e.clientY - panStartY; panStartX = e.clientX; @@ -56019,30 +56203,59 @@ function _artMapSetupInteraction(canvas) { _artMap.hoveredNode = _artMapHitTest(nx, ny); canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab'; _artMapShowTooltip(e, _artMap.hoveredNode); - if (prev !== _artMap.hoveredNode) _artMapRender(); + if (prev !== _artMap.hoveredNode) { + // Reset constellation highlight timer + clearTimeout(_artMap._constellationTimer); + if (_artMap._constellationActive) { + _artMap._constellationActive = false; + _artMapAnimateConstellation(); // fade out + } + if (_artMap.hoveredNode) { + // Delay constellation effect by 800ms of sustained hover + _artMap._constellationTimer = setTimeout(() => { + if (_artMap.hoveredNode) { + _artMap._constellationActive = true; + _artMap._constellationFade = 0; + _artMap._constellationCache = null; + _artMapAnimateConstellation(); + } + }, 800); + } + _artMapRender(); + } } }); canvas.addEventListener('mouseup', (e) => { const wasDrag = clickStart && (Math.abs(e.clientX - clickStart.x) > 5 || Math.abs(e.clientY - clickStart.y) > 5); - if (_artMap.dragNode && !wasDrag) { - // It was a click, not a drag — open info modal - const node = _artMap.dragNode; - _artMap.dragNode = null; - if (node.spotify_id || node.itunes_id || node.deezer_id) { - openYourArtistInfoModal_direct(node); - } - } else { - _artMap.dragNode = null; - } isPanning = false; + + if (!wasDrag && clickStart) { + // It was a click — find the node under cursor + const { nx, ny } = _artMapScreenToWorld(e, canvas); + const node = _artMapHitTest(nx, ny); + if (node) { + _artMap._ripple = { x: node.x, y: node.y, radius: node.radius, start: performance.now() }; + _artMapRender(); + if (node.spotify_id || node.itunes_id || node.deezer_id) { + setTimeout(() => openYourArtistInfoModal_direct(node), 200); + } + } + } + clickStart = null; _artMapShowTooltip(e, null); }); canvas.addEventListener('mouseleave', () => { _artMapShowTooltip(null, null); - if (_artMap.hoveredNode) { _artMap.hoveredNode = null; _artMapRender(); } + clearTimeout(_artMap._constellationTimer); + if (_artMap._constellationActive) { + _artMap._constellationActive = false; + _artMapAnimateConstellation(); + } + _artMap.hoveredNode = null; + _artMapRender(); }); // Touch support — single finger pan, pinch to zoom @@ -56134,19 +56347,41 @@ function _artMapHitTest(wx, wy) { } async function openYourArtistInfoModal_direct(node) { - // Create a pool-like object from the map node for the info modal + // Determine best source ID — prefer active metadata source + let bestId = '', bestSource = ''; + // Check what the active source is + const activeSource = window._yaActiveSource || 'spotify'; + const sourceOrder = activeSource === 'spotify' ? ['spotify_id','itunes_id','deezer_id','discogs_id'] + : activeSource === 'itunes' ? ['itunes_id','spotify_id','deezer_id','discogs_id'] + : activeSource === 'deezer' ? ['deezer_id','spotify_id','itunes_id','discogs_id'] + : ['spotify_id','itunes_id','deezer_id','discogs_id']; + const sourceMap = {spotify_id:'spotify', itunes_id:'itunes', deezer_id:'deezer', discogs_id:'discogs'}; + for (const key of sourceOrder) { + if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; } + } + + // Gather connected artists from the map edges + const related = []; + 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]); }); + } + const poolEntry = { id: node.id, artist_name: node.name, - active_source_id: node.spotify_id || node.itunes_id || node.deezer_id || '', - active_source: node.spotify_id ? 'spotify' : node.itunes_id ? 'itunes' : node.deezer_id ? 'deezer' : '', + active_source_id: bestId, + active_source: bestSource, image_url: node.image_url || '', spotify_artist_id: node.spotify_id || '', itunes_artist_id: node.itunes_id || '', deezer_artist_id: node.deezer_id || '', - discogs_artist_id: '', + discogs_artist_id: node.discogs_id || '', source_services: [], on_watchlist: node.type === 'watchlist' ? 1 : 0, + _related: related, }; if (!window._yaArtists) window._yaArtists = {}; window._yaArtists[node.id] = poolEntry; diff --git a/webui/static/style.css b/webui/static/style.css index d146b1b8..dc8b370f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -29652,6 +29652,32 @@ body.helper-mode-active #dashboard-activity-feed:hover { .ya-info-stat-label { font-size: 10px; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.5px; } .ya-info-bio { font-size: 13px; color: rgba(255,255,255,0.4); line-height: 1.7; } .ya-info-empty { text-align: center; padding: 30px 0; color: rgba(255,255,255,0.2); font-size: 13px; } + +/* Related artists list in info modal */ +.ya-info-related { display: flex; flex-direction: column; gap: 2px; } +.ya-info-related-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: 10px; cursor: pointer; + transition: background 0.15s; +} +.ya-info-related-item:hover { background: rgba(255,255,255,0.04); } +.ya-info-related-img { + width: 36px; height: 36px; border-radius: 50%; overflow: hidden; + flex-shrink: 0; background: rgba(255,255,255,0.04); + display: flex; align-items: center; justify-content: center; +} +.ya-info-related-img img { width: 100%; height: 100%; object-fit: cover; } +.ya-info-related-img span { font-size: 14px; color: rgba(255,255,255,0.1); } +.ya-info-related-text { flex: 1; min-width: 0; } +.ya-info-related-name { + font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.8); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ya-info-related-badge { font-size: 9px; font-weight: 700; color: #fbbf24; margin-top: 1px; } +.ya-info-related-more { + text-align: center; padding: 6px; font-size: 11px; + color: rgba(255,255,255,0.25); +} .ya-info-footer { padding: 16px 24px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: center; gap: 10px; } /* ── Artist Map ── */