scan db on artist page
This commit is contained in:
parent
d13bd255d0
commit
245c6dfbf2
3 changed files with 668 additions and 3 deletions
345
web_server.py
345
web_server.py
|
|
@ -1692,6 +1692,351 @@ def get_artist_discography(artist_id):
|
|||
traceback.print_exc()
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/artist/<artist_id>/completion', methods=['POST'])
|
||||
def check_artist_discography_completion(artist_id):
|
||||
"""Check completion status for artist's albums and singles"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'discography' not in data:
|
||||
return jsonify({"error": "Missing discography data"}), 400
|
||||
|
||||
discography = data['discography']
|
||||
test_mode = data.get('test_mode', False) # Add test mode for demonstration
|
||||
albums_completion = []
|
||||
singles_completion = []
|
||||
|
||||
# Get database instance
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
|
||||
# Get artist name - should be provided by the frontend
|
||||
artist_name = data.get('artist_name', 'Unknown Artist')
|
||||
|
||||
# If no artist name provided, try to infer it from the request
|
||||
if artist_name == 'Unknown Artist':
|
||||
print(f"⚠️ No artist name provided in request, attempting to infer from discography data")
|
||||
# Try to extract from first album's title by using a simple search
|
||||
all_items = discography.get('albums', []) + discography.get('singles', [])
|
||||
if all_items and spotify_client and spotify_client.is_authenticated():
|
||||
try:
|
||||
first_item = all_items[0]
|
||||
# Search for the first track to get artist name
|
||||
search_results = spotify_client.search_tracks(first_item.get('name', ''), limit=1)
|
||||
if search_results and len(search_results) > 0:
|
||||
artist_name = search_results[0].artists[0] if search_results[0].artists else "Unknown Artist"
|
||||
print(f"🎤 Inferred artist name from search: {artist_name}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not infer artist name: {e}")
|
||||
artist_name = "Unknown Artist"
|
||||
|
||||
print(f"🎤 Checking completion for artist: {artist_name}")
|
||||
|
||||
# Process albums
|
||||
for album in discography.get('albums', []):
|
||||
completion_data = _check_album_completion(db, album, artist_name, test_mode)
|
||||
albums_completion.append(completion_data)
|
||||
|
||||
# Process singles/EPs
|
||||
for single in discography.get('singles', []):
|
||||
completion_data = _check_single_completion(db, single, artist_name, test_mode)
|
||||
singles_completion.append(completion_data)
|
||||
|
||||
return jsonify({
|
||||
"albums": albums_completion,
|
||||
"singles": singles_completion
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error checking discography completion: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
def _check_album_completion(db: 'MusicDatabase', album_data: dict, artist_name: str, test_mode: bool = False) -> dict:
|
||||
"""Check completion status for a single album"""
|
||||
try:
|
||||
album_name = album_data.get('name', '')
|
||||
total_tracks = album_data.get('total_tracks', 0)
|
||||
album_id = album_data.get('id', '')
|
||||
|
||||
print(f"🔍 Checking album: '{album_name}' ({total_tracks} tracks)")
|
||||
|
||||
if test_mode:
|
||||
# Generate test data to demonstrate the feature
|
||||
import random
|
||||
owned_tracks = random.randint(0, max(1, total_tracks))
|
||||
expected_tracks = total_tracks
|
||||
confidence = random.uniform(0.7, 1.0)
|
||||
db_album = True # Simulate found album
|
||||
print(f"🧪 TEST MODE: Simulating {owned_tracks}/{expected_tracks} tracks for '{album_name}'")
|
||||
else:
|
||||
# Check if album exists in database with completeness info
|
||||
try:
|
||||
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
|
||||
title=album_name,
|
||||
artist=artist_name,
|
||||
expected_track_count=total_tracks if total_tracks > 0 else None,
|
||||
confidence_threshold=0.7 # Slightly lower threshold for better matching
|
||||
)
|
||||
except Exception as db_error:
|
||||
print(f"⚠️ Database error for album '{album_name}': {db_error}")
|
||||
# Return error state for this album
|
||||
return {
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
"expected_tracks": total_tracks,
|
||||
"completion_percentage": 0,
|
||||
"confidence": 0.0,
|
||||
"found_in_db": False,
|
||||
"error_message": str(db_error)
|
||||
}
|
||||
|
||||
# Calculate completion percentage
|
||||
if expected_tracks > 0:
|
||||
completion_percentage = (owned_tracks / expected_tracks) * 100
|
||||
elif total_tracks > 0:
|
||||
completion_percentage = (owned_tracks / total_tracks) * 100
|
||||
else:
|
||||
completion_percentage = 100 if owned_tracks > 0 else 0
|
||||
|
||||
# Determine completion status based on percentage
|
||||
if completion_percentage >= 90 and owned_tracks > 0:
|
||||
status = "completed"
|
||||
elif completion_percentage >= 60:
|
||||
status = "nearly_complete"
|
||||
elif completion_percentage > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "missing"
|
||||
|
||||
print(f" 📊 Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}")
|
||||
|
||||
return {
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
"status": status,
|
||||
"owned_tracks": owned_tracks,
|
||||
"expected_tracks": expected_tracks or total_tracks,
|
||||
"completion_percentage": round(completion_percentage, 1),
|
||||
"confidence": round(confidence, 2) if confidence else 0.0,
|
||||
"found_in_db": db_album is not None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": album_data.get('id', ''),
|
||||
"name": album_data.get('name', 'Unknown'),
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
"expected_tracks": album_data.get('total_tracks', 0),
|
||||
"completion_percentage": 0,
|
||||
"confidence": 0.0,
|
||||
"found_in_db": False
|
||||
}
|
||||
|
||||
def _check_single_completion(db: 'MusicDatabase', single_data: dict, artist_name: str, test_mode: bool = False) -> dict:
|
||||
"""Check completion status for a single/EP (treat EPs like albums, singles as single tracks)"""
|
||||
try:
|
||||
single_name = single_data.get('name', '')
|
||||
total_tracks = single_data.get('total_tracks', 1)
|
||||
single_id = single_data.get('id', '')
|
||||
album_type = single_data.get('album_type', 'single')
|
||||
|
||||
print(f"🎵 Checking {album_type}: '{single_name}' ({total_tracks} tracks)")
|
||||
|
||||
if test_mode:
|
||||
# Generate test data for singles/EPs
|
||||
import random
|
||||
if album_type == 'ep' or total_tracks > 1:
|
||||
owned_tracks = random.randint(0, total_tracks)
|
||||
expected_tracks = total_tracks
|
||||
confidence = random.uniform(0.7, 1.0)
|
||||
print(f"🧪 TEST MODE: EP with {owned_tracks}/{expected_tracks} tracks")
|
||||
else:
|
||||
owned_tracks = random.choice([0, 1]) # 50/50 chance
|
||||
expected_tracks = 1
|
||||
confidence = random.uniform(0.7, 1.0) if owned_tracks else 0.0
|
||||
print(f"🧪 TEST MODE: Single with {owned_tracks}/{expected_tracks} tracks")
|
||||
elif album_type == 'ep' or total_tracks > 1:
|
||||
# Treat EPs like albums
|
||||
try:
|
||||
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
|
||||
title=single_name,
|
||||
artist=artist_name,
|
||||
expected_track_count=total_tracks,
|
||||
confidence_threshold=0.7
|
||||
)
|
||||
except Exception as db_error:
|
||||
print(f"⚠️ Database error for EP '{single_name}': {db_error}")
|
||||
owned_tracks, expected_tracks, confidence = 0, total_tracks, 0.0
|
||||
|
||||
# Calculate completion percentage
|
||||
if expected_tracks > 0:
|
||||
completion_percentage = (owned_tracks / expected_tracks) * 100
|
||||
else:
|
||||
completion_percentage = (owned_tracks / total_tracks) * 100
|
||||
|
||||
# Determine status
|
||||
if completion_percentage >= 90 and owned_tracks > 0:
|
||||
status = "completed"
|
||||
elif completion_percentage >= 60:
|
||||
status = "nearly_complete"
|
||||
elif completion_percentage > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "missing"
|
||||
|
||||
print(f" 📊 EP Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}")
|
||||
|
||||
else:
|
||||
# Single track - just check if the track exists
|
||||
try:
|
||||
db_track, confidence = db.check_track_exists(
|
||||
title=single_name,
|
||||
artist=artist_name,
|
||||
confidence_threshold=0.7
|
||||
)
|
||||
except Exception as db_error:
|
||||
print(f"⚠️ Database error for single '{single_name}': {db_error}")
|
||||
db_track, confidence = None, 0.0
|
||||
|
||||
owned_tracks = 1 if db_track else 0
|
||||
expected_tracks = 1
|
||||
completion_percentage = 100 if db_track else 0
|
||||
|
||||
status = "completed" if db_track else "missing"
|
||||
|
||||
print(f" 🎵 Single Result: {owned_tracks}/1 tracks ({completion_percentage:.1f}%) - {status}")
|
||||
|
||||
return {
|
||||
"id": single_id,
|
||||
"name": single_name,
|
||||
"status": status,
|
||||
"owned_tracks": owned_tracks,
|
||||
"expected_tracks": expected_tracks or total_tracks,
|
||||
"completion_percentage": round(completion_percentage, 1),
|
||||
"confidence": round(confidence, 2) if confidence else 0.0,
|
||||
"found_in_db": (db_album if album_type == 'ep' or total_tracks > 1 else db_track) is not None,
|
||||
"type": album_type
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": single_data.get('id', ''),
|
||||
"name": single_data.get('name', 'Unknown'),
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
"expected_tracks": single_data.get('total_tracks', 1),
|
||||
"completion_percentage": 0,
|
||||
"confidence": 0.0,
|
||||
"found_in_db": False,
|
||||
"type": single_data.get('album_type', 'single')
|
||||
}
|
||||
|
||||
@app.route('/api/artist/<artist_id>/completion-stream', methods=['POST'])
|
||||
def check_artist_discography_completion_stream(artist_id):
|
||||
"""Stream completion status for artist's albums and singles one by one"""
|
||||
# Capture request data BEFORE the generator function
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'discography' not in data:
|
||||
return jsonify({"error": "Missing discography data"}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": "Invalid request data"}), 400
|
||||
|
||||
# Extract data for the generator
|
||||
discography = data['discography']
|
||||
test_mode = data.get('test_mode', False)
|
||||
artist_name = data.get('artist_name', 'Unknown Artist')
|
||||
|
||||
def generate_completion_stream():
|
||||
try:
|
||||
print(f"🎤 Starting streaming completion check for artist: {artist_name}")
|
||||
|
||||
# Get database instance
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
|
||||
# Process albums one by one
|
||||
total_items = len(discography.get('albums', [])) + len(discography.get('singles', []))
|
||||
processed_count = 0
|
||||
|
||||
# Send initial status
|
||||
yield f"data: {json.dumps({'type': 'start', 'total_items': total_items, 'artist_name': artist_name})}\n\n"
|
||||
|
||||
# Process albums
|
||||
for album in discography.get('albums', []):
|
||||
try:
|
||||
completion_data = _check_album_completion(db, album, artist_name, test_mode)
|
||||
completion_data['type'] = 'album_completion'
|
||||
completion_data['container_type'] = 'albums'
|
||||
processed_count += 1
|
||||
completion_data['progress'] = round((processed_count / total_items) * 100, 1)
|
||||
|
||||
yield f"data: {json.dumps(completion_data)}\n\n"
|
||||
|
||||
# Small delay to make the streaming effect visible
|
||||
import time
|
||||
time.sleep(0.1) # 100ms delay between items
|
||||
|
||||
except Exception as e:
|
||||
error_data = {
|
||||
'type': 'error',
|
||||
'container_type': 'albums',
|
||||
'id': album.get('id', ''),
|
||||
'name': album.get('name', 'Unknown'),
|
||||
'error': str(e)
|
||||
}
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
|
||||
# Process singles/EPs
|
||||
for single in discography.get('singles', []):
|
||||
try:
|
||||
completion_data = _check_single_completion(db, single, artist_name, test_mode)
|
||||
completion_data['type'] = 'single_completion'
|
||||
completion_data['container_type'] = 'singles'
|
||||
processed_count += 1
|
||||
completion_data['progress'] = round((processed_count / total_items) * 100, 1)
|
||||
|
||||
yield f"data: {json.dumps(completion_data)}\n\n"
|
||||
|
||||
# Small delay to make the streaming effect visible
|
||||
time.sleep(0.1) # 100ms delay between items
|
||||
|
||||
except Exception as e:
|
||||
error_data = {
|
||||
'type': 'error',
|
||||
'container_type': 'singles',
|
||||
'id': single.get('id', ''),
|
||||
'name': single.get('name', 'Unknown'),
|
||||
'error': str(e)
|
||||
}
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
|
||||
# Send completion signal
|
||||
yield f"data: {json.dumps({'type': 'complete', 'processed_count': processed_count})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in streaming completion check: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
|
||||
|
||||
return Response(
|
||||
generate_completion_stream(),
|
||||
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"""
|
||||
|
|
|
|||
|
|
@ -9375,7 +9375,10 @@ async function loadArtistDiscography(artistId) {
|
|||
// Check cache first
|
||||
if (artistsPageState.cache.discography[artistId]) {
|
||||
console.log('📦 Using cached discography');
|
||||
displayArtistDiscography(artistsPageState.cache.discography[artistId]);
|
||||
const cachedDiscography = artistsPageState.cache.discography[artistId];
|
||||
displayArtistDiscography(cachedDiscography);
|
||||
// Still check completion status for cached data
|
||||
await checkDiscographyCompletion(artistId, cachedDiscography);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -9413,6 +9416,9 @@ async function loadArtistDiscography(artistId) {
|
|||
// Display results
|
||||
displayArtistDiscography(discography);
|
||||
|
||||
// Check completion status for all albums and singles
|
||||
await checkDiscographyCompletion(artistId, discography);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load discography:', error);
|
||||
showDiscographyError(error.message);
|
||||
|
|
@ -9476,6 +9482,183 @@ function displayArtistDiscography(discography) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check completion status for entire discography with streaming updates
|
||||
*/
|
||||
async function checkDiscographyCompletion(artistId, discography) {
|
||||
console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
|
||||
|
||||
try {
|
||||
// Use EventSource for Server-Sent Events streaming
|
||||
const eventSource = new EventSource('data:text/plain,'); // Dummy EventSource
|
||||
|
||||
// Use fetch with streaming response
|
||||
const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
discography: discography,
|
||||
artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
|
||||
test_mode: window.location.search.includes('test=true')
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to start completion check: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handleStreamingCompletionUpdate(data);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse streaming data:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check completion status:', error);
|
||||
showCompletionError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle individual streaming completion updates
|
||||
*/
|
||||
function handleStreamingCompletionUpdate(data) {
|
||||
console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
|
||||
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
|
||||
// Could show a progress indicator here
|
||||
break;
|
||||
|
||||
case 'album_completion':
|
||||
updateAlbumCompletionOverlay(data, 'albums');
|
||||
console.log(`📀 Updated album: ${data.name} (${data.status})`);
|
||||
break;
|
||||
|
||||
case 'single_completion':
|
||||
updateAlbumCompletionOverlay(data, 'singles');
|
||||
console.log(`🎵 Updated single: ${data.name} (${data.status})`);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('❌ Error processing item:', data.name, data.error);
|
||||
// Could show error for specific item
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown streaming update type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update completion overlay for a specific album/single
|
||||
*/
|
||||
function updateAlbumCompletionOverlay(completionData, containerType) {
|
||||
const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (!container) {
|
||||
console.warn(`Container ${containerId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the album card by data-album-id
|
||||
const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
|
||||
|
||||
if (!albumCard) {
|
||||
console.warn(`Album card not found for ID: ${completionData.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = albumCard.querySelector('.completion-overlay');
|
||||
if (!overlay) {
|
||||
console.warn(`Completion overlay not found for album: ${completionData.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing status classes
|
||||
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error');
|
||||
|
||||
// Add new status class
|
||||
overlay.classList.add(completionData.status);
|
||||
|
||||
// Update overlay text and content
|
||||
const statusText = getCompletionStatusText(completionData);
|
||||
const progressText = `${completionData.owned_tracks}/${completionData.expected_tracks}`;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<span class="completion-status">${statusText}</span>
|
||||
<span class="completion-progress">${progressText}</span>
|
||||
`;
|
||||
|
||||
// Add tooltip with more details
|
||||
overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`;
|
||||
|
||||
// Add brief flash animation to indicate update
|
||||
overlay.style.animation = 'none';
|
||||
overlay.offsetHeight; // Trigger reflow
|
||||
overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
|
||||
console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status text for completion overlay
|
||||
*/
|
||||
function getCompletionStatusText(completionData) {
|
||||
switch (completionData.status) {
|
||||
case 'completed':
|
||||
return 'Complete';
|
||||
case 'nearly_complete':
|
||||
return 'Nearly Complete';
|
||||
case 'partial':
|
||||
return 'Partial';
|
||||
case 'missing':
|
||||
return 'Missing';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error state on all completion overlays
|
||||
*/
|
||||
function showCompletionError() {
|
||||
const allOverlays = document.querySelectorAll('.completion-overlay.checking');
|
||||
allOverlays.forEach(overlay => {
|
||||
overlay.classList.remove('checking');
|
||||
overlay.classList.add('error');
|
||||
overlay.innerHTML = '<span class="completion-status">Error</span>';
|
||||
overlay.title = 'Failed to check completion status';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTML for an album/single card
|
||||
*/
|
||||
|
|
@ -9491,8 +9674,11 @@ function createAlbumCard(album) {
|
|||
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);`;
|
||||
|
||||
return `
|
||||
<div class="album-card" data-album-id="${album.id}">
|
||||
<div class="album-card" data-album-id="${album.id}" data-album-name="${escapeHtml(album.name)}" data-album-type="${album.album_type}" data-total-tracks="${album.total_tracks || 0}">
|
||||
<div class="album-card-image" style="${backgroundStyle}"></div>
|
||||
<div class="completion-overlay checking">
|
||||
<span class="completion-status">Checking...</span>
|
||||
</div>
|
||||
<div class="album-card-content">
|
||||
<div class="album-card-name" title="${escapeHtml(album.name)}">${escapeHtml(album.name)}</div>
|
||||
<div class="album-card-year">${year || 'Unknown'}</div>
|
||||
|
|
|
|||
|
|
@ -5908,7 +5908,7 @@ body {
|
|||
|
||||
/* Elegant shadow system */
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.5),
|
||||
0 10px 20px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(29, 185, 84, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
|
@ -6361,6 +6361,140 @@ body {
|
|||
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
/* Completion Status Overlay */
|
||||
.completion-overlay {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid;
|
||||
z-index: 10;
|
||||
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
/* Smooth entrance animation */
|
||||
animation: completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Completion status variants */
|
||||
.completion-overlay.completed {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(46, 204, 64, 0.9) 0%,
|
||||
rgba(34, 139, 47, 0.95) 100%);
|
||||
color: #ffffff;
|
||||
border-color: rgba(46, 204, 64, 0.6);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(46, 204, 64, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.completion-overlay.nearly_complete {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 193, 7, 0.9) 0%,
|
||||
rgba(255, 152, 0, 0.95) 100%);
|
||||
color: #ffffff;
|
||||
border-color: rgba(255, 193, 7, 0.6);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 193, 7, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.completion-overlay.partial {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 111, 97, 0.9) 0%,
|
||||
rgba(255, 87, 51, 0.95) 100%);
|
||||
color: #ffffff;
|
||||
border-color: rgba(255, 111, 97, 0.6);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 111, 97, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.completion-overlay.missing {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(108, 117, 125, 0.9) 0%,
|
||||
rgba(73, 80, 87, 0.95) 100%);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(108, 117, 125, 0.6);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(108, 117, 125, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.completion-overlay.error {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(220, 53, 69, 0.9) 0%,
|
||||
rgba(176, 42, 55, 0.95) 100%);
|
||||
color: #ffffff;
|
||||
border-color: rgba(220, 53, 69, 0.6);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(220, 53, 69, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Hover effects for completion overlays */
|
||||
.album-card:hover .completion-overlay,
|
||||
.artist-card:hover .completion-overlay {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px currentColor,
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Loading state for completion overlay */
|
||||
.completion-overlay.checking {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(29, 185, 84, 0.9) 0%,
|
||||
rgba(24, 156, 71, 0.95) 100%);
|
||||
color: #ffffff;
|
||||
border-color: rgba(29, 185, 84, 0.6);
|
||||
animation: completionOverlayPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Progress indicator inside overlay for detailed completion info */
|
||||
.completion-overlay .completion-progress {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
margin-top: 2px;
|
||||
opacity: 0.8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Completion overlay animations */
|
||||
@keyframes completionOverlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes completionOverlayPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states for album cards */
|
||||
.album-card.loading .album-card-image {
|
||||
background: linear-gradient(
|
||||
|
|
|
|||
Loading…
Reference in a new issue