From d20609c33ba8c49d5182a8380496d480d9b57659 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 29 Dec 2025 14:33:59 -0800 Subject: [PATCH] Add search bubble snapshot and download tracking system Implements a persistent search bubble system for tracking album and track downloads in enhanced search. Adds backend API endpoints for saving and hydrating search bubble snapshots, frontend state management for search download bubbles, UI for displaying and managing active/completed downloads, and associated styles for search bubble cards. This enables users to resume and manage search downloads across page refreshes. --- web_server.py | 191 ++++++++ webui/static/script.js | 971 ++++++++++++++++++++++++++++++++++++++++- webui/static/style.css | 199 +++++++++ 3 files changed, 1360 insertions(+), 1 deletion(-) 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) */