diff --git a/web_server.py b/web_server.py index 892392cb..5198ef2d 100644 --- a/web_server.py +++ b/web_server.py @@ -8660,6 +8660,203 @@ def test_database_access(): "message": "Database access test failed" }), 500 +# --- Artist Bubble Snapshot System --- + +@app.route('/api/artist_bubbles/snapshot', methods=['POST']) +def save_artist_bubble_snapshot(): + """ + Saves a snapshot of current artist bubble state for persistence across page refreshes. + """ + try: + import os + import json + from datetime import datetime + + data = request.json + if not data or 'bubbles' not in data: + return jsonify({'success': False, 'error': 'No bubble data provided'}), 400 + + bubbles = data['bubbles'] + + # Create snapshot with timestamp + snapshot = { + 'bubbles': bubbles, + 'timestamp': datetime.now().isoformat(), + 'snapshot_id': datetime.now().strftime('%Y%m%d_%H%M%S') + } + + # Save to file + snapshot_file = os.path.join(os.path.dirname(__file__), 'artist_bubble_snapshots.json') + with open(snapshot_file, 'w') as f: + json.dump(snapshot, f, indent=2) + + bubble_count = len(bubbles) + print(f"๐Ÿ“ธ Saved artist bubble snapshot: {bubble_count} artists") + + return jsonify({ + 'success': True, + 'message': f'Snapshot saved with {bubble_count} artist bubbles', + 'timestamp': snapshot['timestamp'] + }) + + except Exception as e: + print(f"โŒ Error saving artist bubble snapshot: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@app.route('/api/artist_bubbles/hydrate', methods=['GET']) +def hydrate_artist_bubbles(): + """ + Loads artist bubbles with live status by cross-referencing snapshots with active processes. + """ + try: + import os + import json + from datetime import datetime, timedelta + + snapshot_file = os.path.join(os.path.dirname(__file__), 'artist_bubble_snapshots.json') + + # Load snapshot if it exists + if not os.path.exists(snapshot_file): + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'No snapshots found' + }) + + with open(snapshot_file, 'r') as f: + snapshot_data = json.load(f) + + saved_bubbles = snapshot_data.get('bubbles', {}) + snapshot_time = snapshot_data.get('timestamp', '') + + # Clean up old snapshots (older than 48 hours) + try: + if snapshot_time: + snapshot_dt = datetime.fromisoformat(snapshot_time.replace('Z', '+00:00')) + cutoff = datetime.now() - timedelta(hours=48) + if snapshot_dt < cutoff: + print(f"๐Ÿงน Cleaning up old snapshot from {snapshot_time}") + os.remove(snapshot_file) + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'Old snapshot cleaned up' + }) + except (ValueError, OSError) as e: + print(f"โš ๏ธ Error checking snapshot age: {e}") + + # Get current active download processes for live status + current_processes = {} + try: + with tasks_lock: + for batch_id, batch_data in download_batches.items(): + if batch_data.get('phase') not in ['complete', 'error', 'cancelled']: + playlist_id = batch_data.get('playlist_id') + if playlist_id: + current_processes[playlist_id] = { + 'status': 'in_progress' if batch_data.get('phase') == 'downloading' else 'analyzing', + 'batch_id': batch_id, + 'phase': batch_data.get('phase') + } + except Exception as e: + print(f"โš ๏ธ Error fetching active processes for hydration: {e}") + + # If no active processes exist, the app likely restarted - clean up snapshots + if not current_processes: + print(f"๐Ÿงน No active processes found - app likely restarted, cleaning up snapshot") + try: + os.remove(snapshot_file) + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'Snapshot cleaned up after app restart' + }) + except OSError as e: + print(f"โš ๏ธ Error removing snapshot file: {e}") + # Continue with empty result anyway + + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'No active processes - returning empty bubbles' + }) + + # Update bubble statuses with live data + hydrated_bubbles = {} + for artist_id, bubble_data in saved_bubbles.items(): + hydrated_bubble = { + 'artist': bubble_data['artist'], + 'downloads': [], + 'hasCompletedDownloads': False + } + + for download in bubble_data.get('downloads', []): + virtual_playlist_id = download['virtualPlaylistId'] + + # Determine current live status + if virtual_playlist_id in current_processes: + process_info = current_processes[virtual_playlist_id] + live_status = 'in_progress' + print(f"๐Ÿ”„ Found active process for {download['album']['name']}: {process_info['phase']}") + else: + # No active process - likely completed + live_status = 'view_results' + print(f"โœ… No active process for {download['album']['name']} - marking as completed") + + # Create updated download entry + updated_download = { + 'virtualPlaylistId': virtual_playlist_id, + 'album': download['album'], + 'albumType': download.get('albumType', 'album'), + 'status': live_status, + 'startTime': download.get('startTime', datetime.now().isoformat()) + } + + hydrated_bubble['downloads'].append(updated_download) + + # Update hasCompletedDownloads flag + if live_status == 'view_results': + hydrated_bubble['hasCompletedDownloads'] = True + + # Only include artists that still have downloads + if hydrated_bubble['downloads']: + hydrated_bubbles[artist_id] = hydrated_bubble + + bubble_count = len(hydrated_bubbles) + active_count = sum(1 for bubble in hydrated_bubbles.values() + for download in bubble['downloads'] + if download['status'] == 'in_progress') + completed_count = sum(1 for bubble in hydrated_bubbles.values() + for download in bubble['downloads'] + if download['status'] == 'view_results') + + print(f"๐Ÿ”„ Hydrated {bubble_count} artist bubbles: {active_count} active, {completed_count} completed") + + return jsonify({ + 'success': True, + 'bubbles': hydrated_bubbles, + 'stats': { + 'total_artists': bubble_count, + 'active_downloads': active_count, + 'completed_downloads': completed_count, + 'snapshot_time': snapshot_time + } + }) + + except Exception as e: + print(f"โŒ Error hydrating artist bubbles: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + # --- Main Execution --- if __name__ == '__main__': diff --git a/webui/static/script.js b/webui/static/script.js index 1e0407aa..d0b0a726 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1796,6 +1796,9 @@ async function startDownload(index) { async function loadInitialData() { try { + // Load artist bubble state first + await hydrateArtistBubblesFromSnapshot(); + // Load dashboard data by default await loadDashboardData(); } catch (error) { @@ -1877,6 +1880,82 @@ async function checkForActiveProcesses() { } } +async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) { + /** + * Rehydrates an artist album download modal from backend process data. + * Extracts artist/album info from virtual playlist ID and recreates the modal. + */ + try { + console.log(`๐Ÿ’ง Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`); + + // Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id] + const parts = virtualPlaylistId.split('_'); + if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') { + console.error(`โŒ Invalid virtual playlist ID format: ${virtualPlaylistId}`); + return; + } + + const artistId = parts[2]; + const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores + + console.log(`๐Ÿ” Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`); + + // Fetch the album tracks to get proper artist and album data + try { + const response = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`); + const data = await response.json(); + + if (!data.success || !data.album || !data.tracks) { + console.error('โŒ Failed to fetch album data for rehydration:', data.error); + return; + } + + const album = data.album; + const tracks = data.tracks; + + // Extract artist info from the first track (all tracks should have same artist) + const artist = { + id: artistId, + name: tracks[0].artists[0] // Use first artist name from first track + }; + + console.log(`โœ… Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`); + + // Create the modal using the same function as normal artist album downloads + await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist); + + // Update the rehydrated process with batch info and hide modal for background rehydration + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + + // Update button states to reflect running status + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Hide the modal - this is background rehydration, not user-requested + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${album.name}`); + } + + console.log(`โœ… Rehydrated artist album modal: ${artist.name} - ${album.name}`); + } else { + console.error(`โŒ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + + } catch (error) { + console.error(`โŒ Error fetching album data for rehydration:`, error); + } + + } catch (error) { + console.error(`โŒ Error rehydrating artist album modal:`, error); + } +} + async function rehydrateModal(processInfo, userRequested = false) { const { playlist_id, playlist_name, batch_id } = processInfo; console.log(`๐Ÿ’ง Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`); @@ -1887,6 +1966,13 @@ async function rehydrateModal(processInfo, userRequested = false) { return; } + // Handle artist album virtual playlists + if (playlist_id.startsWith('artist_album_')) { + console.log(`๐Ÿ’ง Rehydrating artist album virtual playlist: ${playlist_id}`); + await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id); + return; + } + // Handle wishlist processes specially if (playlist_id === "wishlist") { console.log(`๐Ÿ” Current activeDownloadProcesses keys: [${Object.keys(activeDownloadProcesses).join(', ')}]`); @@ -10693,6 +10779,9 @@ function registerArtistDownload(artist, album, virtualPlaylistId, albumType) { // Show/update the artist downloads section updateArtistDownloadsSection(); + // Save snapshot of current state + saveArtistBubbleSnapshot(); + // Monitor this download for completion monitorArtistDownload(artistId, virtualPlaylistId); } @@ -10709,6 +10798,140 @@ function updateArtistDownloadsSection() { }, 300); // 300ms debounce } +// --- Artist Bubble Snapshot System --- + +let snapshotSaveTimeout = null; // Debounce snapshot saves + +async function saveArtistBubbleSnapshot() { + /** + * Saves current artistDownloadBubbles state to backend for persistence. + * Debounced to prevent excessive backend calls. + */ + + // Clear any existing timeout + if (snapshotSaveTimeout) { + clearTimeout(snapshotSaveTimeout); + } + + // Debounce the actual save + snapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(artistDownloadBubbles).length; + + // Don't save empty state + if (bubbleCount === 0) { + console.log('๐Ÿ“ธ Skipping snapshot save - no artist bubbles to save'); + return; + } + + console.log(`๐Ÿ“ธ Saving artist bubble snapshot: ${bubbleCount} artists`); + + // Prepare snapshot data (clean up DOM references) + const cleanBubbles = {}; + for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) { + cleanBubbles[artistId] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(download => ({ + virtualPlaylistId: download.virtualPlaylistId, + album: download.album, + albumType: download.albumType, + status: download.status, + startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime + })), + hasCompletedDownloads: bubbleData.hasCompletedDownloads + }; + } + + const response = await fetch('/api/artist_bubbles/snapshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bubbles: cleanBubbles + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`โœ… Artist bubble snapshot saved: ${bubbleCount} artists`); + } else { + console.error('โŒ Failed to save artist bubble snapshot:', data.error); + } + + } catch (error) { + console.error('โŒ Error saving artist bubble snapshot:', error); + } + }, 1000); // 1 second debounce +} + +async function hydrateArtistBubblesFromSnapshot() { + /** + * Hydrates artist download bubbles from backend snapshot with live status. + * Called on page load to restore bubble state. + */ + try { + console.log('๐Ÿ”„ Loading artist bubble snapshot from backend...'); + + const response = await fetch('/api/artist_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('โŒ Failed to load artist bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + const stats = data.stats || {}; + + console.log(`๐Ÿ”„ Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); + + if (Object.keys(bubbles).length === 0) { + console.log('โ„น๏ธ No artist bubbles to hydrate'); + return; + } + + // Clear existing state + artistDownloadBubbles = {}; + + // Restore artistDownloadBubbles with hydrated data + for (const [artistId, bubbleData] of Object.entries(bubbles)) { + artistDownloadBubbles[artistId] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(download => ({ + virtualPlaylistId: download.virtualPlaylistId, + album: download.album, + albumType: download.albumType, + status: download.status, // Live status from backend + startTime: new Date(download.startTime) + })), + element: null, // Will be created when UI updates + hasCompletedDownloads: bubbleData.hasCompletedDownloads + }; + + console.log(`๐Ÿ”„ Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`); + + // Start monitoring for any in-progress downloads + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + console.log(`๐Ÿ“ก Starting monitoring for: ${download.album.name}`); + monitorArtistDownload(artistId, download.virtualPlaylistId); + } + } + } + + // Update UI to show hydrated bubbles + updateArtistDownloadsSection(); + + const totalArtists = Object.keys(artistDownloadBubbles).length; + console.log(`โœ… Successfully hydrated ${totalArtists} artist download bubbles`); + + } catch (error) { + console.error('โŒ Error hydrating artist bubbles from snapshot:', error); + } +} + /** * Show or update the artist downloads section in search state */ @@ -10863,6 +11086,9 @@ function monitorArtistDownload(artistId, virtualPlaylistId) { // Update the downloads section updateArtistDownloadsSection(); + // Save snapshot of updated state + saveArtistBubbleSnapshot(); + // Check if all downloads for this artist are now completed const artistDownloads = artistDownloadBubbles[artistId].downloads; const allCompleted = artistDownloads.every(d => d.status === 'view_results'); @@ -11117,6 +11343,9 @@ function cleanupArtistDownload(virtualPlaylistId) { // Update the downloads section console.log(`๐Ÿ”„ [CLEANUP] Updating artist downloads section...`); updateArtistDownloadsSection(); + + // Save snapshot of updated state + saveArtistBubbleSnapshot(); break; } }