Add lazy loading for artist images across UI

Implements lazy loading of artist images in search results, artist pages, and similar artist bubbles to improve performance and user experience. Updates the iTunes client to prefer explicit album versions and deduplicate albums accordingly. Adds a new API endpoint to fetch artist images, and updates frontend logic to asynchronously fetch and display images where missing.
pull/126/head
Broque Thomas 4 months ago
parent f12478ee70
commit fecd371b5e

@ -228,7 +228,8 @@ class iTunesClient:
'country': self.country,
'media': 'music',
'entity': entity,
'limit': min(limit, 200) # iTunes max is 200
'limit': min(limit, 200), # iTunes max is 200
'explicit': 'Yes' # Include explicit content (prefer over clean versions)
}
response = self.session.get(
@ -335,16 +336,40 @@ class iTunesClient:
@rate_limited
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
"""Search for albums using iTunes API"""
results = self._search(query, 'album', limit)
"""Search for albums using iTunes API.
Filters out clean versions when explicit versions are available.
"""
results = self._search(query, 'album', limit * 2) # Fetch more to account for filtering
albums = []
seen_albums = {} # Track albums by normalized name to prefer explicit versions
for album_data in results:
if album_data.get('wrapperType') == 'collection':
album = Album.from_itunes_album(album_data)
albums.append(album)
return albums
if album_data.get('wrapperType') != 'collection':
continue
# Get album name and explicitness
album_name = album_data.get('collectionName', '').lower().strip()
artist_name = album_data.get('artistName', '').lower().strip()
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
# Create a key for deduplication (album name + artist)
key = f"{album_name}|{artist_name}"
# If we've seen this album before
if key in seen_albums:
# Only replace if current one is explicit and previous was clean
if is_explicit and not seen_albums[key]['is_explicit']:
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
else:
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
# Convert to Album objects
for item in seen_albums.values():
album = Album.from_itunes_album(item['data'])
albums.append(album)
return albums[:limit]
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album information - normalized to Spotify format"""
@ -453,20 +478,17 @@ class iTunesClient:
@rate_limited
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
"""Search for artists using iTunes API - includes album art fallback for images"""
"""Search for artists using iTunes API.
Note: Artist images are not fetched during search to keep it fast.
Images are fetched when viewing artist details (get_artist method).
"""
results = self._search(query, 'musicArtist', limit)
artists = []
for artist_data in results:
if artist_data.get('wrapperType') == 'artist':
artist = Artist.from_itunes_artist(artist_data)
# If no artist image, try to get their first album's artwork
if not artist.image_url:
album_art = self._get_artist_image_from_albums(str(artist_data.get('artistId', '')))
if album_art:
artist.image_url = album_art
artists.append(artist)
return artists
@ -530,12 +552,12 @@ class iTunesClient:
Note: iTunes doesn't support filtering by album_type in the same way as Spotify,
so we fetch all albums and can filter client-side if needed.
Prefers explicit versions over clean versions when both exist.
"""
import re
results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200))
albums = []
seen_albums = set() # Track normalized names to prevent duplicates
seen_albums = {} # Track albums by normalized name, prefer explicit versions
def normalize_album_name(name: str) -> str:
"""Normalize album name for deduplication (removes edition suffixes, etc.)"""
@ -549,25 +571,38 @@ class iTunesClient:
return normalized
for album_data in results:
if album_data.get('wrapperType') == 'collection':
album = Album.from_itunes_album(album_data)
# Filter by album_type if specified (now includes 'ep')
if album_type != 'album,single':
requested_types = [t.strip() for t in album_type.split(',')]
# Also accept 'ep' when 'single' is requested (for backward compat)
if album.album_type not in requested_types:
if not (album.album_type == 'ep' and 'single' in requested_types):
continue
# Deduplicate by normalized name
normalized_name = normalize_album_name(album.name)
if normalized_name in seen_albums:
if album_data.get('wrapperType') != 'collection':
continue
# Check if explicit
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
# Create album object
album = Album.from_itunes_album(album_data)
# Filter by album_type if specified (now includes 'ep')
if album_type != 'album,single':
requested_types = [t.strip() for t in album_type.split(',')]
# Also accept 'ep' when 'single' is requested (for backward compat)
if album.album_type not in requested_types:
if not (album.album_type == 'ep' and 'single' in requested_types):
continue
# Deduplicate by normalized name, prefer explicit versions
normalized_name = normalize_album_name(album.name)
if normalized_name in seen_albums:
# Only replace if current one is explicit and previous was clean
if is_explicit and not seen_albums[normalized_name]['is_explicit']:
logger.debug(f"Replacing clean version with explicit: {album.name}")
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
else:
logger.debug(f"Skipping duplicate album: {album.name} (normalized: {normalized_name})")
continue
else:
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
seen_albums.add(normalized_name)
albums.append(album)
# Extract albums from dict
albums = [item['album'] for item in seen_albums.values()]
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
return albums[:limit]

@ -5017,6 +5017,31 @@ def get_similar_artists(artist_name):
"error": str(e)
}), 500
@app.route('/api/artist/<artist_id>/image', methods=['GET'])
def get_artist_image(artist_id):
"""Get artist image URL - used for lazy loading in search results.
For iTunes, this fetches the artist's first album artwork as a fallback.
For Spotify, returns the artist's image directly.
"""
try:
if spotify_client and spotify_client.is_spotify_authenticated():
# Use Spotify directly
artist_data = spotify_client.sp.artist(artist_id)
if artist_data and artist_data.get('images'):
image_url = artist_data['images'][0]['url'] if artist_data['images'] else None
return jsonify({"success": True, "image_url": image_url})
return jsonify({"success": True, "image_url": None})
else:
# Use iTunes fallback - fetch album art
from core.itunes_client import iTunesClient
itunes = iTunesClient()
image_url = itunes._get_artist_image_from_albums(artist_id)
return jsonify({"success": True, "image_url": image_url})
except Exception as e:
print(f"Error fetching artist image: {e}")
return jsonify({"success": False, "image_url": None, "error": str(e)})
@app.route('/api/artist/<artist_id>/discography', methods=['GET'])
def get_artist_discography(artist_id):
"""Get an artist's complete discography (albums and singles)"""
@ -14111,9 +14136,15 @@ def get_album_tracks(album_id):
if not album_data:
return jsonify({"error": "Album not found"}), 404
# Extract tracks from album data
# Extract tracks from album data (Spotify format)
tracks = album_data.get('tracks', {}).get('items', [])
# If no tracks in album data (iTunes format), fetch them separately
if not tracks:
tracks_data = spotify_client.get_album_tracks(album_id)
if tracks_data and 'items' in tracks_data:
tracks = tracks_data['items']
# Format response
album_dict = {
'id': album_data['id'],
@ -14121,12 +14152,13 @@ def get_album_tracks(album_id):
'artists': album_data.get('artists', []),
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', 0),
'album_type': album_data.get('album_type', 'album'), # CRITICAL FIX: Include album_type for correct classification
'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []),
'tracks': tracks
}
return jsonify(album_dict)
except Exception as e:
logger.error(f"Error fetching album tracks: {e}")
return jsonify({"error": str(e)}), 500

@ -2681,6 +2681,57 @@ function initializeSearchModeToggle() {
};
}
);
// Lazy load artist images that are missing
lazyLoadEnhancedSearchArtistImages();
}
// Lazy load artist images for enhanced search results
async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [
document.getElementById('enh-db-artists-list'),
document.getElementById('enh-spotify-artists-list')
];
for (const list of artistLists) {
if (!list) continue;
const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) continue;
console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`);
for (const card of cardsNeedingImages) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
if (data.success && data.image_url) {
// Find the placeholder and replace with image
const placeholder = card.querySelector('.enh-item-image-placeholder');
if (placeholder) {
const img = document.createElement('img');
img.src = data.image_url;
img.className = 'enh-item-image artist-image';
img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist';
placeholder.replaceWith(img);
// Apply dynamic glow
extractImageColors(data.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
card.dataset.needsImage = 'false';
console.log(`✅ Loaded image for artist ${artistId}`);
}
} catch (error) {
console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error);
}
}
}
}
function formatDuration(durationMs) {
@ -2729,6 +2780,11 @@ function initializeSearchModeToggle() {
// Add appropriate card class
if (isArtist) {
elem.className = 'enh-compact-item artist-card';
// Add data attributes for lazy loading
if (item.id) {
elem.dataset.artistId = item.id;
elem.dataset.needsImage = config.image ? 'false' : 'true';
}
} else if (isAlbum) {
elem.className = 'enh-compact-item album-card';
} else if (isTrack) {
@ -2752,7 +2808,7 @@ function initializeSearchModeToggle() {
const imageHtml = config.image
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
: `<div class="${placeholderClass}">${config.placeholder}</div>`;
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
@ -11451,6 +11507,10 @@ function createArtistCard(artist, confidence) {
const imageUrl = artist.image_url || '';
const confidencePercent = Math.round(confidence * 100);
// Add data attribute for lazy loading
card.dataset.artistId = artist.id;
card.dataset.needsImage = imageUrl ? 'false' : 'true';
card.innerHTML = `
<div class="suggestion-card-overlay"></div>
<div class="suggestion-card-content">
@ -11683,6 +11743,16 @@ function renderArtistSearchResults(results) {
console.error(`Error calling createArtistCard for result ${index}:`, error);
}
});
// Lazy load missing artist images
console.log('🖼️ Starting lazy load for artist images in matching modal...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('❌ lazyLoadArtistImages function not found!');
}
}
function renderAlbumSearchResults(results) {
@ -19768,6 +19838,16 @@ function displayArtistsResults(query, results) {
// Update watchlist status for all cards
updateArtistCardWatchlistStatus();
// Lazy load missing artist images
console.log('🖼️ Starting lazy load for artist images on Artists page...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('❌ lazyLoadArtistImages function not found!');
}
// Add mouse wheel horizontal scrolling
container.addEventListener('wheel', (event) => {
if (event.deltaY !== 0) {
@ -19777,6 +19857,77 @@ function displayArtistsResults(query, results) {
});
}
/**
* Lazy load artist images for cards that don't have images yet.
* Fetches images asynchronously so search results appear immediately.
*/
async function lazyLoadArtistImages(container) {
if (!container) {
console.error('❌ lazyLoadArtistImages: container is null');
return;
}
// Find all cards that need images
const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) {
console.log('✅ All artist cards have images');
return;
}
console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`);
// Load images in parallel (but with a small batch to avoid overwhelming the server)
const batchSize = 5;
const cards = Array.from(cardsNeedingImages);
for (let i = 0; i < cards.length; i += batchSize) {
const batch = cards.slice(i, i + batchSize);
await Promise.all(batch.map(async (card) => {
const artistId = card.dataset.artistId;
if (!artistId) {
console.warn('⚠️ Card missing artistId:', card);
return;
}
try {
console.log(`🔄 Fetching image for artist ${artistId}...`);
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
console.log(`📥 Got response for ${artistId}:`, data);
if (data.success && data.image_url) {
// Update the card's background image
// Handle both card types (suggestion-card and artist-card)
if (card.classList.contains('suggestion-card')) {
card.style.backgroundImage = `url(${data.image_url})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
} else if (card.classList.contains('artist-card')) {
const bgElement = card.querySelector('.artist-card-background');
if (bgElement) {
// Clear the gradient first, then set the image
bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`;
}
}
card.dataset.needsImage = 'false';
console.log(`✅ Loaded image for artist ${artistId}`);
}
} catch (error) {
console.error(`❌ Failed to load image for artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading artist images');
}
// Make function globally accessible
window.lazyLoadArtistImages = lazyLoadArtistImages;
/**
* Create HTML for an artist card
*/
@ -19794,8 +19945,11 @@ function createArtistCardHTML(artist) {
// Format popularity as a percentage for better UX
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
// Track if image needs to be lazy loaded
const needsImage = imageUrl ? 'false' : 'true';
return `
<div class="artist-card" data-artist-id="${artist.id}">
<div class="artist-card" data-artist-id="${artist.id}" data-needs-image="${needsImage}">
<div class="artist-card-background" style="${backgroundStyle}"></div>
<div class="artist-card-overlay"></div>
<div class="artist-card-content">
@ -20107,6 +20261,9 @@ async function loadSimilarArtists(artistName) {
<div style="font-size: 14px;">No similar artists found</div>
</div>
`;
} else {
// Lazy load images for similar artists that don't have them
lazyLoadSimilarArtistImages(container);
}
}
} catch (parseError) {
@ -20145,6 +20302,54 @@ async function loadSimilarArtists(artistName) {
}
}
/**
* Lazy load images for similar artist bubbles that don't have images
*/
async function lazyLoadSimilarArtistImages(container) {
if (!container) return;
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
if (bubblesNeedingImages.length === 0) {
console.log('✅ All similar artist bubbles have images');
return;
}
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
// Load images in parallel batches
const batchSize = 5;
const bubbles = Array.from(bubblesNeedingImages);
for (let i = 0; i < bubbles.length; i += batchSize) {
const batch = bubbles.slice(i, i + batchSize);
await Promise.all(batch.map(async (bubble) => {
const artistId = bubble.getAttribute('data-artist-id');
if (!artistId) return;
try {
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
if (data.success && data.image_url) {
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
if (imageContainer) {
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
bubble.setAttribute('data-needs-image', 'false');
console.log(`✅ Loaded image for similar artist ${artistId}`);
}
}
} catch (error) {
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading similar artist images');
}
/**
* Display similar artist bubble cards progressively (one at a time with delay)
*/
@ -20206,11 +20411,15 @@ function createSimilarArtistBubble(artist) {
bubble.className = 'similar-artist-bubble';
bubble.setAttribute('data-artist-id', artist.id);
// Track if image needs lazy loading
const hasImage = artist.image_url && artist.image_url.trim() !== '';
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
// Create image container
const imageContainer = document.createElement('div');
imageContainer.className = 'similar-artist-bubble-image';
if (artist.image_url && artist.image_url.trim() !== '') {
if (hasImage) {
const img = document.createElement('img');
img.src = artist.image_url;
img.alt = artist.name;
@ -20219,11 +20428,12 @@ function createSimilarArtistBubble(artist) {
img.onerror = () => {
console.log(`Failed to load image for ${artist.name}`);
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
bubble.setAttribute('data-needs-image', 'true');
};
imageContainer.appendChild(img);
} else {
// No image - show fallback
// No image - show fallback (will be lazy loaded)
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
}

Loading…
Cancel
Save