From fecd371b5ec21fbbafca52d13aa93a85cc0bbea9 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 23 Jan 2026 07:38:03 -0800 Subject: [PATCH] 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. --- core/itunes_client.py | 107 +++++++++++++------- web_server.py | 36 ++++++- webui/static/script.js | 218 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 319 insertions(+), 42 deletions(-) diff --git a/core/itunes_client.py b/core/itunes_client.py index e799fa74..cf974687 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -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] diff --git a/web_server.py b/web_server.py index 93b341bb..753a598d 100644 --- a/web_server.py +++ b/web_server.py @@ -5017,6 +5017,31 @@ def get_similar_artists(artist_name): "error": str(e) }), 500 +@app.route('/api/artist//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//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 diff --git a/webui/static/script.js b/webui/static/script.js index 5cff6c6e..5aa92be5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 ? `${escapeHtml(config.name)}` - : `
${config.placeholder}
`; + : `
${config.placeholder}
`; const badgeHtml = config.badge ? `
${config.badge.text}
` @@ -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 = `
@@ -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 ` -
+
@@ -20107,6 +20261,9 @@ async function loadSimilarArtists(artistName) {
No similar artists found
`; + } 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 = `${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 = `
🎵
`; + bubble.setAttribute('data-needs-image', 'true'); }; imageContainer.appendChild(img); } else { - // No image - show fallback + // No image - show fallback (will be lazy loaded) imageContainer.innerHTML = `
🎵
`; }