From 245c6dfbf234aab732c6780c7933db65b3681da0 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 3 Sep 2025 23:00:20 -0700 Subject: [PATCH] scan db on artist page --- web_server.py | 345 +++++++++++++++++++++++++++++++++++++++++ webui/static/script.js | 190 ++++++++++++++++++++++- webui/static/style.css | 136 +++++++++++++++- 3 files changed, 668 insertions(+), 3 deletions(-) diff --git a/web_server.py b/web_server.py index 0dc1facf..6c042a09 100644 --- a/web_server.py +++ b/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//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//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""" diff --git a/webui/static/script.js b/webui/static/script.js index 5c932737..64432cd2 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = ` + ${statusText} + ${progressText} + `; + + // 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 = 'Error'; + 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 ` -
+
+
+ Checking... +
${escapeHtml(album.name)}
${year || 'Unknown'}
diff --git a/webui/static/style.css b/webui/static/style.css index 42d2046e..ccdab5bf 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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(