similar artists start

This commit is contained in:
Broque Thomas 2025-10-04 16:09:11 -07:00
parent 2f8bad23a8
commit ea9f351bdb
4 changed files with 796 additions and 3 deletions

View file

@ -3594,6 +3594,243 @@ def debug_library_photos():
except Exception as e:
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'])
def get_artist_discography(artist_id):
"""Get an artist's complete discography (albums and singles)"""

View file

@ -1268,13 +1268,38 @@
<!-- Album cards will be populated here -->
</div>
</div>
<div class="tab-content" id="singles-content">
<div class="singles-cards-container" id="singles-cards-container">
<!-- Singles cards will be populated here -->
</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>

View file

@ -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 = `
<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
*/

View file

@ -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;
}
}