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 = `
🎵
`; }