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.
pull/130/head
Broque Thomas 3 months ago
parent 965f93ab31
commit f7c929abec

@ -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,

@ -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");

@ -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 {

Loading…
Cancel
Save