similar artists start
This commit is contained in:
parent
2f8bad23a8
commit
ea9f351bdb
4 changed files with 796 additions and 3 deletions
237
web_server.py
237
web_server.py
|
|
@ -3594,6 +3594,243 @@ def debug_library_photos():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/artist/similar/<path:artist_name>/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/<path:artist_name>')
|
||||||
|
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/<artist_id>/discography', methods=['GET'])
|
@app.route('/api/artist/<artist_id>/discography', methods=['GET'])
|
||||||
def get_artist_discography(artist_id):
|
def get_artist_discography(artist_id):
|
||||||
"""Get an artist's complete discography (albums and singles)"""
|
"""Get an artist's complete discography (albums and singles)"""
|
||||||
|
|
|
||||||
|
|
@ -1268,13 +1268,38 @@
|
||||||
<!-- Album cards will be populated here -->
|
<!-- Album cards will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content" id="singles-content">
|
<div class="tab-content" id="singles-content">
|
||||||
<div class="singles-cards-container" id="singles-cards-container">
|
<div class="singles-cards-container" id="singles-cards-container">
|
||||||
<!-- Singles cards will be populated here -->
|
<!-- Singles cards will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Similar Artists Section -->
|
||||||
|
<div class="similar-artists-section" id="similar-artists-section">
|
||||||
|
<div class="similar-artists-header">
|
||||||
|
<h3 class="similar-artists-title">Similar Artists</h3>
|
||||||
|
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div class="similar-artists-loading hidden" id="similar-artists-loading">
|
||||||
|
<div class="loading-spinner-small"></div>
|
||||||
|
<span>Finding similar artists...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div class="similar-artists-error hidden" id="similar-artists-error">
|
||||||
|
<span class="error-icon">⚠️</span>
|
||||||
|
<span class="error-text">Unable to load similar artists</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Similar Artists Bubbles Container -->
|
||||||
|
<div class="similar-artists-bubbles-container" id="similar-artists-bubbles-container">
|
||||||
|
<!-- Artist bubble cards will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15022,6 +15022,12 @@ async function loadArtistDiscography(artistId) {
|
||||||
console.log('📦 Using cached discography');
|
console.log('📦 Using cached discography');
|
||||||
const cachedDiscography = artistsPageState.cache.discography[artistId];
|
const cachedDiscography = artistsPageState.cache.discography[artistId];
|
||||||
displayArtistDiscography(cachedDiscography);
|
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
|
// Still check completion status for cached data
|
||||||
await checkDiscographyCompletion(artistId, cachedDiscography);
|
await checkDiscographyCompletion(artistId, cachedDiscography);
|
||||||
return;
|
return;
|
||||||
|
|
@ -15060,10 +15066,15 @@ async function loadArtistDiscography(artistId) {
|
||||||
|
|
||||||
// Display results
|
// Display results
|
||||||
displayArtistDiscography(discography);
|
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
|
// Check completion status for all albums and singles
|
||||||
await checkDiscographyCompletion(artistId, discography);
|
await checkDiscographyCompletion(artistId, discography);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to load discography:', error);
|
console.error('❌ Failed to load discography:', error);
|
||||||
showDiscographyError(error.message);
|
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 = `
|
||||||
|
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.5);">
|
||||||
|
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
|
||||||
|
<div style="font-size: 14px;">No similar artists found</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 = `
|
||||||
|
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(239, 68, 68, 0.7);">
|
||||||
|
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
|
||||||
|
<div style="font-size: 14px;">${error.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
imageContainer.appendChild(img);
|
||||||
|
} else {
|
||||||
|
// No image - show fallback
|
||||||
|
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
* Restore cached completion data without re-scanning the database
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -15410,3 +15410,291 @@ body {
|
||||||
.fix-modal-results .error-message {
|
.fix-modal-results .error-message {
|
||||||
color: rgba(239, 68, 68, 0.9);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue