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.

+
+ `; + return; + } + + // Create bubbles display + const bubblesHTML = activeArtists.map(artistName => + createSearchBubbleCard(searchDownloadBubbles[artistName]) + ).join(''); + + resultsArea.innerHTML = ` +
+
+

Active Downloads

+ ${activeArtists.length} +
+
+ ${bubblesHTML} +
+
+ `; + + console.log(`โœ… [SHOW] Displayed ${activeArtists.length} search bubbles`); +} + +/** + * Create HTML for a search bubble card (grouped by artist) + */ +function createSearchBubbleCard(artistBubbleData) { + const { artist, downloads } = artistBubbleData; + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + console.log(`๐Ÿ”ต [BUBBLE] Creating bubble for ${artist.name}:`, { + totalDownloads: downloads.length, + activeCount, + completedCount, + allCompleted + }); + + const imageUrl = artist.image_url || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${escapeHtml(imageUrl)}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ โœ… +
+ ` : ''} +
+ `; +} + +/** + * Open modal showing all downloads for an artist + */ +async function openSearchDownloadModal(artistName) { + const artistBubbleData = searchDownloadBubbles[artistName]; + if (!artistBubbleData || searchDownloadModalOpen) return; + + console.log(`๐ŸŽต [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`); + + searchDownloadModalOpen = true; + + const modal = document.createElement('div'); + modal.id = 'search-download-management-modal'; + modal.className = 'artist-download-management-modal'; + modal.innerHTML = ` +
+
+
+
+
+
+ ${artistBubbleData.artist.image_url + ? `${escapeHtml(artistBubbleData.artist.name)}` + : '
๐ŸŽต
' + } +
+
+

${escapeHtml(artistBubbleData.artist.name)}

+

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

+
+
+ × +
+
+ +
+
+ ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')} +
+
+
+
+ `; + + document.body.appendChild(modal); + modal.style.display = 'flex'; + + // Start monitoring for status changes + monitorSearchDownloadModal(artistName); +} + +/** + * Create HTML for a download item in the search modal + */ +function createSearchDownloadItem(download, index) { + const { item, type, status, virtualPlaylistId } = download; + const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; + const buttonClass = status === 'view_results' ? 'completed' : 'active'; + const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track'; + + return ` +
+
+ ${item.image_url + ? `${escapeHtml(item.name)}` + : `
+ ${type === 'album' ? '๐Ÿ’ฟ' : '๐ŸŽต'} +
` + } +
+
+
${escapeHtml(item.name)}
+
${typeLabel}
+
+
+ +
+
+ `; +} + +/** + * Reopen an individual download modal from the artist modal + */ +async function reopenDownloadModal(virtualPlaylistId) { + const process = activeDownloadProcesses[virtualPlaylistId]; + + // If process exists, show the existing modal + if (process && process.modalElement) { + console.log(`โœ… [REOPEN] Showing existing modal for ${virtualPlaylistId}`); + closeSearchDownloadModal(); + setTimeout(() => { + process.modalElement.style.display = 'flex'; + }, 100); + return; + } + + // Process doesn't exist (after page refresh) - recreate it + console.log(`๐Ÿ”„ [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`); + + // 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 for ${virtualPlaylistId}`); + return; + } + + // Close search modal first + closeSearchDownloadModal(); + + // Recreate the modal based on type + const { item, type } = downloadData; + + if (type === 'album') { + // For albums, we need to fetch the tracks + console.log(`๐Ÿ“ฅ [REOPEN] Recreating album modal for: ${item.name}`); + + // Fetch album tracks from Spotify API + showLoadingOverlay(`Loading ${item.name}...`); + + try { + const response = await fetch(`/api/spotify/album/${item.id}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + 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 + })); + + hideLoadingOverlay(); + + // Open the modal + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + item.name, + spotifyTracks, + item, + { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, + false // Don't show loading overlay again + ); + + // Sync with backend to check for active batch process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + try { + const processResponse = await fetch('/api/active-processes'); + if (processResponse.ok) { + const processData = await processResponse.json(); + const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); + + if (activeProcess) { + console.log(`๐Ÿ“ก [REOPEN] Found active batch for album: ${activeProcess.batch_id}`); + process.status = 'running'; + process.batchId = activeProcess.batch_id; + + // Update UI to show running state + 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'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + } + } + } catch (err) { + console.warn('Could not check for active processes:', err); + } + } + + } catch (error) { + hideLoadingOverlay(); + showToast(`Failed to load album: ${error.message}`, 'error'); + console.error('Error loading album:', error); + } + + } else { + // For tracks, create enriched track and open modal + console.log(`๐ŸŽต [REOPEN] Recreating track 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 + }; + + await openDownloadMissingModalForYouTube( + virtualPlaylistId, + `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, + [enrichedTrack] + ); + + // Sync with backend to check for active batch process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + try { + const processResponse = await fetch('/api/active-processes'); + if (processResponse.ok) { + const processData = await processResponse.json(); + const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); + + if (activeProcess) { + console.log(`๐Ÿ“ก [REOPEN] Found active batch for track: ${activeProcess.batch_id}`); + process.status = 'running'; + process.batchId = activeProcess.batch_id; + + // Update UI to show running state + 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'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + } + } + } catch (err) { + console.warn('Could not check for active processes:', err); + } + } + } +} + +/** + * Monitor search download modal for status changes + */ +function monitorSearchDownloadModal(artistName) { + const updateModal = () => { + if (!searchDownloadModalOpen) return; + + const modal = document.getElementById('search-download-management-modal'); + const itemsContainer = document.getElementById('search-download-items'); + + if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return; + + const downloads = searchDownloadBubbles[artistName].downloads; + + // If no downloads at all, close modal + if (downloads.length === 0) { + closeSearchDownloadModal(); + return; + } + + // Update modal content and sync status with active processes + let statusChanged = false; + itemsContainer.innerHTML = downloads.map((download, index) => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + + // Only update status if process exists (otherwise keep current status) + if (process) { + const newStatus = process.status === 'complete' || process.status === 'view_results' + ? 'view_results' + : 'in_progress'; + + if (download.status !== newStatus) { + console.log(`๐Ÿ”„ [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`); + download.status = newStatus; + statusChanged = true; + } + } + + return createSearchDownloadItem(download, index); + }).join(''); + + // If status changed, refresh bubble display and save + if (statusChanged) { + updateSearchDownloadsSection(); + saveSearchBubbleSnapshot(); + } + + // Continue monitoring + setTimeout(updateModal, 2000); + }; + + setTimeout(updateModal, 1000); +} + +/** + * Close the search download modal + */ +function closeSearchDownloadModal() { + const modal = document.getElementById('search-download-management-modal'); + if (modal) { + modal.style.display = 'none'; + if (modal.parentElement) { + modal.parentElement.removeChild(modal); + } + } + searchDownloadModalOpen = false; +} + +/** + * Bulk complete all downloads for an artist (called when user clicks green checkmark) + */ +function bulkCompleteSearchDownloads(artistName) { + console.log(`๐ŸŽฏ Bulk completing downloads for artist: ${artistName}`); + + const artistBubbleData = searchDownloadBubbles[artistName]; + if (!artistBubbleData) { + console.warn(`โŒ No artist bubble data found for ${artistName}`); + return; + } + + // Find all completed downloads + const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); + console.log(`๐Ÿ“‹ Found ${completedDownloads.length} completed downloads to close:`, + completedDownloads.map(d => d.item.name)); + + if (completedDownloads.length === 0) { + console.warn(`โš ๏ธ No completed downloads found for bulk close`); + showToast('No completed downloads to close', 'info'); + return; + } + + // Close all completed modals + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + console.log(`๐Ÿ—‘๏ธ Closing modal for: ${download.item.name}`); + closeDownloadMissingModal(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); +} + +/** + * Cleanup search download when modal is closed + */ +function cleanupSearchDownload(virtualPlaylistId) { + console.log(`๐Ÿ” [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`); + + // Find which artist this download belongs to + for (const artistName in searchDownloadBubbles) { + const downloads = searchDownloadBubbles[artistName].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + console.log(`๐Ÿงน [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`); + + // Remove this download + downloads.splice(downloadIndex, 1); + console.log(`๐Ÿ—‘๏ธ [CLEANUP] Removed download from ${artistName}'s bubble`); + + // If no more downloads for this artist, remove the bubble + if (downloads.length === 0) { + delete searchDownloadBubbles[artistName]; + console.log(`๐Ÿงน [CLEANUP] No more downloads - removed artist bubble: ${artistName}`); + } + + // Save snapshot and refresh + saveSearchBubbleSnapshot(); + updateSearchDownloadsSection(); + + return; + } + } + + console.log(`โš ๏ธ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`); +} + /** * Show or update the artist downloads section in search state */ diff --git a/webui/static/style.css b/webui/static/style.css index 063408e7..5ad9d4f1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -19568,6 +19568,205 @@ body { margin-left: 8px; } +/* ========================================= */ +/* SEARCH BUBBLE CARDS - Download Tracking */ +/* ========================================= */ + +.search-bubble-section { + padding: 32px 24px; +} + +.search-bubble-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.search-bubble-title { + font-size: 20px; + font-weight: 700; + color: #ffffff; + margin: 0; + letter-spacing: -0.4px; +} + +.search-bubble-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 12px; + background: rgba(29, 185, 84, 0.15); + border: 1px solid rgba(29, 185, 84, 0.3); + border-radius: 16px; + font-size: 14px; + font-weight: 600; + color: rgba(29, 185, 84, 1); +} + +.search-bubble-container { + display: flex; + flex-wrap: wrap; + gap: 24px; + justify-content: center; + align-items: center; + padding: 12px; +} + +.search-bubble-card { + position: relative; + width: 150px; + height: 150px; + border-radius: 50%; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.95) 0%, + rgba(18, 18, 18, 0.98) 100%); + border: 2px solid rgba(255, 255, 255, 0.1); + + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(29, 185, 84, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.search-bubble-card:hover { + transform: translateY(-3px) scale(1.05); + border-color: rgba(29, 185, 84, 0.3); + + box-shadow: + 0 8px 20px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(29, 185, 84, 0.2), + 0 0 15px rgba(29, 185, 84, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.search-bubble-card.all-completed { + border-color: rgba(34, 197, 94, 0.4); +} + +.search-bubble-card.all-completed:hover { + border-color: rgba(34, 197, 94, 0.6); + box-shadow: + 0 8px 20px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(34, 197, 94, 0.3), + 0 0 15px rgba(34, 197, 94, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.search-bubble-image { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border-radius: 50%; + overflow: hidden; +} + +.search-bubble-overlay { + position: absolute; + inset: 0; + background: linear-gradient(135deg, + rgba(0, 0, 0, 0.3) 0%, + rgba(0, 0, 0, 0.6) 100%); + border-radius: 50%; +} + +.search-bubble-content { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + text-align: center; + z-index: 2; +} + +.search-bubble-name { + font-size: 14px; + font-weight: 700; + color: #ffffff; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.3; +} + +.search-bubble-artist { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.search-bubble-status { + display: flex; + align-items: center; + gap: 4px; + margin-top: auto; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.4); + border-radius: 12px; + backdrop-filter: blur(4px); +} + +.search-bubble-status-icon { + font-size: 12px; +} + +.search-bubble-status-text { + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.search-bubble-type { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + background: rgba(29, 185, 84, 0.9); + border-radius: 8px; + font-size: 10px; + font-weight: 600; + color: #ffffff; + backdrop-filter: blur(4px); +} + +/* Responsive search bubbles */ +@media (max-width: 768px) { + .search-bubble-card { + width: 120px; + height: 120px; + } + + .search-bubble-name { + font-size: 12px; + } + + .search-bubble-artist { + font-size: 11px; + } +} + /* ========================================= */ /* ENHANCED SEARCH CATEGORIZED RESULTS */ /* (OLD - REMOVE IF NOT NEEDED) */