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.
This commit is contained in:
parent
965f93ab31
commit
f7c929abec
3 changed files with 426 additions and 171 deletions
227
web_server.py
227
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,
|
||||
|
|
|
|||
|
|
@ -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…
Reference in a new issue