diff --git a/web_server.py b/web_server.py index 52966e2c..0e3c932e 100644 --- a/web_server.py +++ b/web_server.py @@ -15909,6 +15909,197 @@ def hydrate_artist_bubbles(): 'error': str(e) }), 500 +# --- Search Bubble Snapshot System --- + +@app.route('/api/search_bubbles/snapshot', methods=['POST']) +def save_search_bubble_snapshot(): + """ + Saves a snapshot of current search 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__), 'search_bubble_snapshots.json') + with open(snapshot_file, 'w') as f: + json.dump(snapshot, f, indent=2) + + bubble_count = len(bubbles) + print(f"๐ธ Saved search bubble snapshot: {bubble_count} albums/tracks") + + return jsonify({ + 'success': True, + 'message': f'Snapshot saved with {bubble_count} search bubbles', + 'timestamp': snapshot['timestamp'] + }) + + except Exception as e: + print(f"โ Error saving search bubble snapshot: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@app.route('/api/search_bubbles/hydrate', methods=['GET']) +def hydrate_search_bubbles(): + """ + Loads search 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__), 'search_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 search 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 search 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}") + + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'No active processes - returning empty bubbles' + }) + + # Update bubble statuses with live data (artist-grouped structure) + hydrated_bubbles = {} + for artist_name, bubble_data in saved_bubbles.items(): + hydrated_bubble = { + 'artist': bubble_data['artist'], + 'downloads': [] + } + + 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['item']['name']}: {process_info['phase']}") + else: + # No active process - likely completed + live_status = 'view_results' + print(f"โ No active process for {download['item']['name']} - marking as completed") + + # Create updated download entry + updated_download = { + 'virtualPlaylistId': virtual_playlist_id, + 'item': download['item'], + 'type': download.get('type', 'album'), + 'status': live_status, + 'startTime': download.get('startTime', datetime.now().isoformat()) + } + + hydrated_bubble['downloads'].append(updated_download) + + # Only include artists that still have downloads + if hydrated_bubble['downloads']: + hydrated_bubbles[artist_name] = 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} search bubbles (artists): {active_count} active, {completed_count} completed") + + return jsonify({ + 'success': True, + 'bubbles': hydrated_bubbles, + 'stats': { + 'total_items': bubble_count, + 'active_downloads': active_count, + 'completed_downloads': completed_count, + 'snapshot_time': snapshot_time + } + }) + + except Exception as e: + print(f"โ Error hydrating search bubbles: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + # --- Watchlist API Endpoints --- @app.route('/api/watchlist/count', methods=['GET']) diff --git a/webui/static/script.js b/webui/static/script.js index f46d13fa..e064209d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -81,6 +81,10 @@ let artistsPageState = { let artistDownloadBubbles = {}; // Track artist download bubbles: artistId -> { artist, downloads: [], element } let artistDownloadModalOpen = false; // Track if artist download modal is open let downloadsUpdateTimeout = null; // Debounce downloads section updates + +// --- Search Downloads Management State --- +let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] } +let searchDownloadModalOpen = false; // Track if search download modal is open let artistsSearchTimeout = null; let artistsSearchController = null; let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away @@ -2581,7 +2585,7 @@ function initializeSearchModeToggle() { name: track.name, meta: `${track.artist} โข ${track.album}`, duration: duration, - onClick: () => searchSlskdFor('track', track) + onClick: () => handleEnhancedSearchTrackClick(track) }; } ); @@ -2768,6 +2772,20 @@ function initializeSearchModeToggle() { false // Don't show loading overlay, we already have one ); + // Register this download in search bubbles + registerSearchDownload( + { + id: album.id, + name: albumData.name, + artist: album.artist, + image_url: albumData.images?.[0]?.url || null, + images: albumData.images || [] + }, + 'album', + virtualPlaylistId, + album.artist // artistName for grouping + ); + hideLoadingOverlay(); } catch (error) { @@ -2777,6 +2795,105 @@ function initializeSearchModeToggle() { } } + async function handleEnhancedSearchTrackClick(track) { + console.log(`๐ต Enhanced search track clicked: ${track.name} by ${track.artist}`); + + hideDropdown(); + showLoadingOverlay('Loading track...'); + + try { + // Create virtual playlist ID for enhanced search tracks + const virtualPlaylistId = `enhanced_search_track_${track.id}`; + + // Check if modal already exists and show it + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`๐ฑ Reopening existing modal for ${track.name}`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + hideLoadingOverlay(); + return; + } + } + + // Enrich track with album object (needed for wishlist functionality) + const enrichedTrack = { + id: track.id, + name: track.name, + artists: [track.artist], // Convert string to array for modal compatibility + album: { + name: track.album, + id: null, + album_type: 'single', + images: track.image_url ? [{ url: track.image_url }] : [], + release_date: null, + total_tracks: 1 + }, + duration_ms: track.duration_ms, + popularity: track.popularity || 0, + preview_url: track.preview_url || null, + external_urls: track.external_urls || null, + image_url: track.image_url + }; + + console.log(`๐ฆ Enriched track with album metadata`); + + // Format playlist name + const playlistName = `${track.artist} - ${track.name}`; + + // Create minimal artist object for the modal + const artistObject = { + id: null, + name: track.artist + }; + + // Prepare album object for modal (single track) + const albumObject = { + name: track.album, + id: null, + album_type: 'single', + images: track.image_url ? [{ url: track.image_url }] : [], + release_date: null, + total_tracks: 1, + artists: [{ name: track.artist }] + }; + + // Open download missing tracks modal with single track + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + playlistName, + [enrichedTrack], // Array with single track + albumObject, + artistObject, + false + ); + + // Register this download in search bubbles + registerSearchDownload( + { + id: track.id, + name: track.name, + artist: track.artist, + image_url: track.image_url, + images: track.image_url ? [{ url: track.image_url }] : [] + }, + 'track', + virtualPlaylistId, + track.artist // artistName for grouping + ); + + hideLoadingOverlay(); + + } catch (error) { + hideLoadingOverlay(); + console.error('โ Error handling enhanced search track click:', error); + showToast(`Error opening track: ${error.message}`, 'error'); + } + } + async function searchSlskdFor(type, item) { const mainResultsArea = document.getElementById('enhanced-main-results-area'); if (!mainResultsArea) return; @@ -3028,6 +3145,9 @@ async function loadInitialData() { // Load artist bubble state first await hydrateArtistBubblesFromSnapshot(); + // Load search bubble state + await hydrateSearchBubblesFromSnapshot(); + // Load discover download state await hydrateDiscoverDownloadsFromSnapshot(); @@ -3372,6 +3492,154 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b } } +async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, batchId) { + /** + * Rehydrates an enhanced search download modal from backend process data. + * Fetches item data from searchDownloadBubbles and recreates the modal. + */ + try { + console.log(`๐ง Rehydrating enhanced search modal: ${virtualPlaylistId} (${playlistName})`); + + // Find the download in searchDownloadBubbles + let downloadData = null; + for (const artistName in searchDownloadBubbles) { + const bubble = searchDownloadBubbles[artistName]; + const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (download) { + downloadData = download; + break; + } + } + + if (!downloadData) { + console.warn(`โ ๏ธ No download data found in searchDownloadBubbles for ${virtualPlaylistId}`); + return; + } + + const { item, type } = downloadData; + + if (type === 'album') { + // For albums, fetch tracks from Spotify API + console.log(`๐ง Album download - fetching album ${item.id}...`); + + try { + const response = await fetch(`/api/spotify/album/${item.id}`); + if (!response.ok) { + console.error(`โ Failed to fetch album: ${response.status}`); + return; + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + console.error(`โ No tracks in album`); + return; + } + + const spotifyTracks = albumData.tracks.map(track => ({ + id: track.id, + name: track.name, + artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], + album: { + name: item.name, + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: track.duration_ms || 0 + })); + + console.log(`โ Retrieved ${spotifyTracks.length} tracks for album`); + + // Create modal + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + item.name, + spotifyTracks, + item, + { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, + false // Don't show loading overlay + ); + + // Update process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + + 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 modal for background rehydration + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`๐ Hiding rehydrated modal for background processing: ${playlistName}`); + } + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + + console.log(`โ Rehydrated enhanced search album modal: ${playlistName}`); + } else { + console.error(`โ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + + } catch (error) { + console.error(`โ Error fetching album:`, error); + } + + } else { + // For tracks, create enriched track and open modal + console.log(`๐ง Track download - creating modal for ${item.name}...`); + + const enrichedTrack = { + id: item.id, + name: item.name, + artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], + album: item.album || { + name: item.album?.name || 'Unknown Album', + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: item.duration_ms || 0 + }; + + // Create modal + await openDownloadMissingModalForYouTube( + virtualPlaylistId, + `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, + [enrichedTrack] + ); + + // Update process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + + 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 modal for background rehydration + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`๐ Hiding rehydrated modal for background processing: ${playlistName}`); + } + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + + console.log(`โ Rehydrated enhanced search track modal: ${playlistName}`); + } else { + console.error(`โ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + } + + } catch (error) { + console.error(`โ Error rehydrating enhanced search modal:`, error); + } +} + async function rehydrateModal(processInfo, userRequested = false) { const { playlist_id, playlist_name, batch_id, current_cycle } = processInfo; console.log(`๐ง Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`); @@ -3402,6 +3670,13 @@ async function rehydrateModal(processInfo, userRequested = false) { return; } + // Handle enhanced search virtual playlists (albums and tracks) + if (playlist_id.startsWith('enhanced_search_album_') || playlist_id.startsWith('enhanced_search_track_')) { + console.log(`๐ง Rehydrating enhanced search virtual playlist: ${playlist_id}`); + await rehydrateEnhancedSearchModal(playlist_id, playlist_name, batch_id); + return; + } + // Handle wishlist processes specially if (playlist_id === "wishlist") { console.log(`๐ง [Rehydrate] Handling wishlist modal for active process: ${batch_id}`); @@ -5596,6 +5871,13 @@ async function closeDownloadMissingModal(playlistId) { console.log(`โ [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`); } + // Clean up search download if this is an enhanced search playlist + if (playlistId.startsWith('enhanced_search_')) { + console.log(`๐งน [MODAL CLOSE] Cleaning up search download for completed modal: ${playlistId}`); + cleanupSearchDownload(playlistId); + console.log(`โ [MODAL CLOSE] Search download cleanup completed for: ${playlistId}`); + } + // Remove from discover download sidebar if this is a discover page download if (discoverDownloads && discoverDownloads[playlistId]) { console.log(`๐งน [MODAL CLOSE] Removing discover download bubble: ${playlistId}`); @@ -20852,6 +21134,693 @@ async function hydrateArtistBubblesFromSnapshot() { } } +// --- Search Bubble Snapshot System --- + +async function saveSearchBubbleSnapshot() { + /** + * Saves current searchDownloadBubbles state to backend for persistence. + */ + try { + // Rate limit saves to avoid spamming backend + if (saveSearchBubbleSnapshot.lastSaveTime) { + const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime; + if (timeSinceLastSave < 2000) { + console.log('โฑ๏ธ Skipping search bubble snapshot save (rate limited)'); + return; + } + } + + const bubbleCount = Object.keys(searchDownloadBubbles).length; + + if (bubbleCount === 0) { + console.log('๐ธ Skipping snapshot save - no search bubbles to save'); + return; + } + + console.log(`๐ธ Saving search bubble snapshot: ${bubbleCount} artists`); + + // Convert search bubbles to plain objects for serialization + const bubblesToSave = {}; + for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) { + bubblesToSave[artistName] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + item: d.item, + type: d.type, + status: d.status, + startTime: d.startTime + })) + }; + } + + const response = await fetch('/api/search_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: bubblesToSave }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`โ Search bubble snapshot saved: ${bubbleCount} artists`); + saveSearchBubbleSnapshot.lastSaveTime = Date.now(); + } else { + console.error('โ Failed to save search bubble snapshot:', data.error); + } + + } catch (error) { + console.error('โ Error saving search bubble snapshot:', error); + } +} + +async function hydrateSearchBubblesFromSnapshot() { + /** + * Hydrates search download bubbles from backend snapshot with live status. + */ + try { + console.log('๐ Loading search bubble snapshot from backend...'); + + const response = await fetch('/api/search_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('โ Failed to load search bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + const stats = data.stats || {}; + + if (Object.keys(bubbles).length === 0) { + console.log('โน๏ธ No search bubbles to hydrate'); + return; + } + + // Clear and restore search bubbles + searchDownloadBubbles = {}; + + for (const [artistName, bubbleData] of Object.entries(bubbles)) { + searchDownloadBubbles[artistName] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads || [] + }; + + console.log(`๐ Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`); + + // Setup monitoring for each download + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + monitorSearchDownload(artistName, download.virtualPlaylistId); + } + } + } + + const totalArtists = Object.keys(searchDownloadBubbles).length; + console.log(`โ Successfully hydrated ${totalArtists} search download bubbles`); + + // Refresh display + showSearchDownloadBubbles(); + + } catch (error) { + console.error('โ Error hydrating search bubbles from snapshot:', error); + } +} + +/** + * Register a new search download for bubble management (grouped by artist) + */ +function registerSearchDownload(item, type, virtualPlaylistId, artistName) { + console.log(`๐ [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`); + + // Initialize artist bubble if it doesn't exist + if (!searchDownloadBubbles[artistName]) { + searchDownloadBubbles[artistName] = { + artist: { + name: artistName, + image_url: item.image_url || (item.images && item.images[0]?.url) || null + }, + downloads: [] + }; + } + + // Add this download to the artist's downloads + const downloadInfo = { + virtualPlaylistId: virtualPlaylistId, + item: item, + type: type, // 'album' or 'track' + status: 'in_progress', + startTime: new Date().toISOString() + }; + + searchDownloadBubbles[artistName].downloads.push(downloadInfo); + + console.log(`โ [REGISTER] Registered search download for ${artistName} - ${item.name}`); + + // Save snapshot + saveSearchBubbleSnapshot(); + + // Setup monitoring + monitorSearchDownload(artistName, virtualPlaylistId); + + // Refresh display + updateSearchDownloadsSection(); +} + +/** + * Debounced update for search downloads section + */ +function updateSearchDownloadsSection() { + if (window.searchUpdateTimeout) { + clearTimeout(window.searchUpdateTimeout); + } + window.searchUpdateTimeout = setTimeout(() => { + showSearchDownloadBubbles(); + }, 300); +} + +/** + * Monitor a search download for completion status changes + */ +function monitorSearchDownload(artistName, virtualPlaylistId) { + const checkCompletion = setInterval(() => { + const process = activeDownloadProcesses[virtualPlaylistId]; + + if (!process || !searchDownloadBubbles[artistName]) { + clearInterval(checkCompletion); + return; + } + + // Find the download in the artist's downloads + const download = searchDownloadBubbles[artistName].downloads.find( + d => d.virtualPlaylistId === virtualPlaylistId + ); + + if (!download) { + clearInterval(checkCompletion); + return; + } + + // Update status + const newStatus = process.status === 'complete' || process.status === 'view_results' + ? 'view_results' + : 'in_progress'; + + if (download.status !== newStatus) { + console.log(`๐ [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`); + download.status = newStatus; + + // Save snapshot and refresh + saveSearchBubbleSnapshot(); + updateSearchDownloadsSection(); + } + }, 2000); +} + +/** + * Show or update the search downloads bubble section + */ +function showSearchDownloadBubbles() { + console.log(`๐ [SHOW] showSearchDownloadBubbles() called`); + + const resultsArea = document.getElementById('enhanced-main-results-area'); + if (!resultsArea) { + console.log(`โญ๏ธ [SHOW] Skipping - no enhanced-main-results-area found`); + return; + } + + // Count active artists (those with downloads) + const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName => + searchDownloadBubbles[artistName].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + // Show placeholder + resultsArea.innerHTML = ` +
Search results will appear here when you select an album or track.
+