From f7c929abecbed0772bed95a04488a0c986509e9f Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 12 Feb 2026 10:54:42 -0800 Subject: [PATCH] Library page: lazy-load artist discography with SSE streaming Replace blocking DB matching in the Library artist detail view with a two-phase render pattern. The page now renders album cards instantly from Spotify/Itunes data , then streams per-release ownership results via SSE that update cards one-by-one. --- web_server.py | 227 +++++++++++---------------- webui/static/script.js | 343 ++++++++++++++++++++++++++++++++++++----- webui/static/style.css | 27 ++++ 3 files changed, 426 insertions(+), 171 deletions(-) diff --git a/web_server.py b/web_server.py index 7bf7ecf6..cb1586e9 100644 --- a/web_server.py +++ b/web_server.py @@ -4822,7 +4822,7 @@ def get_artist_detail(artist_id): } # Merge owned and Spotify data for complete picture - merged_discography = merge_discography_data(owned_releases, spotify_discography) + merged_discography = merge_discography_data(owned_releases, spotify_discography, db=database, artist_name=artist_info['name']) else: print(f"⚠️ Spotify discography not found: {spotify_discography.get('error', 'Unknown error')}") # Fall back to our database categorization @@ -5781,11 +5781,79 @@ def check_artist_discography_completion_stream(artist_id): } ) +@app.route('/api/library/completion-stream', methods=['POST']) +def library_completion_stream(): + """Stream completion status for library artist detail view - checks ownership per release via SSE""" + try: + data = request.get_json() + if not data or 'artist_name' not in data: + return jsonify({"error": "Missing artist_name"}), 400 + except Exception as e: + return jsonify({"error": "Invalid request data"}), 400 + + artist_name = data['artist_name'] + + def generate(): + try: + from database.music_database import MusicDatabase + db = MusicDatabase() + + categories = ['albums', 'eps', 'singles'] + all_items = [] + for cat in categories: + for item in data.get(cat, []): + all_items.append((cat, item)) + + yield f"data: {json.dumps({'type': 'start', 'total_items': len(all_items)})}\n\n" + + for i, (category, item) in enumerate(all_items): + try: + # Map Library field names to helper field names + mapped = { + 'id': item.get('spotify_id', ''), + 'name': item['title'], + 'total_tracks': item.get('track_count', 0), + 'album_type': item.get('album_type', 'album') + } + + if category == 'singles': + result = _check_single_completion(db, mapped, artist_name) + else: + result = _check_album_completion(db, mapped, artist_name) + + result['spotify_id'] = item.get('spotify_id', '') + result['category'] = category + result['type'] = 'completion' + yield f"data: {json.dumps(result)}\n\n" + except Exception as e: + yield f"data: {json.dumps({'type': 'completion', 'category': category, 'spotify_id': item.get('spotify_id', ''), 'status': 'error', 'owned_tracks': 0, 'expected_tracks': item.get('track_count', 0), 'completion_percentage': 0, 'confidence': 0.0, 'error': str(e)})}\n\n" + + time.sleep(0.05) # 50ms between items for visible streaming + + yield f"data: {json.dumps({'type': 'complete', 'processed_count': len(all_items)})}\n\n" + + except Exception as e: + print(f"❌ Error in library completion stream: {e}") + import traceback + traceback.print_exc() + yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" + + return Response( + generate(), + content_type='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + } + ) + @app.route('/api/stream/start', methods=['POST']) def stream_start(): """Start streaming a track in the background""" global stream_background_task - + data = request.get_json() if not data: return jsonify({"success": False, "error": "No track data provided"}), 400 @@ -24642,6 +24710,7 @@ def get_spotify_artist_discography(artist_name): release_data = { 'title': album.name, 'year': album.release_date[:4] if album.release_date else None, + 'release_date': album.release_date if album.release_date else None, 'image_url': album.image_url, 'spotify_id': album.id, 'owned': False, # Will be updated when merging with owned data @@ -24681,151 +24750,37 @@ def get_spotify_artist_discography(artist_name): 'error': str(e) } -def merge_discography_data(owned_releases, spotify_discography): - """Build discography using Spotify as source of truth, checking if we own each release""" +def merge_discography_data(owned_releases, spotify_discography, db=None, artist_name=None): + """Build discography from Spotify data with 'checking' state - ownership is resolved via SSE stream""" try: - print("🔄 Building discography using Spotify categorization...") - - def normalize_title(title): - """Normalize title for comparison""" - import re - import unicodedata - - # Normalize unicode characters to decomposed form (NFD) - normalized = unicodedata.normalize('NFD', title) - # Filter out non-spacing mark characters (accents) - normalized = "".join(c for c in normalized if unicodedata.category(c) != 'Mn') - - # Standard normalization - normalized = normalized.lower().strip() - normalized = re.sub(r'[^\w\s]', '', normalized) - normalized = re.sub(r'\s+', ' ', normalized) - return normalized.strip() - - def normalize_year(year): - """Normalize year to integer for comparison""" - if year is None: - return None - try: - return int(year) - except (ValueError, TypeError): - return None - - # Create a flat map of ALL owned releases (regardless of category) - all_owned = [] - all_owned.extend(owned_releases['albums']) - all_owned.extend(owned_releases['eps']) - all_owned.extend(owned_releases['singles']) - - owned_map = {} - for owned in all_owned: - key = (normalize_title(owned['title']), normalize_year(owned.get('year'))) - if key not in owned_map: - owned_map[key] = [] - owned_map[key].append(owned) - - print(f"📀 Created lookup map for {len(all_owned)} owned releases") + print("🔄 Building discography cards (fast path - no DB matching)...") def build_category(spotify_category, category_name): - """Build cards for a category using Spotify as source of truth""" + """Build cards for a category with checking state""" cards = [] - print(f"📀 Building {category_name} category with {len(spotify_category)} Spotify releases") - for spotify_release in spotify_category: - spotify_key = (normalize_title(spotify_release['title']), normalize_year(spotify_release.get('year'))) - - # Check if we own this release (exact match first) - owned_release = None - matched_key = None - - if spotify_key in owned_map and owned_map[spotify_key]: - owned_release = owned_map[spotify_key].pop(0).copy() - matched_key = spotify_key - else: - # Fallback: try matching by title only (ignore year) - title_only = normalize_title(spotify_release['title']) - for key, releases in owned_map.items(): - if key[0] == title_only and releases: # key[0] is the normalized title - owned_release = releases.pop(0).copy() - matched_key = key - print(f"🔄 Year mismatch fallback: '{spotify_release['title']}' matched by title only") - break - - if owned_release: - # We own it - use owned data with Spotify enhancements - - # Add Spotify metadata - owned_release['spotify_id'] = spotify_release['spotify_id'] - owned_release['album_type'] = spotify_release.get('album_type', 'album') - owned_release['owned'] = True - - # Calculate track completion using Spotify track count - spotify_track_count = spotify_release.get('track_count', 0) - owned_track_count = owned_release.get('owned_tracks') or 0 - - if spotify_track_count > 0 and owned_track_count is not None: - completion_percentage = (owned_track_count / spotify_track_count) * 100 - owned_release['track_completion'] = { - 'owned_tracks': owned_track_count, - 'total_tracks': spotify_track_count, - 'percentage': round(completion_percentage, 1), - 'missing_tracks': spotify_track_count - owned_track_count - } - else: - # Fallback if no Spotify track count - owned_release['track_completion'] = { - 'owned_tracks': owned_track_count, - 'total_tracks': owned_track_count, - 'percentage': 100.0, - 'missing_tracks': 0 - } - - # Image priority: owned first, then Spotify fallback - if not owned_release.get('image_url') and spotify_release.get('image_url'): - owned_release['image_url'] = spotify_release['image_url'] - - # Release date priority: Spotify first (more reliable), then owned fallback - if spotify_release.get('release_date'): - owned_release['release_date'] = spotify_release['release_date'] - elif spotify_release.get('year'): - owned_release['release_date'] = f"{spotify_release['year']}-01-01" - elif owned_release.get('year'): - # Convert year to release_date format if needed - owned_release['release_date'] = f"{owned_release['year']}-01-01" - - cards.append(owned_release) - - # Enhanced logging with track completion - completion = owned_release['track_completion'] - if completion['missing_tracks'] > 0: - print(f"✅ {category_name}: '{spotify_release['title']}' - OWNED ({completion['owned_tracks']}/{completion['total_tracks']} tracks, missing {completion['missing_tracks']})") - else: - print(f"✅ {category_name}: '{spotify_release['title']}' - OWNED (complete: {completion['owned_tracks']} tracks)") - - # Remove empty lists from map (use the key that actually matched) - if matched_key and not owned_map[matched_key]: - del owned_map[matched_key] - else: - # We don't own it - create missing card - missing_release = spotify_release.copy() - missing_release['owned'] = False - missing_release['track_completion'] = 0 - cards.append(missing_release) - print(f"❌ {category_name}: '{spotify_release['title']}' - MISSING") - + card = { + 'title': spotify_release['title'], + 'spotify_id': spotify_release.get('spotify_id'), + 'album_type': spotify_release.get('album_type', 'album'), + 'image_url': spotify_release.get('image_url'), + 'year': spotify_release.get('year'), + 'track_count': spotify_release.get('track_count', 0), + 'owned': None, # null = checking (resolved by completion stream) + 'track_completion': 'checking', + } + if spotify_release.get('release_date'): + card['release_date'] = spotify_release['release_date'] + elif spotify_release.get('year'): + card['release_date'] = f"{spotify_release['year']}-01-01" + cards.append(card) return cards - # Build each category using Spotify as the source of truth albums = build_category(spotify_discography['albums'], 'Albums') eps = build_category(spotify_discography['eps'], 'EPs') singles = build_category(spotify_discography['singles'], 'Singles') - # Report any owned releases that didn't match Spotify (rare) - remaining_owned = sum(len(owned_list) for owned_list in owned_map.values()) - if remaining_owned > 0: - print(f"📀 Note: {remaining_owned} owned releases not found on Spotify (compilations, bootlegs, etc.)") - - print(f"✅ Built discography - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}") + print(f"✅ Built discography cards - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}") return { 'success': True, diff --git a/webui/static/script.js b/webui/static/script.js index da462dab..51bf4403 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -25368,6 +25368,12 @@ let artistDetailPageState = { function navigateToArtistDetail(artistId, artistName) { console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId})`); + // Abort any in-progress completion stream + if (artistDetailPageState.completionController) { + artistDetailPageState.completionController.abort(); + artistDetailPageState.completionController = null; + } + // Store current artist info artistDetailPageState.currentArtistId = artistId; artistDetailPageState.currentArtistName = artistName; @@ -25392,6 +25398,11 @@ function initializeArtistDetailPage() { if (backBtn) { backBtn.addEventListener("click", () => { console.log("🔙 Returning to Library page"); + // Abort any in-progress completion stream + if (artistDetailPageState.completionController) { + artistDetailPageState.completionController.abort(); + artistDetailPageState.completionController = null; + } // Clear artist detail state so we go back to the list view artistDetailPageState.currentArtistId = null; artistDetailPageState.currentArtistName = null; @@ -25454,6 +25465,17 @@ async function loadArtistDetailData(artistId, artistName) { // Update header with artist name and MusicBrainz link LAST to avoid overwrite updateArtistDetailPageHeaderWithData(data.artist); + // Start streaming ownership checks if we have Spotify discography with checking state + if (data.discography && data.discography.albums) { + const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])] + .some(r => r.owned === null); + if (hasChecking) { + // Store discography for stream updates + artistDetailPageState.currentDiscography = data.discography; + checkLibraryCompletion(data.artist.name, data.discography); + } + } + } catch (error) { console.error(`❌ Error loading artist detail data:`, error); @@ -25590,28 +25612,30 @@ function updateArtistGenres(genres) { } function updateArtistSummaryStats(discography) { - // Calculate stats - const ownedAlbums = discography.albums.filter(album => album.owned).length; - const missingAlbums = discography.albums.filter(album => !album.owned).length; + const allReleases = [...discography.albums, ...discography.eps, ...discography.singles]; + const hasChecking = allReleases.some(r => r.owned === null); + + const ownedAlbums = discography.albums.filter(album => album.owned === true).length; + const missingAlbums = discography.albums.filter(album => album.owned === false).length; const totalAlbums = discography.albums.length; const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0; // Update owned albums count const ownedElement = document.getElementById("owned-albums-count"); if (ownedElement) { - ownedElement.textContent = ownedAlbums; + ownedElement.textContent = hasChecking ? '...' : ownedAlbums; } // Update missing albums count const missingElement = document.getElementById("missing-albums-count"); if (missingElement) { - missingElement.textContent = missingAlbums; + missingElement.textContent = hasChecking ? '...' : missingAlbums; } // Update completion percentage const completionElement = document.getElementById("completion-percentage"); if (completionElement) { - completionElement.textContent = `${completionPercentage}%`; + completionElement.textContent = hasChecking ? 'Checking...' : `${completionPercentage}%`; } } @@ -25675,29 +25699,42 @@ function updateArtistHeroSection(artist, discography) { } function updateCategoryStats(category, releases) { - const owned = releases.filter(r => r.owned !== false).length; + const hasChecking = releases.some(r => r.owned === null); + const owned = releases.filter(r => r.owned === true).length; const missing = releases.filter(r => r.owned === false).length; const total = releases.length; const completion = total > 0 ? Math.round((owned / total) * 100) : 100; - console.log(`📊 ${category}: ${owned} owned, ${missing} missing, ${completion}% complete`); - // Update stats text const statsElement = document.getElementById(`${category}-stats`); if (statsElement) { - statsElement.textContent = `${owned} owned, ${missing} missing`; + if (hasChecking) { + statsElement.textContent = `Checking...`; + } else { + statsElement.textContent = `${owned} owned, ${missing} missing`; + } } // Update completion bar const fillElement = document.getElementById(`${category}-completion-fill`); if (fillElement) { - fillElement.style.width = `${completion}%`; + if (hasChecking) { + fillElement.style.width = '100%'; + fillElement.classList.add('checking'); + } else { + fillElement.style.width = `${completion}%`; + fillElement.classList.remove('checking'); + } } // Update completion text const textElement = document.getElementById(`${category}-completion-text`); if (textElement) { - textElement.textContent = `${completion}%`; + if (hasChecking) { + textElement.textContent = `Checking...`; + } else { + textElement.textContent = `${completion}%`; + } } } @@ -25723,28 +25760,26 @@ function populateReleaseSection(sectionType, releases) { // Clear existing content grid.innerHTML = ""; - // Calculate stats - const ownedCount = releases.filter(release => release.owned).length; - const missingCount = releases.filter(release => !release.owned).length; + const hasChecking = releases.some(r => r.owned === null); + const ownedCount = releases.filter(release => release.owned === true).length; + const missingCount = releases.filter(release => release.owned === false).length; // Update section stats const ownedElement = document.getElementById(ownedCountId); const missingElement = document.getElementById(missingCountId); if (ownedElement) { - ownedElement.textContent = `${ownedCount} owned`; + ownedElement.textContent = hasChecking ? 'Checking...' : `${ownedCount} owned`; } if (missingElement) { - missingElement.textContent = `${missingCount} missing`; + missingElement.textContent = hasChecking ? '' : `${missingCount} missing`; } // Create release cards releases.forEach((release, index) => { - console.log(`📀 Creating card ${index + 1} for: ${release.title}`); const card = createReleaseCard(release); grid.appendChild(card); - console.log(`📀 Added card to grid:`, card); }); console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`); @@ -25754,9 +25789,12 @@ function populateReleaseSection(sectionType, releases) { function createReleaseCard(release) { const card = document.createElement("div"); - card.className = `release-card${release.owned ? "" : " missing"}`; + const isChecking = release.owned === null; + card.className = `release-card${isChecking ? " checking" : (release.owned ? "" : " missing")}`; card.setAttribute("data-release-id", release.id || ""); card.setAttribute("data-spotify-id", release.spotify_id || ""); + // Store mutable reference so stream updates propagate to click handler + card._releaseData = release; // Add MusicBrainz icon if available let mbIcon = null; @@ -25847,7 +25885,13 @@ function createReleaseCard(release) { const completionFill = document.createElement("div"); completionFill.className = "completion-fill"; - if (release.owned) { + if (release.owned === null || release.track_completion === 'checking') { + // Checking state - ownership not yet resolved + completionText.textContent = "Checking..."; + completionText.className = "completion-text checking"; + completionFill.className += " checking"; + completionFill.style.width = "100%"; + } else if (release.owned) { // Handle new detailed track completion object if (release.track_completion && typeof release.track_completion === 'object') { const completion = release.track_completion; @@ -25907,15 +25951,22 @@ function createReleaseCard(release) { card.appendChild(mbIcon); } - // Add click handler for release card + // Add click handler for release card (uses card._releaseData for mutable reference) card.addEventListener("click", async () => { - console.log(`Clicked on release: ${release.title} (Owned: ${release.owned})`); + const rel = card._releaseData; + console.log(`Clicked on release: ${rel.title} (Owned: ${rel.owned})`); + + // Still checking - ignore click + if (rel.owned === null) { + showToast(`Still checking ownership for ${rel.title}...`, "info"); + return; + } // For owned/complete releases, show info message - if (release.owned && (!release.track_completion || - (typeof release.track_completion === 'object' && release.track_completion.missing_tracks === 0) || - (typeof release.track_completion === 'number' && release.track_completion === 100))) { - showToast(`${release.title} is already complete in your library`, "info"); + if (rel.owned && (!rel.track_completion || + (typeof rel.track_completion === 'object' && rel.track_completion.missing_tracks === 0) || + (typeof rel.track_completion === 'number' && rel.track_completion === 100))) { + showToast(`${rel.title} is already complete in your library`, "info"); return; } @@ -25925,13 +25976,13 @@ function createReleaseCard(release) { try { // Convert release object to album format expected by our function const albumData = { - id: release.spotify_id || release.id, - name: release.title, - image_url: release.image_url, - release_date: release.year ? `${release.year}-01-01` : '', - album_type: release.album_type || release.type || 'album', - total_tracks: (release.track_completion && typeof release.track_completion === 'object') - ? release.track_completion.total_tracks : 1 + id: rel.spotify_id || rel.id, + name: rel.title, + image_url: rel.image_url, + release_date: rel.year ? `${rel.year}-01-01` : '', + album_type: rel.album_type || rel.type || 'album', + total_tracks: (rel.track_completion && typeof rel.track_completion === 'object') + ? rel.track_completion.total_tracks : (rel.track_count || 1) }; // Get current artist from artist detail page state @@ -25959,7 +26010,7 @@ function createReleaseCard(release) { } // Use the actual album type from release data - const albumType = release.album_type || release.type || 'album'; + const albumType = rel.album_type || rel.type || 'album'; // Open the Add to Wishlist modal // Note: openAddToWishlistModal has its own loading overlay @@ -26007,6 +26058,228 @@ function getArtistImageFromPage() { } } +// ================================================================================================ +// LIBRARY COMPLETION STREAMING - Two-phase lazy-load pattern +// ================================================================================================ + +async function checkLibraryCompletion(artistName, discography) { + // Abort any in-progress check + if (artistDetailPageState.completionController) { + artistDetailPageState.completionController.abort(); + } + artistDetailPageState.completionController = new AbortController(); + + const payload = { + artist_name: artistName, + albums: discography.albums || [], + eps: discography.eps || [], + singles: discography.singles || [] + }; + + try { + const response = await fetch('/api/library/completion-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: artistDetailPageState.completionController.signal + }); + + if (!response.ok) { + console.error(`❌ Completion stream failed: ${response.status}`); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let ownedCounts = { albums: 0, eps: 0, singles: 0 }; + let totalCounts = { albums: 0, eps: 0, singles: 0 }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const eventData = JSON.parse(line.slice(6)); + if (eventData.type === 'completion') { + updateLibraryReleaseCard(eventData); + totalCounts[eventData.category]++; + if (eventData.status !== 'missing' && eventData.status !== 'error') { + ownedCounts[eventData.category]++; + } + // Update stats incrementally + updateCategoryStatsFromStream( + eventData.category, + ownedCounts[eventData.category], + totalCounts[eventData.category] - ownedCounts[eventData.category] + ); + } else if (eventData.type === 'complete') { + console.log(`✅ Library completion stream done: ${eventData.processed_count} items`); + // Final stats recalculation + recalculateSummaryStats(); + } + } catch (parseError) { + console.warn('Error parsing SSE event:', parseError, line); + } + } + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('🛑 Library completion stream aborted (navigation)'); + } else { + console.error('❌ Error in library completion stream:', error); + } + } +} + +function updateLibraryReleaseCard(data) { + const card = document.querySelector(`[data-spotify-id="${data.spotify_id}"]`); + if (!card) return; + + const isOwned = data.status !== 'missing' && data.status !== 'error'; + + // Update card class + card.classList.remove('checking', 'missing'); + if (!isOwned) { + card.classList.add('missing'); + } + + // Update the mutable release data on the card + if (card._releaseData) { + card._releaseData.owned = isOwned; + if (isOwned && data.expected_tracks > 0) { + card._releaseData.track_completion = { + owned_tracks: data.owned_tracks, + total_tracks: data.expected_tracks, + percentage: data.completion_percentage, + missing_tracks: data.expected_tracks - data.owned_tracks + }; + } else if (isOwned) { + card._releaseData.track_completion = { + owned_tracks: data.owned_tracks, + total_tracks: data.owned_tracks, + percentage: 100, + missing_tracks: 0 + }; + } else { + card._releaseData.track_completion = 0; + } + } + + // Update completion text element in-place + const completionText = card.querySelector('.completion-text'); + if (completionText) { + completionText.classList.remove('checking', 'complete', 'partial', 'missing'); + if (isOwned) { + const missing = data.expected_tracks - data.owned_tracks; + if (missing <= 0) { + completionText.textContent = `Complete (${data.owned_tracks})`; + completionText.className = 'completion-text complete'; + } else { + completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`; + completionText.className = 'completion-text partial'; + completionText.title = `Missing ${missing} track${missing !== 1 ? 's' : ''}`; + } + } else { + completionText.textContent = 'Missing'; + completionText.className = 'completion-text missing'; + } + } + + // Update completion fill bar in-place + const completionFill = card.querySelector('.completion-fill'); + if (completionFill) { + completionFill.classList.remove('checking', 'complete', 'partial', 'missing'); + if (isOwned) { + const pct = data.completion_percentage || 100; + completionFill.style.width = `${pct}%`; + const missing = data.expected_tracks - data.owned_tracks; + completionFill.classList.add(missing <= 0 ? 'complete' : 'partial'); + } else { + completionFill.style.width = '0%'; + completionFill.classList.add('missing'); + } + } +} + +function updateCategoryStatsFromStream(category, ownedCount, missingCount) { + const statsElement = document.getElementById(`${category}-stats`); + if (statsElement) { + statsElement.textContent = `${ownedCount} owned, ${missingCount} missing`; + } + + const total = ownedCount + missingCount; + const completion = total > 0 ? Math.round((ownedCount / total) * 100) : 100; + + const fillElement = document.getElementById(`${category}-completion-fill`); + if (fillElement) { + fillElement.classList.remove('checking'); + fillElement.style.width = `${completion}%`; + } + + const textElement = document.getElementById(`${category}-completion-text`); + if (textElement) { + textElement.textContent = `${completion}%`; + } + + // Update section owned/missing counts + const ownedElement = document.getElementById(`${category}-owned-count`); + if (ownedElement) { + ownedElement.textContent = `${ownedCount} owned`; + } + const missingElement = document.getElementById(`${category}-missing-count`); + if (missingElement) { + missingElement.textContent = `${missingCount} missing`; + } +} + +function recalculateSummaryStats() { + const disc = artistDetailPageState.currentDiscography; + if (!disc) return; + + // Recalculate from the live card data + const categories = ['albums', 'eps', 'singles']; + for (const cat of categories) { + const grid = document.getElementById(`${cat}-grid`); + if (!grid) continue; + let owned = 0, missing = 0; + grid.querySelectorAll('.release-card').forEach(card => { + if (card._releaseData) { + if (card._releaseData.owned === true) owned++; + else if (card._releaseData.owned === false) missing++; + } + }); + updateCategoryStatsFromStream(cat, owned, missing); + } + + // Update summary stats (albums only, matches original behavior) + const albumGrid = document.getElementById('albums-grid'); + if (albumGrid) { + let ownedAlbums = 0, missingAlbums = 0; + albumGrid.querySelectorAll('.release-card').forEach(card => { + if (card._releaseData) { + if (card._releaseData.owned === true) ownedAlbums++; + else if (card._releaseData.owned === false) missingAlbums++; + } + }); + const total = ownedAlbums + missingAlbums; + const pct = total > 0 ? Math.round((ownedAlbums / total) * 100) : 0; + + const ownedEl = document.getElementById("owned-albums-count"); + if (ownedEl) ownedEl.textContent = ownedAlbums; + const missingEl = document.getElementById("missing-albums-count"); + if (missingEl) missingEl.textContent = missingAlbums; + const completionEl = document.getElementById("completion-percentage"); + if (completionEl) completionEl.textContent = `${pct}%`; + } +} + // UI state management functions function showArtistDetailLoading(show) { const loadingElement = document.getElementById("artist-detail-loading"); diff --git a/webui/static/style.css b/webui/static/style.css index 7c4dedd1..a9e0da15 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -13281,6 +13281,33 @@ body { width: 0%; } +/* Checking state - lazy-load ownership streaming */ +.release-card.checking { + opacity: 0.75; + animation: cardCheckingPulse 1.8s ease-in-out infinite; +} + +@keyframes cardCheckingPulse { + 0%, 100% { opacity: 0.75; } + 50% { opacity: 0.55; } +} + +.completion-text.checking { + color: #888; + font-weight: 400; +} + +.completion-fill.checking { + background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.15), rgba(255,255,255,0.08)); + background-size: 200% 100%; + animation: checkingBarShimmer 1.5s ease-in-out infinite; +} + +@keyframes checkingBarShimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + /* Responsive Design */ @media (max-width: 768px) { .artist-detail-header {