diff --git a/web_server.py b/web_server.py index d3e40466..07083de6 100644 --- a/web_server.py +++ b/web_server.py @@ -3594,6 +3594,243 @@ def debug_library_photos(): except Exception as e: return jsonify({"error": str(e)}), 500 +@app.route('/api/artist/similar//stream') +def get_similar_artists_stream(artist_name): + """ + Stream similar artists from MusicMap and match them to Spotify one by one + + Args: + artist_name: The artist name to find similar artists for + + Returns: + Server-Sent Events stream with each matched artist + """ + def generate(): + try: + print(f"🎵 Streaming similar artists for: {artist_name}") + + # Import required libraries + from bs4 import BeautifulSoup + + # Construct MusicMap URL + url_artist = artist_name.lower().replace(' ', '+') + musicmap_url = f'https://www.music-map.com/{url_artist}' + + print(f"🌐 Fetching MusicMap: {musicmap_url}") + + # Set headers to mimic a browser + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + } + + # Fetch MusicMap page + response = requests.get(musicmap_url, headers=headers, timeout=10) + response.raise_for_status() + + # Parse HTML + soup = BeautifulSoup(response.text, 'html.parser') + gnod_map = soup.find(id='gnodMap') + + if not gnod_map: + yield f"data: {json.dumps({'error': 'Could not find artist map on MusicMap'})}\n\n" + return + + # Extract similar artist names + all_anchors = gnod_map.find_all('a') + searched_artist_lower = artist_name.lower().strip() + + similar_artist_names = [] + for anchor in all_anchors: + artist_text = anchor.get_text(strip=True) + + # Skip if this is the searched artist + if artist_text.lower() == searched_artist_lower: + continue + + similar_artist_names.append(artist_text) + + print(f"📦 Found {len(similar_artist_names)} similar artists from MusicMap") + + # Initialize Spotify client + if not spotify_client or not spotify_client.is_authenticated(): + yield f"data: {json.dumps({'error': 'Spotify not authenticated'})}\n\n" + return + + # Match each artist to Spotify one by one and stream results + max_artists = 20 + matched_count = 0 + + for artist_name_to_match in similar_artist_names[:max_artists]: + try: + print(f"🔍 Matching to Spotify: {artist_name_to_match}") + + # Search Spotify for the artist + results = spotify_client.search_artists(artist_name_to_match, limit=1) + + if results and len(results) > 0: + spotify_artist = results[0] + + artist_data = { + 'id': spotify_artist.id, + 'name': spotify_artist.name, + 'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None, + 'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [], + 'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 + } + + # Stream this matched artist immediately + yield f"data: {json.dumps({'artist': artist_data})}\n\n" + matched_count += 1 + + print(f"✅ Matched and streamed: {spotify_artist.name}") + else: + print(f"❌ No Spotify match found for: {artist_name_to_match}") + + except Exception as match_error: + print(f"⚠️ Error matching {artist_name_to_match}: {match_error}") + continue + + # Send completion message + yield f"data: {json.dumps({'complete': True, 'total': matched_count})}\n\n" + print(f"✅ Streaming complete: {matched_count} artists matched") + + except requests.exceptions.RequestException as e: + print(f"❌ Error fetching MusicMap: {e}") + yield f"data: {json.dumps({'error': f'Failed to fetch from MusicMap: {str(e)}'})}\n\n" + + except Exception as e: + print(f"❌ Error streaming similar artists: {e}") + import traceback + traceback.print_exc() + yield f"data: {json.dumps({'error': str(e)})}\n\n" + + return Response(generate(), mimetype='text/event-stream') + +@app.route('/api/artist/similar/') +def get_similar_artists(artist_name): + """ + Get similar artists from MusicMap and match them to Spotify (legacy batch endpoint) + + Args: + artist_name: The artist name to find similar artists for + + Returns: + JSON with similar artists matched to Spotify data + """ + try: + print(f"🎵 Getting similar artists for: {artist_name}") + + # Import required libraries + from bs4 import BeautifulSoup + + # Construct MusicMap URL + url_artist = artist_name.lower().replace(' ', '+') + musicmap_url = f'https://www.music-map.com/{url_artist}' + + print(f"🌐 Fetching MusicMap: {musicmap_url}") + + # Set headers to mimic a browser + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + } + + # Fetch MusicMap page + response = requests.get(musicmap_url, headers=headers, timeout=10) + response.raise_for_status() + + # Parse HTML + soup = BeautifulSoup(response.text, 'html.parser') + gnod_map = soup.find(id='gnodMap') + + if not gnod_map: + return jsonify({ + "success": False, + "error": "Could not find artist map on MusicMap" + }), 404 + + # Extract similar artist names + all_anchors = gnod_map.find_all('a') + searched_artist_lower = artist_name.lower().strip() + + similar_artist_names = [] + for anchor in all_anchors: + artist_text = anchor.get_text(strip=True) + + # Skip if this is the searched artist + if artist_text.lower() == searched_artist_lower: + continue + + similar_artist_names.append(artist_text) + + print(f"📦 Found {len(similar_artist_names)} similar artists from MusicMap") + + # Initialize Spotify client + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({ + "success": False, + "error": "Spotify not authenticated" + }), 401 + + # Match each artist to Spotify (limit to first 20 for performance) + matched_artists = [] + max_artists = 20 + + for artist_name_to_match in similar_artist_names[:max_artists]: + try: + print(f"🔍 Matching to Spotify: {artist_name_to_match}") + + # Search Spotify for the artist + results = spotify_client.search_artists(artist_name_to_match, limit=1) + + if results and len(results) > 0: + spotify_artist = results[0] + + matched_artists.append({ + 'id': spotify_artist.id, + 'name': spotify_artist.name, + 'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None, + 'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [], + 'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 + }) + + print(f"✅ Matched: {spotify_artist.name}") + else: + print(f"❌ No Spotify match found for: {artist_name_to_match}") + + except Exception as match_error: + print(f"⚠️ Error matching {artist_name_to_match}: {match_error}") + continue + + print(f"✅ Successfully matched {len(matched_artists)} artists to Spotify") + + return jsonify({ + "success": True, + "artist": artist_name, + "similar_artists": matched_artists, + "total_found": len(similar_artist_names), + "total_matched": len(matched_artists) + }) + + except requests.exceptions.RequestException as e: + print(f"❌ Error fetching MusicMap: {e}") + return jsonify({ + "success": False, + "error": f"Failed to fetch from MusicMap: {str(e)}" + }), 500 + + except Exception as e: + print(f"❌ Error getting similar artists: {e}") + import traceback + traceback.print_exc() + return jsonify({ + "success": False, + "error": str(e) + }), 500 + @app.route('/api/artist//discography', methods=['GET']) def get_artist_discography(artist_id): """Get an artist's complete discography (albums and singles)""" diff --git a/webui/index.html b/webui/index.html index 6ce35d5c..7249539f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1268,13 +1268,38 @@ - +
+ + +
+
+

Similar Artists

+

Discover artists with a similar sound

+
+ + + + + + + + +
+ +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 1f456e94..c424e6e5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -15022,6 +15022,12 @@ async function loadArtistDiscography(artistId) { console.log('📦 Using cached discography'); const cachedDiscography = artistsPageState.cache.discography[artistId]; displayArtistDiscography(cachedDiscography); + + // Load similar artists in parallel (don't wait) + loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { + console.error('❌ Error loading similar artists:', err); + }); + // Still check completion status for cached data await checkDiscographyCompletion(artistId, cachedDiscography); return; @@ -15060,10 +15066,15 @@ async function loadArtistDiscography(artistId) { // Display results displayArtistDiscography(discography); - + + // Load similar artists and check completion in parallel (don't wait) + loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { + console.error('❌ Error loading similar artists:', err); + }); + // Check completion status for all albums and singles await checkDiscographyCompletion(artistId, discography); - + } catch (error) { console.error('❌ Failed to load discography:', error); showDiscographyError(error.message); @@ -15135,6 +15146,238 @@ function displayArtistDiscography(discography) { } } +/** + * Load similar artists from MusicMap + */ +async function loadSimilarArtists(artistName) { + if (!artistName) { + console.warn('⚠️ No artist name provided for similar artists'); + return; + } + + console.log(`🔍 Loading similar artists for: ${artistName}`); + + // Get DOM elements + const section = document.getElementById('similar-artists-section'); + const loadingEl = document.getElementById('similar-artists-loading'); + const errorEl = document.getElementById('similar-artists-error'); + const container = document.getElementById('similar-artists-bubbles-container'); + + if (!section || !loadingEl || !errorEl || !container) { + console.warn('⚠️ Similar artists section elements not found'); + return; + } + + // Show loading state + loadingEl.classList.remove('hidden'); + errorEl.classList.add('hidden'); + container.innerHTML = ''; + section.style.display = 'block'; + + try { + // Use streaming endpoint for real-time bubble creation + const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; + console.log(`📡 Streaming from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch similar artists: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let artistCount = 0; + + // Read the stream + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('✅ Stream complete'); + break; + } + + // Decode the chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Process complete messages (separated by \n\n) + const messages = buffer.split('\n\n'); + buffer = messages.pop() || ''; // Keep incomplete message in buffer + + for (const message of messages) { + if (!message.trim() || !message.startsWith('data: ')) continue; + + try { + const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix + + if (jsonData.error) { + throw new Error(jsonData.error); + } + + if (jsonData.artist) { + // Hide loading on first artist + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + } + + // Create and append bubble immediately + const bubble = createSimilarArtistBubble(jsonData.artist); + container.appendChild(bubble); + artistCount++; + + console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); + } + + if (jsonData.complete) { + console.log(`🎉 Streaming complete: ${jsonData.total} artists`); + + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + container.innerHTML = ` +
+
🎵
+
No similar artists found
+
+ `; + } + } + } catch (parseError) { + console.error('❌ Error parsing stream message:', parseError); + } + } + } + + } catch (error) { + console.error('❌ Error loading similar artists:', error); + + // Hide loading, show error + loadingEl.classList.add('hidden'); + errorEl.classList.remove('hidden'); + + // Also show error message in container + container.innerHTML = ` +
+
⚠️
+
${error.message}
+
+ `; + } +} + +/** + * Display similar artist bubble cards progressively (one at a time with delay) + */ +function displaySimilarArtistsProgressively(artists) { + const container = document.getElementById('similar-artists-bubbles-container'); + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Add each bubble with a delay to simulate progressive loading + artists.forEach((artist, index) => { + setTimeout(() => { + const bubble = createSimilarArtistBubble(artist); + container.appendChild(bubble); + }, index * 100); // 100ms delay between each bubble + }); + + console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); +} + +/** + * Display similar artist bubble cards (all at once - legacy) + */ +function displaySimilarArtists(artists) { + const container = document.getElementById('similar-artists-bubbles-container'); + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Create bubble cards with staggered animation + artists.forEach((artist, index) => { + const bubble = createSimilarArtistBubble(artist); + + // Add staggered animation delay (50ms per bubble) + bubble.style.animationDelay = `${index * 0.05}s`; + + container.appendChild(bubble); + }); + + console.log(`✅ Displayed ${artists.length} similar artist bubbles`); +} + +/** + * Create a similar artist bubble card element + */ +function createSimilarArtistBubble(artist) { + // Create bubble container + const bubble = document.createElement('div'); + bubble.className = 'similar-artist-bubble'; + bubble.setAttribute('data-artist-id', artist.id); + + // Create image container + const imageContainer = document.createElement('div'); + imageContainer.className = 'similar-artist-bubble-image'; + + if (artist.image_url && artist.image_url.trim() !== '') { + const img = document.createElement('img'); + img.src = artist.image_url; + img.alt = artist.name; + + // Handle image load error + img.onerror = () => { + console.log(`Failed to load image for ${artist.name}`); + imageContainer.innerHTML = `
🎵
`; + }; + + imageContainer.appendChild(img); + } else { + // No image - show fallback + imageContainer.innerHTML = `
🎵
`; + } + + // Create name element + const name = document.createElement('div'); + name.className = 'similar-artist-bubble-name'; + name.textContent = artist.name; + name.title = artist.name; // Tooltip for full name + + // Optional: Create genres element (hidden by default in CSS) + const genres = document.createElement('div'); + genres.className = 'similar-artist-bubble-genres'; + if (artist.genres && artist.genres.length > 0) { + genres.textContent = artist.genres.slice(0, 2).join(', '); + } + + // Assemble bubble + bubble.appendChild(imageContainer); + bubble.appendChild(name); + if (artist.genres && artist.genres.length > 0) { + bubble.appendChild(genres); + } + + // TODO: Add click handler when functionality is ready + // For now, just make it visually interactive + bubble.addEventListener('click', () => { + console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); + // Future: Navigate to this artist's detail page or trigger action + }); + + return bubble; +} + /** * Restore cached completion data without re-scanning the database */ diff --git a/webui/static/style.css b/webui/static/style.css index ed52423d..a26e994a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -15410,3 +15410,291 @@ body { .fix-modal-results .error-message { color: rgba(239, 68, 68, 0.9); } + +/* =============================================== + Similar Artists Section - Artists Page + =============================================== */ + +.similar-artists-section { + margin-top: 60px; + padding: 40px 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.similar-artists-header { + margin-bottom: 32px; +} + +.similar-artists-title { + font-size: 28px; + font-weight: 700; + color: #ffffff; + margin: 0 0 8px 0; + font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; + letter-spacing: -0.5px; +} + +.similar-artists-subtitle { + font-size: 15px; + color: rgba(255, 255, 255, 0.6); + margin: 0; + font-weight: 500; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* Loading State */ +.similar-artists-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.6); + font-size: 15px; + font-weight: 500; +} + +.loading-spinner-small { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top-color: #1db954; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Error State */ +.similar-artists-error { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 20px; + color: rgba(239, 68, 68, 0.8); + font-size: 15px; + font-weight: 500; +} + +.similar-artists-error .error-icon { + font-size: 20px; +} + +/* Bubbles Container - Horizontal Scroll */ +.similar-artists-bubbles-container { + display: grid; + grid-auto-flow: column; + grid-auto-columns: calc((100% - (5 * 24px)) / 6); /* Each item is 1/6th of container width */ + gap: 24px; + overflow-x: auto; + padding: 20px 0 30px 0; + scroll-behavior: smooth; + + /* Custom scrollbar */ + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05); +} + +.similar-artists-bubbles-container::-webkit-scrollbar { + height: 8px; +} + +.similar-artists-bubbles-container::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; +} + +.similar-artists-bubbles-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; + transition: background 0.2s ease; +} + +.similar-artists-bubbles-container::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Artist Bubble Card */ +.similar-artist-bubble { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 20px 16px; + border-radius: 16px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + /* Subtle background */ + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.6) 0%, + rgba(18, 18, 18, 0.7) 100%); + border: 1px solid rgba(255, 255, 255, 0.06); + + /* Elegant shadow */ + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + + /* Progressive load animation */ + opacity: 0; + transform: translateY(20px); + animation: fadeInUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* Keyframe animation for bubbles */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.similar-artist-bubble:hover { + transform: translateY(-6px) scale(1.02); + background: linear-gradient(135deg, + rgba(32, 32, 32, 0.8) 0%, + rgba(24, 24, 24, 0.9) 100%); + border-color: rgba(29, 185, 84, 0.3); + + box-shadow: + 0 8px 20px rgba(0, 0, 0, 0.4), + 0 0 20px rgba(29, 185, 84, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +/* Circular Image Container */ +.similar-artist-bubble-image { + position: relative; + width: 120px; + height: 120px; + margin: 0 auto; + border-radius: 50%; + overflow: hidden; + + /* Premium border */ + border: 3px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 4px 15px rgba(0, 0, 0, 0.4), + inset 0 2px 4px rgba(0, 0, 0, 0.3); + + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.similar-artist-bubble:hover .similar-artist-bubble-image { + border-color: rgba(29, 185, 84, 0.5); + box-shadow: + 0 6px 20px rgba(0, 0, 0, 0.5), + 0 0 25px rgba(29, 185, 84, 0.2), + inset 0 2px 4px rgba(0, 0, 0, 0.3); + transform: scale(1.05); +} + +.similar-artist-bubble-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Fallback for images that fail to load */ +.similar-artist-bubble-image-fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.2) 0%, + rgba(30, 215, 96, 0.15) 100%); + font-size: 40px; +} + +/* Artist Name */ +.similar-artist-bubble-name { + text-align: center; + font-size: 14px; + font-weight: 600; + color: #ffffff; + line-height: 1.3; + width: 100%; + + /* Truncate long names */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.similar-artist-bubble:hover .similar-artist-bubble-name { + color: #1db954; +} + +/* Genres (Optional - hidden by default for cleaner look) */ +.similar-artist-bubble-genres { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + text-align: center; + width: 100%; + + /* Truncate */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* Responsive adjustments */ +@media (max-width: 1400px) { + .similar-artists-bubbles-container { + grid-auto-columns: calc((100% - (4 * 24px)) / 5); + } +} + +@media (max-width: 1200px) { + .similar-artists-bubbles-container { + grid-auto-columns: calc((100% - (3 * 24px)) / 4); + } +} + +@media (max-width: 900px) { + .similar-artists-bubbles-container { + grid-auto-columns: calc((100% - (2 * 24px)) / 3); + } +} + +@media (max-width: 768px) { + .similar-artists-section { + margin-top: 40px; + padding: 30px 0; + } + + .similar-artists-title { + font-size: 24px; + } + + .similar-artists-bubbles-container { + grid-auto-columns: calc((100% - 16px) / 2); + gap: 16px; + } + + .similar-artist-bubble { + padding: 16px 12px; + } + + .similar-artist-bubble-image { + width: 100px; + height: 100px; + } + + .similar-artist-bubble-name { + font-size: 13px; + } +}