From 12c37fa61c264b167391902ca093ec0626b86294 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 18 Nov 2025 18:58:37 -0800 Subject: [PATCH] download bubbles for discoverp age --- .claude/settings.local.json | 6 +- web_server.py | 173 ++++++++ webui/index.html | 12 + webui/static/script.js | 790 +++++++++++++++++++++++++++++++++++- webui/static/style.css | 219 ++++++++++ 5 files changed, 1193 insertions(+), 7 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 005ca5a..d5a09fd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,8 +7,10 @@ "Bash(grep:*)", "WebFetch(domain:python-plexapi.readthedocs.io)", "Bash(git restore:*)", - "Bash(python3:*)" + "Bash(python3:*)", + "Bash(awk:*)", + "Bash(cat:*)" ], "deny": [] } -} \ No newline at end of file +} diff --git a/web_server.py b/web_server.py index 426e38a..a1c80ee 100644 --- a/web_server.py +++ b/web_server.py @@ -13586,6 +13586,179 @@ def test_database_access(): "message": "Database access test failed" }), 500 +# --- Discover Download Snapshot System --- + +@app.route('/api/discover_downloads/snapshot', methods=['POST']) +def save_discover_download_snapshot(): + """ + Saves a snapshot of current discover download state for persistence across page refreshes. + """ + try: + import os + import json + from datetime import datetime + + data = request.json + if not data or 'downloads' not in data: + return jsonify({'success': False, 'error': 'No download data provided'}), 400 + + downloads = data['downloads'] + + # Create snapshot with timestamp + snapshot = { + 'downloads': downloads, + '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__), 'discover_download_snapshots.json') + with open(snapshot_file, 'w') as f: + json.dump(snapshot, f, indent=2) + + download_count = len(downloads) + print(f"๐Ÿ“ธ Saved discover download snapshot: {download_count} downloads") + + return jsonify({ + 'success': True, + 'message': f'Snapshot saved with {download_count} downloads', + 'timestamp': snapshot['timestamp'] + }) + + except Exception as e: + print(f"โŒ Error saving discover download snapshot: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@app.route('/api/discover_downloads/hydrate', methods=['GET']) +def hydrate_discover_downloads(): + """ + Loads discover downloads 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__), 'discover_download_snapshots.json') + + # Load snapshot if it exists + if not os.path.exists(snapshot_file): + return jsonify({ + 'success': True, + 'downloads': {}, + 'message': 'No snapshots found' + }) + + with open(snapshot_file, 'r') as f: + snapshot_data = json.load(f) + + saved_downloads = snapshot_data.get('downloads', {}) + 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 discover download snapshot from {snapshot_time}") + os.remove(snapshot_file) + return jsonify({ + 'success': True, + 'downloads': {}, + 'message': 'Old snapshot cleaned up' + }) + except (ValueError, OSError) as e: + print(f"โš ๏ธ Error checking discover 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 discover download 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 discover download snapshot") + try: + os.remove(snapshot_file) + return jsonify({ + 'success': True, + 'downloads': {}, + 'message': 'Snapshot cleaned up after app restart' + }) + except OSError as e: + print(f"โš ๏ธ Error removing discover snapshot file: {e}") + + return jsonify({ + 'success': True, + 'downloads': {}, + 'message': 'No active processes - returning empty downloads' + }) + + # Update download statuses with live data + hydrated_downloads = {} + for playlist_id, download_data in saved_downloads.items(): + # Determine current live status + if playlist_id in current_processes: + process_info = current_processes[playlist_id] + live_status = 'in_progress' + print(f"๐Ÿ”„ Found active process for discover download {playlist_id}: {process_info['phase']}") + else: + # No active process - likely completed + live_status = 'completed' + print(f"โœ… No active process for discover download {playlist_id} - marking as completed") + + # Create updated download entry + hydrated_downloads[playlist_id] = { + 'name': download_data.get('name'), + 'type': download_data.get('type'), + 'status': live_status, + 'virtualPlaylistId': playlist_id, + 'imageUrl': download_data.get('imageUrl'), + 'startTime': download_data.get('startTime', datetime.now().isoformat()) + } + + download_count = len(hydrated_downloads) + active_count = sum(1 for d in hydrated_downloads.values() if d['status'] == 'in_progress') + completed_count = sum(1 for d in hydrated_downloads.values() if d['status'] == 'completed') + + print(f"โœ… Hydrated {download_count} discover downloads: {active_count} active, {completed_count} completed") + + return jsonify({ + 'success': True, + 'downloads': hydrated_downloads, + 'stats': { + 'total_downloads': download_count, + 'active_downloads': active_count, + 'completed_downloads': completed_count + } + }) + + except Exception as e: + print(f"โŒ Error hydrating discover downloads: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + # --- Artist Bubble Snapshot System --- @app.route('/api/artist_bubbles/snapshot', methods=['POST']) diff --git a/webui/index.html b/webui/index.html index 66d7227..f2dedae 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2814,6 +2814,18 @@ + +
+
+ ๐ŸŽต + Downloads + 0 +
+
+ +
+
+ \ No newline at end of file diff --git a/webui/static/script.js b/webui/static/script.js index 0c1b579..492997d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -365,7 +365,7 @@ function initializeWatchlist() { function navigateToPage(pageId) { if (pageId === currentPage) return; - + // Update navigation buttons (only if there's a nav button for this page) document.querySelectorAll('.nav-button').forEach(btn => { btn.classList.remove('active'); @@ -374,15 +374,33 @@ function navigateToPage(pageId) { if (navButton) { navButton.classList.add('active'); } - + // Update pages document.querySelectorAll('.page').forEach(page => { page.classList.remove('active'); }); document.getElementById(`${pageId}-page`).classList.add('active'); - + currentPage = pageId; - + + // Show/hide discover download sidebar based on page + const downloadSidebar = document.getElementById('discover-download-sidebar'); + if (downloadSidebar) { + if (pageId === 'discover') { + // Show sidebar on discover page if there are active downloads + const activeDownloads = Object.keys(discoverDownloads || {}).length; + console.log(`๐Ÿ“Š [NAVIGATE] Discover page - ${activeDownloads} active downloads`); + if (activeDownloads > 0) { + // Update the sidebar UI to render the bubbles + console.log(`๐Ÿ”„ [NAVIGATE] Updating discover download bar UI`); + updateDiscoverDownloadBar(); + } + } else { + // Always hide sidebar on other pages + downloadSidebar.classList.add('hidden'); + } + } + // Load page-specific data loadPageData(pageId); } @@ -2190,7 +2208,10 @@ async function loadInitialData() { try { // Load artist bubble state first await hydrateArtistBubblesFromSnapshot(); - + + // Load discover download state + await hydrateDiscoverDownloadsFromSnapshot(); + // Load dashboard data by default await loadDashboardData(); } catch (error) { @@ -2359,12 +2380,95 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b try { console.log(`๐Ÿ’ง Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`); + // Handle album downloads from Recent Releases + if (virtualPlaylistId.startsWith('discover_album_')) { + const albumId = virtualPlaylistId.replace('discover_album_', ''); + console.log(`๐Ÿ’ง Album download - fetching album ${albumId}...`); + + try { + const albumResponse = await fetch(`/api/spotify/album/${albumId}`); + if (!albumResponse.ok) { + console.error(`โŒ Failed to fetch album: ${albumResponse.status}`); + return; + } + + const albumData = await albumResponse.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + console.error(`โŒ No tracks in album`); + return; + } + + // Convert tracks to expected format + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || []; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + name: albumData.name || playlistName.split(' - ')[0], + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0 + }; + }); + + console.log(`โœ… Retrieved ${spotifyTracks.length} tracks for album`); + + // Create modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + // 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}`); + } + + console.log(`โœ… Rehydrated album modal: ${playlistName}`); + } + return; + + } catch (error) { + console.error(`โŒ Error fetching album:`, error); + return; + } + } + // Determine API endpoint based on playlist ID let apiEndpoint; if (virtualPlaylistId === 'discover_release_radar') { apiEndpoint = '/api/discover/release-radar'; } else if (virtualPlaylistId === 'discover_discovery_weekly') { apiEndpoint = '/api/discover/discovery-weekly'; + } else if (virtualPlaylistId === 'discover_seasonal_playlist') { + apiEndpoint = '/api/discover/seasonal-playlist'; + } else if (virtualPlaylistId === 'discover_popular_picks') { + apiEndpoint = '/api/discover/popular-picks'; + } else if (virtualPlaylistId === 'discover_hidden_gems') { + apiEndpoint = '/api/discover/hidden-gems'; + } else if (virtualPlaylistId === 'discover_discovery_shuffle') { + apiEndpoint = '/api/discover/discovery-shuffle'; + } else if (virtualPlaylistId === 'discover_familiar_favorites') { + apiEndpoint = '/api/discover/familiar-favorites'; + } else if (virtualPlaylistId === 'build_playlist_custom') { + apiEndpoint = '/api/discover/build-playlist'; + } else if (virtualPlaylistId.startsWith('discover_lb_')) { + console.log(`๐Ÿ’ง ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`); + return; } else { console.error(`โŒ Unknown discover playlist type: ${virtualPlaylistId}`); return; @@ -4210,6 +4314,23 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : 'YouTube'; + // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) + if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_')) { + // Extract image URL from first track's album cover + let imageUrl = null; + if (spotifyTracks && spotifyTracks.length > 0) { + const firstTrack = spotifyTracks[0]; + if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { + imageUrl = firstTrack.album.images[0].url; + } + } + // Store in process for later use when Begin Analysis is clicked + activeDownloadProcesses[virtualPlaylistId].discoverMetadata = { + imageUrl: imageUrl, + type: 'album' + }; + } + const heroContext = { type: 'playlist', playlist: { name: playlistName, owner: source }, @@ -4494,6 +4615,13 @@ async function closeDownloadMissingModal(playlistId) { console.log(`โœ… [MODAL CLOSE] Artist 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}`); + removeDiscoverDownload(playlistId); + console.log(`โœ… [MODAL CLOSE] Discover download bubble removed for: ${playlistId}`); + } + // Automatic cleanup and server operations after successful downloads await handlePostDownloadAutomation(playlistId, process); @@ -4819,6 +4947,15 @@ async function startMissingTracksProcess(playlistId) { document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; + // Add to discover download sidebar if this is a discover page download + if (process.discoverMetadata) { + const playlistName = process.playlist.name; + const imageUrl = process.discoverMetadata.imageUrl; + const type = process.discoverMetadata.type; + addDiscoverDownload(playlistId, playlistName, type, imageUrl); + console.log(`๐Ÿ“ฅ [BEGIN ANALYSIS] Added discover download: ${playlistName}`); + } + // Check if force download toggle is enabled const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; @@ -26635,6 +26772,18 @@ async function startListenBrainzPlaylistSync(identifier, title, playlistId) { // Use the same sync function as all other discover playlists await startPlaylistSync(virtualPlaylistId); + // Extract image URL from first track for download bar bubble + let imageUrl = null; + if (spotifyTracks && spotifyTracks.length > 0) { + const firstTrack = spotifyTracks[0]; + if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { + imageUrl = firstTrack.album.images[0].url; + } + } + + // Add to discover download bar + addDiscoverDownload(virtualPlaylistId, title, 'listenbrainz', imageUrl); + // Start polling for progress updates (using discover playlist pattern) startListenBrainzSyncPolling(playlistId, virtualPlaylistId); @@ -27812,6 +27961,18 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) { // Start sync using existing function await startPlaylistSync(virtualPlaylistId); + // Extract image URL from first track for download bar bubble + let imageUrl = null; + if (spotifyTracks && spotifyTracks.length > 0) { + const firstTrack = spotifyTracks[0]; + if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { + imageUrl = firstTrack.album.images[0].url; + } + } + + // Add to discover download bar + addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl); + // Start polling for progress updates startDiscoverSyncPolling(playlistType, virtualPlaylistId); } @@ -27962,3 +28123,622 @@ async function openDownloadModalForRecentAlbum(albumIndex) { hideLoadingOverlay(); } } + +// =============================== +// DISCOVER DOWNLOAD BAR +// =============================== + +// Track discover page downloads +let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime } + +/** + * Add a download to the discover download bar + */ +function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) { + console.log(`๐Ÿ“ฅ [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`); + + // Check if download sidebar exists + const downloadSidebar = document.getElementById('discover-download-sidebar'); + if (!downloadSidebar) { + console.warn('โš ๏ธ [DOWNLOAD SIDEBAR] Download sidebar element not found - user might not be on discover page'); + return; + } + + discoverDownloads[playlistId] = { + name: playlistName, + type: playlistType, + status: 'in_progress', + virtualPlaylistId: playlistId, + imageUrl: imageUrl, + startTime: new Date() + }; + + console.log(`๐Ÿ“Š [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads)); + updateDiscoverDownloadBar(); + monitorDiscoverDownload(playlistId); +} + +/** + * Monitor a discover download for completion + */ +function monitorDiscoverDownload(playlistId) { + let notFoundCount = 0; + const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds) + + const checkInterval = setInterval(async () => { + try { + // Check if download still exists + if (!discoverDownloads[playlistId]) { + clearInterval(checkInterval); + return; + } + + // First check if there's an active download process (modal-based downloads) + const activeProcess = activeDownloadProcesses[playlistId]; + if (activeProcess) { + console.log(`๐Ÿ“‚ [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`); + + if (activeProcess.status === 'complete') { + console.log(`โœ… [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`); + discoverDownloads[playlistId].status = 'completed'; + updateDiscoverDownloadBar(); + clearInterval(checkInterval); + + // Auto-remove completed downloads after 30 seconds + setTimeout(() => { + if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { + removeDiscoverDownload(playlistId); + } + }, 30000); + } + return; // Continue monitoring + } + + // Check sync status API (for sync-based downloads) + const response = await fetch(`/api/sync/status/${playlistId}`); + if (response.ok) { + const data = await response.json(); + notFoundCount = 0; // Reset counter if found + + console.log(`๐Ÿ”„ [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`); + + if (data.status === 'complete') { + console.log(`โœ… [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`); + discoverDownloads[playlistId].status = 'completed'; + updateDiscoverDownloadBar(); + clearInterval(checkInterval); + + // Auto-remove completed downloads after 30 seconds + setTimeout(() => { + if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { + removeDiscoverDownload(playlistId); + } + }, 30000); + } + } else if (response.status === 404) { + notFoundCount++; + console.log(`๐Ÿ” [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`); + + // Only remove after multiple attempts (give it time to start) + if (notFoundCount >= maxNotFoundAttempts) { + console.log(`โน๏ธ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`); + clearInterval(checkInterval); + removeDiscoverDownload(playlistId); + } + } + } catch (error) { + console.error(`โŒ [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error); + } + }, 2000); // Check every 2 seconds +} + +/** + * Remove a download from the bar + */ +function removeDiscoverDownload(playlistId) { + console.log(`๐Ÿ—‘๏ธ Removing discover download: ${playlistId}`); + delete discoverDownloads[playlistId]; + updateDiscoverDownloadBar(); + saveDiscoverDownloadSnapshot(); // Save state after removal +} + +/** + * Update the discover download sidebar UI + */ +function updateDiscoverDownloadBar() { + const downloadSidebar = document.getElementById('discover-download-sidebar'); + const bubblesContainer = document.getElementById('discover-download-bubbles'); + const countElement = document.getElementById('discover-download-count'); + + console.log(`๐Ÿ”„ [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, { + downloadSidebar: !!downloadSidebar, + bubblesContainer: !!bubblesContainer, + countElement: !!countElement + }); + + if (!downloadSidebar || !bubblesContainer || !countElement) { + console.warn('โš ๏ธ [DOWNLOAD SIDEBAR] Missing elements, cannot update'); + return; + } + + const activeDownloads = Object.keys(discoverDownloads); + const count = activeDownloads.length; + + console.log(`๐Ÿ“Š [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`); + + // Update count + countElement.textContent = count; + + // Show/hide sidebar + if (count === 0) { + console.log(`๐Ÿ‘๏ธ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`); + downloadSidebar.classList.add('hidden'); + return; + } else { + console.log(`๐Ÿ‘๏ธ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`); + downloadSidebar.classList.remove('hidden'); + } + + // Update bubbles + bubblesContainer.innerHTML = activeDownloads.map(playlistId => { + const download = discoverDownloads[playlistId]; + const isCompleted = download.status === 'completed'; + const icon = isCompleted ? 'โœ…' : 'โณ'; + + // Use image if available, otherwise gradient background + const imageUrl = download.imageUrl || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${imageUrl}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
+ ${icon} +
+
+
${escapeHtml(download.name)}
+
+ `; + }).join(''); + + console.log(`๐Ÿ“Š Updated discover download sidebar: ${count} active downloads`); + + // Save snapshot after UI update + saveDiscoverDownloadSnapshot(); +} + +/** + * Open download modal for a discover playlist + */ +async function openDiscoverDownloadModal(playlistId) { + console.log(`๐Ÿ“‚ [DOWNLOAD BAR] Opening download modal for: ${playlistId}`); + + // Check if there's an active download process with modal + let process = activeDownloadProcesses[playlistId]; + + console.log(`๐Ÿ“‹ [DOWNLOAD BAR] Process found:`, { + exists: !!process, + hasModalElement: !!(process && process.modalElement), + hasModalId: !!(process && process.modalId) + }); + + if (process) { + // Try modalElement first (album downloads) + if (process.modalElement) { + console.log(`โœ… [DOWNLOAD BAR] Opening modal via modalElement`); + process.modalElement.style.display = 'flex'; + return; + } + + // Try modalId (sync downloads) + if (process.modalId) { + const modal = document.getElementById(process.modalId); + if (modal) { + console.log(`โœ… [DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`); + modal.style.display = 'flex'; + return; + } + } + } + + // If no process found, try to rehydrate from backend + console.log(`๐Ÿ’ง [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`); + const rehydrated = await rehydrateDiscoverDownloadModal(playlistId); + + if (rehydrated) { + console.log(`โœ… [DOWNLOAD BAR] Successfully rehydrated modal, opening it...`); + // Try again after rehydration + process = activeDownloadProcesses[playlistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + return; + } + } + + // Fallback: show toast + const download = discoverDownloads[playlistId]; + if (download) { + console.log(`โ„น๏ธ [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`); + showToast(`Download: ${download.name} - ${download.status}`, 'info'); + } else { + console.warn(`โš ๏ธ [DOWNLOAD BAR] No download or process found for: ${playlistId}`); + } +} + +/** + * Initialize discover download sidebar on page load + */ +function initializeDiscoverDownloadBar() { + console.log('๐ŸŽต Initializing discover download sidebar...'); + + // Start with sidebar hidden (will be shown if downloads exist after hydration) + const downloadSidebar = document.getElementById('discover-download-sidebar'); + if (downloadSidebar) { + downloadSidebar.classList.add('hidden'); + } +} + +// --- Discover Download Modal Rehydration --- + +async function rehydrateDiscoverDownloadModal(playlistId) { + /** + * Rehydrates a discover download modal from backend process data. + * Fetches tracks from backend API and recreates the modal (user-requested). + */ + try { + console.log(`๐Ÿ’ง [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`); + + // Check if there's an active backend process for this playlist + const batchResponse = await fetch(`/api/playlists/batch_info`); + if (!batchResponse.ok) { + console.log(`โš ๏ธ [REHYDRATE] Failed to fetch batch info`); + return false; + } + + const batchData = await batchResponse.json(); + const batches = batchData.batches || []; + + // Find the batch for this playlist + const batch = batches.find(b => b.playlist_id === playlistId); + + if (!batch) { + console.log(`โš ๏ธ [REHYDRATE] No active batch found for ${playlistId}`); + return false; + } + + console.log(`โœ… [REHYDRATE] Found active batch for ${playlistId}:`, batch); + + // Get the download metadata from discoverDownloads + const downloadData = discoverDownloads[playlistId]; + if (!downloadData) { + console.log(`โš ๏ธ [REHYDRATE] No download metadata found for ${playlistId}`); + return false; + } + + // Handle album downloads from Recent Releases + if (playlistId.startsWith('discover_album_')) { + const albumId = playlistId.replace('discover_album_', ''); + console.log(`๐Ÿ’ง [REHYDRATE] Album download - fetching album ${albumId}...`); + + try { + const albumResponse = await fetch(`/api/spotify/album/${albumId}`); + if (!albumResponse.ok) { + console.error(`โŒ [REHYDRATE] Failed to fetch album: ${albumResponse.status}`); + return false; + } + + const albumData = await albumResponse.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + console.error(`โŒ [REHYDRATE] No tracks in album`); + return false; + } + + // Convert tracks to expected format + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || []; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + name: albumData.name || downloadData.name.split(' - ')[0], + images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + }); + + console.log(`โœ… [REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`); + + // Create modal + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + + // Update process + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batch.batch_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + console.log(`โœ… [REHYDRATE] Successfully rehydrated album modal`); + return true; + } + return false; + + } catch (error) { + console.error(`โŒ [REHYDRATE] Error fetching album:`, error); + return false; + } + } + + // Determine API endpoint based on playlist ID + let apiEndpoint; + if (playlistId === 'discover_release_radar') { + apiEndpoint = '/api/discover/release-radar'; + } else if (playlistId === 'discover_discovery_weekly') { + apiEndpoint = '/api/discover/discovery-weekly'; + } else if (playlistId === 'discover_seasonal_playlist') { + apiEndpoint = '/api/discover/seasonal-playlist'; + } else if (playlistId === 'discover_popular_picks') { + apiEndpoint = '/api/discover/popular-picks'; + } else if (playlistId === 'discover_hidden_gems') { + apiEndpoint = '/api/discover/hidden-gems'; + } else if (playlistId === 'discover_discovery_shuffle') { + apiEndpoint = '/api/discover/discovery-shuffle'; + } else if (playlistId === 'discover_familiar_favorites') { + apiEndpoint = '/api/discover/familiar-favorites'; + } else if (playlistId === 'build_playlist_custom') { + apiEndpoint = '/api/discover/build-playlist'; + } else if (playlistId.startsWith('discover_lb_')) { + // ListenBrainz playlist - fetch from cache + const identifier = playlistId.replace('discover_lb_', ''); + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + console.log(`โš ๏ธ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`); + return false; + } + + // Convert to Spotify format + const spotifyTracks = tracks.map(track => ({ + id: null, + name: track.track_name, + artists: [track.artist_name], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0, + mbid: track.mbid + })); + + // Create modal and update process + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batch.batch_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + console.log(`โœ… [REHYDRATE] Successfully rehydrated ListenBrainz modal`); + return true; + } + return false; + } else { + console.error(`โŒ [REHYDRATE] Unknown discover playlist type: ${playlistId}`); + return false; + } + + // Fetch tracks from API + console.log(`๐Ÿ“ก [REHYDRATE] Fetching tracks from ${apiEndpoint}...`); + const response = await fetch(apiEndpoint); + if (!response.ok) { + console.error(`โŒ [REHYDRATE] Failed to fetch tracks: ${response.status}`); + return false; + } + + const data = await response.json(); + if (!data.success || !data.tracks) { + console.error(`โŒ [REHYDRATE] Invalid track data:`, data); + return false; + } + + const tracks = data.tracks; + console.log(`โœ… [REHYDRATE] Retrieved ${tracks.length} tracks`); + + // Transform tracks to Spotify format + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + return spotifyTrack; + }); + + // Create the modal + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + + // Update process with batch info + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batch.batch_id; + + // Update button states + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Don't hide the modal - user clicked to open it + console.log(`โœ… [REHYDRATE] Successfully rehydrated modal for ${downloadData.name}`); + return true; + } else { + console.error(`โŒ [REHYDRATE] Failed to find rehydrated process for ${playlistId}`); + return false; + } + + } catch (error) { + console.error(`โŒ [REHYDRATE] Error rehydrating discover download modal:`, error); + return false; + } +} + +// --- Discover Download Snapshot System --- + +let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves + +async function saveDiscoverDownloadSnapshot() { + /** + * Saves current discoverDownloads state to backend for persistence. + * Debounced to prevent excessive backend calls. + */ + + // Clear any existing timeout + if (discoverSnapshotSaveTimeout) { + clearTimeout(discoverSnapshotSaveTimeout); + } + + // Debounce the actual save + discoverSnapshotSaveTimeout = setTimeout(async () => { + try { + const downloadCount = Object.keys(discoverDownloads).length; + + // Don't save empty state + if (downloadCount === 0) { + console.log('๐Ÿ“ธ Skipping discover snapshot save - no downloads to save'); + return; + } + + console.log(`๐Ÿ“ธ Saving discover download snapshot: ${downloadCount} downloads`); + + // Prepare snapshot data (clean format) + const cleanDownloads = {}; + for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) { + cleanDownloads[playlistId] = { + name: downloadData.name, + type: downloadData.type, + status: downloadData.status, + virtualPlaylistId: downloadData.virtualPlaylistId, + imageUrl: downloadData.imageUrl, + startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime + }; + } + + const response = await fetch('/api/discover_downloads/snapshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + downloads: cleanDownloads + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`โœ… Discover download snapshot saved: ${downloadCount} downloads`); + } else { + console.error('โŒ Failed to save discover download snapshot:', data.error); + } + + } catch (error) { + console.error('โŒ Error saving discover download snapshot:', error); + } + }, 1000); // 1 second debounce +} + +async function hydrateDiscoverDownloadsFromSnapshot() { + /** + * Hydrates discover downloads from backend snapshot with live status. + * Called on page load to restore download state. + */ + try { + console.log('๐Ÿ”„ Loading discover download snapshot from backend...'); + + const response = await fetch('/api/discover_downloads/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('โŒ Failed to load discover download snapshot:', data.error); + return; + } + + const downloads = data.downloads || {}; + const stats = data.stats || {}; + + console.log(`๐Ÿ”„ Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); + + if (Object.keys(downloads).length === 0) { + console.log('โ„น๏ธ No discover downloads to hydrate'); + return; + } + + // Clear existing state + discoverDownloads = {}; + + // Restore discoverDownloads with hydrated data + for (const [playlistId, downloadData] of Object.entries(downloads)) { + discoverDownloads[playlistId] = { + name: downloadData.name, + type: downloadData.type, + status: downloadData.status, // Live status from backend + virtualPlaylistId: downloadData.virtualPlaylistId, + imageUrl: downloadData.imageUrl, + startTime: new Date(downloadData.startTime) + }; + + console.log(`๐Ÿ”„ Hydrated download: ${downloadData.name} (${downloadData.status})`); + + // Start monitoring for any in-progress downloads + if (downloadData.status === 'in_progress') { + console.log(`๐Ÿ“ก Starting monitoring for: ${downloadData.name}`); + monitorDiscoverDownload(playlistId); + } + } + + // Don't update UI here - it will be updated when user navigates to discover page + // This allows hydration to work even if page loads on a different tab + + const totalDownloads = Object.keys(discoverDownloads).length; + console.log(`โœ… Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`); + + } catch (error) { + console.error('โŒ Error hydrating discover downloads from snapshot:', error); + } +} + +// Initialize on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar); +} else { + initializeDiscoverDownloadBar(); +} diff --git a/webui/static/style.css b/webui/static/style.css index 12f24c8..e9fa725 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -17090,6 +17090,225 @@ body { margin: 0; } +/* =============================== + DISCOVER DOWNLOAD BAR + =============================== */ + +/* Fixed bottom download bar */ +/* =============================== + DISCOVER DOWNLOAD SIDEBAR + Right sidebar for active downloads + =============================== */ + +.discover-download-sidebar { + position: fixed; + top: 0; + right: 0; + width: 140px; + height: 100vh; + background: linear-gradient(270deg, + rgba(18, 18, 18, 0.98) 0%, + rgba(12, 12, 12, 0.99) 100%); + border-left: 1px solid rgba(255, 255, 255, 0.08); + padding: 20px 12px; + z-index: 9999; + transform: translateX(0); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(20px); + opacity: 1; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + overflow-x: hidden; +} + +/* Hidden state */ +.discover-download-sidebar.hidden { + transform: translateX(100%); + opacity: 0; + pointer-events: none; +} + +/* Sidebar header */ +.discover-download-sidebar-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.discover-download-sidebar-icon { + font-size: 20px; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.1); } +} + +.discover-download-sidebar-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; + text-align: center; +} + +.discover-download-sidebar-count { + background: linear-gradient(135deg, #1db954 0%, #1ed760 100%); + color: white; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 12px; + min-width: 20px; + text-align: center; +} + +/* Download bubbles container */ +.discover-download-bubbles { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; +} + +/* Individual download bubble - 100x100px circular */ +.discover-download-bubble { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 100%; +} + +.discover-download-bubble-card { + position: relative; + width: 100px; + height: 100px; + border-radius: 50%; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.95) 0%, + rgba(18, 18, 18, 0.98) 100%); + border: 2px solid rgba(29, 185, 84, 0.3); + 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); + overflow: hidden; +} + +.discover-download-bubble-card:hover { + transform: scale(1.08); + border-color: rgba(29, 185, 84, 0.5); + 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.15), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.discover-download-bubble-card.completed { + border-color: rgba(34, 197, 94, 0.4); +} + +.discover-download-bubble-card.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); +} + +/* Bubble card background image */ +.discover-download-bubble-image { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border-radius: 50%; +} + +/* Bubble card overlay */ +.discover-download-bubble-overlay { + position: absolute; + inset: 0; + background: linear-gradient(135deg, + rgba(0, 0, 0, 0.2) 0%, + rgba(0, 0, 0, 0.5) 100%); + border-radius: 50%; +} + +/* Bubble card content (icon/status) */ +.discover-download-bubble-content { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 2; + padding: 8px; +} + +.discover-download-bubble-icon { + font-size: 28px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8)); +} + +/* Bubble name (below the card) */ +.discover-download-bubble-name { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + text-align: center; + line-height: 1.2; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +/* Bubble status text (optional, below name) */ +.discover-download-bubble-status { + font-size: 8px; + color: rgba(255, 255, 255, 0.7); + text-align: center; + margin-top: 2px; +} + +/* Scrollbar styling */ +.discover-download-sidebar::-webkit-scrollbar { + width: 4px; +} + +.discover-download-sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.discover-download-sidebar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.discover-download-sidebar::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + /* =============================== BUILD A PLAYLIST STYLES =============================== */