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
225
web_server.py
225
web_server.py
|
|
@ -4822,7 +4822,7 @@ def get_artist_detail(artist_id):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Merge owned and Spotify data for complete picture
|
# 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:
|
else:
|
||||||
print(f"⚠️ Spotify discography not found: {spotify_discography.get('error', 'Unknown error')}")
|
print(f"⚠️ Spotify discography not found: {spotify_discography.get('error', 'Unknown error')}")
|
||||||
# Fall back to our database categorization
|
# Fall back to our database categorization
|
||||||
|
|
@ -5781,6 +5781,74 @@ 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'])
|
@app.route('/api/stream/start', methods=['POST'])
|
||||||
def stream_start():
|
def stream_start():
|
||||||
"""Start streaming a track in the background"""
|
"""Start streaming a track in the background"""
|
||||||
|
|
@ -24642,6 +24710,7 @@ def get_spotify_artist_discography(artist_name):
|
||||||
release_data = {
|
release_data = {
|
||||||
'title': album.name,
|
'title': album.name,
|
||||||
'year': album.release_date[:4] if album.release_date else None,
|
'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,
|
'image_url': album.image_url,
|
||||||
'spotify_id': album.id,
|
'spotify_id': album.id,
|
||||||
'owned': False, # Will be updated when merging with owned data
|
'owned': False, # Will be updated when merging with owned data
|
||||||
|
|
@ -24681,151 +24750,37 @@ def get_spotify_artist_discography(artist_name):
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
def merge_discography_data(owned_releases, spotify_discography):
|
def merge_discography_data(owned_releases, spotify_discography, db=None, artist_name=None):
|
||||||
"""Build discography using Spotify as source of truth, checking if we own each release"""
|
"""Build discography from Spotify data with 'checking' state - ownership is resolved via SSE stream"""
|
||||||
try:
|
try:
|
||||||
print("🔄 Building discography using Spotify categorization...")
|
print("🔄 Building discography cards (fast path - no DB matching)...")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
def build_category(spotify_category, category_name):
|
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 = []
|
cards = []
|
||||||
print(f"📀 Building {category_name} category with {len(spotify_category)} Spotify releases")
|
|
||||||
|
|
||||||
for spotify_release in spotify_category:
|
for spotify_release in spotify_category:
|
||||||
spotify_key = (normalize_title(spotify_release['title']), normalize_year(spotify_release.get('year')))
|
card = {
|
||||||
|
'title': spotify_release['title'],
|
||||||
# Check if we own this release (exact match first)
|
'spotify_id': spotify_release.get('spotify_id'),
|
||||||
owned_release = None
|
'album_type': spotify_release.get('album_type', 'album'),
|
||||||
matched_key = None
|
'image_url': spotify_release.get('image_url'),
|
||||||
|
'year': spotify_release.get('year'),
|
||||||
if spotify_key in owned_map and owned_map[spotify_key]:
|
'track_count': spotify_release.get('track_count', 0),
|
||||||
owned_release = owned_map[spotify_key].pop(0).copy()
|
'owned': None, # null = checking (resolved by completion stream)
|
||||||
matched_key = spotify_key
|
'track_completion': 'checking',
|
||||||
else:
|
}
|
||||||
# Fallback: try matching by title only (ignore year)
|
if spotify_release.get('release_date'):
|
||||||
title_only = normalize_title(spotify_release['title'])
|
card['release_date'] = spotify_release['release_date']
|
||||||
for key, releases in owned_map.items():
|
elif spotify_release.get('year'):
|
||||||
if key[0] == title_only and releases: # key[0] is the normalized title
|
card['release_date'] = f"{spotify_release['year']}-01-01"
|
||||||
owned_release = releases.pop(0).copy()
|
cards.append(card)
|
||||||
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")
|
|
||||||
|
|
||||||
return cards
|
return cards
|
||||||
|
|
||||||
# Build each category using Spotify as the source of truth
|
|
||||||
albums = build_category(spotify_discography['albums'], 'Albums')
|
albums = build_category(spotify_discography['albums'], 'Albums')
|
||||||
eps = build_category(spotify_discography['eps'], 'EPs')
|
eps = build_category(spotify_discography['eps'], 'EPs')
|
||||||
singles = build_category(spotify_discography['singles'], 'Singles')
|
singles = build_category(spotify_discography['singles'], 'Singles')
|
||||||
|
|
||||||
# Report any owned releases that didn't match Spotify (rare)
|
print(f"✅ Built discography cards - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}")
|
||||||
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)}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|
|
||||||
|
|
@ -25368,6 +25368,12 @@ let artistDetailPageState = {
|
||||||
function navigateToArtistDetail(artistId, artistName) {
|
function navigateToArtistDetail(artistId, artistName) {
|
||||||
console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId})`);
|
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
|
// Store current artist info
|
||||||
artistDetailPageState.currentArtistId = artistId;
|
artistDetailPageState.currentArtistId = artistId;
|
||||||
artistDetailPageState.currentArtistName = artistName;
|
artistDetailPageState.currentArtistName = artistName;
|
||||||
|
|
@ -25392,6 +25398,11 @@ function initializeArtistDetailPage() {
|
||||||
if (backBtn) {
|
if (backBtn) {
|
||||||
backBtn.addEventListener("click", () => {
|
backBtn.addEventListener("click", () => {
|
||||||
console.log("🔙 Returning to Library page");
|
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
|
// Clear artist detail state so we go back to the list view
|
||||||
artistDetailPageState.currentArtistId = null;
|
artistDetailPageState.currentArtistId = null;
|
||||||
artistDetailPageState.currentArtistName = 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
|
// Update header with artist name and MusicBrainz link LAST to avoid overwrite
|
||||||
updateArtistDetailPageHeaderWithData(data.artist);
|
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) {
|
} catch (error) {
|
||||||
console.error(`❌ Error loading artist detail data:`, error);
|
console.error(`❌ Error loading artist detail data:`, error);
|
||||||
|
|
||||||
|
|
@ -25590,28 +25612,30 @@ function updateArtistGenres(genres) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateArtistSummaryStats(discography) {
|
function updateArtistSummaryStats(discography) {
|
||||||
// Calculate stats
|
const allReleases = [...discography.albums, ...discography.eps, ...discography.singles];
|
||||||
const ownedAlbums = discography.albums.filter(album => album.owned).length;
|
const hasChecking = allReleases.some(r => r.owned === null);
|
||||||
const missingAlbums = discography.albums.filter(album => !album.owned).length;
|
|
||||||
|
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 totalAlbums = discography.albums.length;
|
||||||
const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0;
|
const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0;
|
||||||
|
|
||||||
// Update owned albums count
|
// Update owned albums count
|
||||||
const ownedElement = document.getElementById("owned-albums-count");
|
const ownedElement = document.getElementById("owned-albums-count");
|
||||||
if (ownedElement) {
|
if (ownedElement) {
|
||||||
ownedElement.textContent = ownedAlbums;
|
ownedElement.textContent = hasChecking ? '...' : ownedAlbums;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update missing albums count
|
// Update missing albums count
|
||||||
const missingElement = document.getElementById("missing-albums-count");
|
const missingElement = document.getElementById("missing-albums-count");
|
||||||
if (missingElement) {
|
if (missingElement) {
|
||||||
missingElement.textContent = missingAlbums;
|
missingElement.textContent = hasChecking ? '...' : missingAlbums;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update completion percentage
|
// Update completion percentage
|
||||||
const completionElement = document.getElementById("completion-percentage");
|
const completionElement = document.getElementById("completion-percentage");
|
||||||
if (completionElement) {
|
if (completionElement) {
|
||||||
completionElement.textContent = `${completionPercentage}%`;
|
completionElement.textContent = hasChecking ? 'Checking...' : `${completionPercentage}%`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25675,29 +25699,42 @@ function updateArtistHeroSection(artist, discography) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCategoryStats(category, releases) {
|
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 missing = releases.filter(r => r.owned === false).length;
|
||||||
const total = releases.length;
|
const total = releases.length;
|
||||||
const completion = total > 0 ? Math.round((owned / total) * 100) : 100;
|
const completion = total > 0 ? Math.round((owned / total) * 100) : 100;
|
||||||
|
|
||||||
console.log(`📊 ${category}: ${owned} owned, ${missing} missing, ${completion}% complete`);
|
|
||||||
|
|
||||||
// Update stats text
|
// Update stats text
|
||||||
const statsElement = document.getElementById(`${category}-stats`);
|
const statsElement = document.getElementById(`${category}-stats`);
|
||||||
if (statsElement) {
|
if (statsElement) {
|
||||||
statsElement.textContent = `${owned} owned, ${missing} missing`;
|
if (hasChecking) {
|
||||||
|
statsElement.textContent = `Checking...`;
|
||||||
|
} else {
|
||||||
|
statsElement.textContent = `${owned} owned, ${missing} missing`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update completion bar
|
// Update completion bar
|
||||||
const fillElement = document.getElementById(`${category}-completion-fill`);
|
const fillElement = document.getElementById(`${category}-completion-fill`);
|
||||||
if (fillElement) {
|
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
|
// Update completion text
|
||||||
const textElement = document.getElementById(`${category}-completion-text`);
|
const textElement = document.getElementById(`${category}-completion-text`);
|
||||||
if (textElement) {
|
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
|
// Clear existing content
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
|
|
||||||
// Calculate stats
|
const hasChecking = releases.some(r => r.owned === null);
|
||||||
const ownedCount = releases.filter(release => release.owned).length;
|
const ownedCount = releases.filter(release => release.owned === true).length;
|
||||||
const missingCount = releases.filter(release => !release.owned).length;
|
const missingCount = releases.filter(release => release.owned === false).length;
|
||||||
|
|
||||||
// Update section stats
|
// Update section stats
|
||||||
const ownedElement = document.getElementById(ownedCountId);
|
const ownedElement = document.getElementById(ownedCountId);
|
||||||
const missingElement = document.getElementById(missingCountId);
|
const missingElement = document.getElementById(missingCountId);
|
||||||
|
|
||||||
if (ownedElement) {
|
if (ownedElement) {
|
||||||
ownedElement.textContent = `${ownedCount} owned`;
|
ownedElement.textContent = hasChecking ? 'Checking...' : `${ownedCount} owned`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingElement) {
|
if (missingElement) {
|
||||||
missingElement.textContent = `${missingCount} missing`;
|
missingElement.textContent = hasChecking ? '' : `${missingCount} missing`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create release cards
|
// Create release cards
|
||||||
releases.forEach((release, index) => {
|
releases.forEach((release, index) => {
|
||||||
console.log(`📀 Creating card ${index + 1} for: ${release.title}`);
|
|
||||||
const card = createReleaseCard(release);
|
const card = createReleaseCard(release);
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
console.log(`📀 Added card to grid:`, card);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`);
|
console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`);
|
||||||
|
|
@ -25754,9 +25789,12 @@ function populateReleaseSection(sectionType, releases) {
|
||||||
|
|
||||||
function createReleaseCard(release) {
|
function createReleaseCard(release) {
|
||||||
const card = document.createElement("div");
|
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-release-id", release.id || "");
|
||||||
card.setAttribute("data-spotify-id", release.spotify_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
|
// Add MusicBrainz icon if available
|
||||||
let mbIcon = null;
|
let mbIcon = null;
|
||||||
|
|
@ -25847,7 +25885,13 @@ function createReleaseCard(release) {
|
||||||
const completionFill = document.createElement("div");
|
const completionFill = document.createElement("div");
|
||||||
completionFill.className = "completion-fill";
|
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
|
// Handle new detailed track completion object
|
||||||
if (release.track_completion && typeof release.track_completion === 'object') {
|
if (release.track_completion && typeof release.track_completion === 'object') {
|
||||||
const completion = release.track_completion;
|
const completion = release.track_completion;
|
||||||
|
|
@ -25907,15 +25951,22 @@ function createReleaseCard(release) {
|
||||||
card.appendChild(mbIcon);
|
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 () => {
|
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
|
// For owned/complete releases, show info message
|
||||||
if (release.owned && (!release.track_completion ||
|
if (rel.owned && (!rel.track_completion ||
|
||||||
(typeof release.track_completion === 'object' && release.track_completion.missing_tracks === 0) ||
|
(typeof rel.track_completion === 'object' && rel.track_completion.missing_tracks === 0) ||
|
||||||
(typeof release.track_completion === 'number' && release.track_completion === 100))) {
|
(typeof rel.track_completion === 'number' && rel.track_completion === 100))) {
|
||||||
showToast(`${release.title} is already complete in your library`, "info");
|
showToast(`${rel.title} is already complete in your library`, "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25925,13 +25976,13 @@ function createReleaseCard(release) {
|
||||||
try {
|
try {
|
||||||
// Convert release object to album format expected by our function
|
// Convert release object to album format expected by our function
|
||||||
const albumData = {
|
const albumData = {
|
||||||
id: release.spotify_id || release.id,
|
id: rel.spotify_id || rel.id,
|
||||||
name: release.title,
|
name: rel.title,
|
||||||
image_url: release.image_url,
|
image_url: rel.image_url,
|
||||||
release_date: release.year ? `${release.year}-01-01` : '',
|
release_date: rel.year ? `${rel.year}-01-01` : '',
|
||||||
album_type: release.album_type || release.type || 'album',
|
album_type: rel.album_type || rel.type || 'album',
|
||||||
total_tracks: (release.track_completion && typeof release.track_completion === 'object')
|
total_tracks: (rel.track_completion && typeof rel.track_completion === 'object')
|
||||||
? release.track_completion.total_tracks : 1
|
? rel.track_completion.total_tracks : (rel.track_count || 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current artist from artist detail page state
|
// Get current artist from artist detail page state
|
||||||
|
|
@ -25959,7 +26010,7 @@ function createReleaseCard(release) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the actual album type from release data
|
// 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
|
// Open the Add to Wishlist modal
|
||||||
// Note: openAddToWishlistModal has its own loading overlay
|
// 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
|
// UI state management functions
|
||||||
function showArtistDetailLoading(show) {
|
function showArtistDetailLoading(show) {
|
||||||
const loadingElement = document.getElementById("artist-detail-loading");
|
const loadingElement = document.getElementById("artist-detail-loading");
|
||||||
|
|
|
||||||
|
|
@ -13281,6 +13281,33 @@ body {
|
||||||
width: 0%;
|
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 */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.artist-detail-header {
|
.artist-detail-header {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue