diff --git a/core/youtube_client.py b/core/youtube_client.py index c5282a0b..b927097b 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -513,7 +513,7 @@ class YouTubeClient: video_id = entry.get('id', '') filename = f"{video_id}||{title}" # Store video_id and title for later download - return TrackResult( + track_result = TrackResult( username="youtube", # YouTube doesn't have users - use constant filename=filename, size=file_size, @@ -528,6 +528,11 @@ class YouTubeClient: album=None, # YouTube videos don't have album info (will be added from Spotify) track_number=None ) + + # Add thumbnail for frontend (surgical addition) + track_result.thumbnail = entry.get('thumbnail') + + return track_result async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]: """ @@ -564,8 +569,8 @@ class YouTubeClient: } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - # Search YouTube (max 10 results) - search_results = ydl.extract_info(f"ytsearch10:{query}", download=False) + # Search YouTube (max 50 results) + search_results = ydl.extract_info(f"ytsearch50:{query}", download=False) if not search_results or 'entries' not in search_results: return [] diff --git a/webui/static/script.js b/webui/static/script.js index b4d322d0..0d4b7c7e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -38,7 +38,7 @@ let spotifyPlaylists = []; let selectedPlaylists = new Set(); let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array -let spotifyPlaylistsLoaded = false; +let spotifyPlaylistsLoaded = false; let activeDownloadProcesses = {}; let sequentialSyncManager = null; @@ -93,39 +93,39 @@ let similarArtistsController = null; // Track ongoing similar artists stream to // --- Wishlist Modal Persistence State Management --- const WishlistModalState = { // Track if wishlist modal was visible before page refresh - setVisible: function() { + setVisible: function () { localStorage.setItem('wishlist_modal_visible', 'true'); console.log('๐Ÿ“ฑ [Modal State] Wishlist modal marked as visible in localStorage'); }, - - setHidden: function() { + + setHidden: function () { localStorage.setItem('wishlist_modal_visible', 'false'); console.log('๐Ÿ“ฑ [Modal State] Wishlist modal marked as hidden in localStorage'); }, - - wasVisible: function() { + + wasVisible: function () { const visible = localStorage.getItem('wishlist_modal_visible') === 'true'; console.log(`๐Ÿ“ฑ [Modal State] Checking if wishlist modal was visible: ${visible}`); return visible; }, - - clear: function() { + + clear: function () { localStorage.removeItem('wishlist_modal_visible'); console.log('๐Ÿ“ฑ [Modal State] Cleared wishlist modal visibility state'); }, - + // Track if user manually closed the modal during auto-processing - setUserClosed: function() { + setUserClosed: function () { localStorage.setItem('wishlist_modal_user_closed', 'true'); console.log('๐Ÿ“ฑ [Modal State] User manually closed wishlist modal during auto-processing'); }, - - clearUserClosed: function() { + + clearUserClosed: function () { localStorage.removeItem('wishlist_modal_user_closed'); console.log('๐Ÿ“ฑ [Modal State] Cleared user closed state'); }, - - wasUserClosed: function() { + + wasUserClosed: function () { const closed = localStorage.getItem('wishlist_modal_user_closed') === 'true'; console.log(`๐Ÿ“ฑ [Modal State] Checking if user closed modal: ${closed}`); return closed; @@ -173,10 +173,10 @@ class SequentialSyncManager { try { // Use existing single sync function await startPlaylistSync(playlistId); - + // Wait for sync to complete by monitoring the poller await this.waitForSyncCompletion(playlistId); - + } catch (error) { console.error(`โŒ Sequential sync: Failed to sync playlist ${playlistId}:`, error); showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error'); @@ -207,15 +207,15 @@ class SequentialSyncManager { const duration = ((Date.now() - this.startTime) / 1000).toFixed(1); const completedCount = this.queue.length; console.log(`๐Ÿ Sequential sync completed in ${duration}s`); - + this.isRunning = false; this.queue = []; this.currentIndex = 0; this.startTime = null; - + // Re-enable playlist selection disablePlaylistSelection(false); - + this.updateUI(); updateRefreshButtonState(); // Refresh button state after completion showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success'); @@ -223,16 +223,16 @@ class SequentialSyncManager { cancel() { if (!this.isRunning) return; - + console.log('๐Ÿ›‘ Cancelling sequential sync'); this.isRunning = false; this.queue = []; this.currentIndex = 0; this.startTime = null; - + // Re-enable playlist selection disablePlaylistSelection(false); - + this.updateUI(); updateRefreshButtonState(); // Refresh button state after cancellation showToast('Sequential sync cancelled', 'info'); @@ -241,7 +241,7 @@ class SequentialSyncManager { updateUI() { const startSyncBtn = document.getElementById('start-sync-btn'); const selectionInfo = document.getElementById('selection-info'); - + if (!this.isRunning) { // Reset to normal state if (startSyncBtn) { @@ -250,8 +250,8 @@ class SequentialSyncManager { } if (selectionInfo) { const count = selectedPlaylists.size; - selectionInfo.textContent = count === 0 - ? 'Select playlists to sync' + selectionInfo.textContent = count === 0 + ? 'Select playlists to sync' : `${count} playlist${count > 1 ? 's' : ''} selected`; } } else { @@ -284,7 +284,7 @@ const API = { activity: '/api/activity', stream: { start: '/api/stream/start', - status: '/api/stream/status', + status: '/api/stream/status', toggle: '/api/stream/toggle', stop: '/api/stream/stop' } @@ -294,7 +294,7 @@ const API = { // INITIALIZATION // =============================== -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', function () { console.log('SoulSync WebUI initializing...'); // Initialize components @@ -318,19 +318,19 @@ document.addEventListener('DOMContentLoaded', function() { initializeBeatportDJSlider(); } - + // Start global service status polling for sidebar (works on all pages) fetchAndUpdateServiceStatus(); setInterval(fetchAndUpdateServiceStatus, 10000); // Every 10 seconds - + // Start always-on download polling (batched, minimal overhead) startGlobalDownloadPolling(); - + // Load initial data loadInitialData(); - + // Handle window resize to re-check track title scrolling - window.addEventListener('resize', function() { + window.addEventListener('resize', function () { if (currentTrack) { const trackTitleElement = document.getElementById('track-title'); const trackTitle = currentTrack.title || 'Unknown Track'; @@ -339,7 +339,7 @@ document.addEventListener('DOMContentLoaded', function() { }, 100); // Small delay to allow layout to settle } }); - + console.log('SoulSync WebUI initialized successfully!'); }); @@ -349,7 +349,7 @@ document.addEventListener('DOMContentLoaded', function() { function initializeNavigation() { const navButtons = document.querySelectorAll('.nav-button'); - + navButtons.forEach(button => { button.addEventListener('click', () => { const page = button.getAttribute('data-page'); @@ -538,7 +538,7 @@ function initializeMediaPlayer() { const playButton = document.getElementById('play-button'); const stopButton = document.getElementById('stop-button'); const volumeSlider = document.getElementById('volume-slider'); - + // Initialize HTML5 audio player audioPlayer = document.getElementById('audio-player'); if (audioPlayer) { @@ -548,20 +548,20 @@ function initializeMediaPlayer() { audioPlayer.addEventListener('error', onAudioError); audioPlayer.addEventListener('loadstart', onAudioLoadStart); audioPlayer.addEventListener('canplay', onAudioCanPlay); - + // Set initial volume audioPlayer.volume = 0.7; // 70% volumeSlider.value = 70; } - + // Track title click - toggle expansion trackTitle.addEventListener('click', toggleMediaPlayerExpansion); - + // Media controls playButton.addEventListener('click', handlePlayPause); stopButton.addEventListener('click', handleStop); volumeSlider.addEventListener('input', handleVolumeChange); - + // Progress bar controls const progressBar = document.getElementById('progress-bar'); if (progressBar) { @@ -574,20 +574,20 @@ function initializeMediaPlayer() { delete progressBar.dataset.seeking; }); } - + // Update volume slider styling volumeSlider.addEventListener('input', updateVolumeSliderAppearance); } function toggleMediaPlayerExpansion() { if (!currentTrack) return; - + const mediaPlayer = document.getElementById('media-player'); const expandedContent = document.getElementById('media-expanded'); const noTrackMessage = document.getElementById('no-track-message'); - + mediaPlayerExpanded = !mediaPlayerExpanded; - + if (mediaPlayerExpanded) { mediaPlayer.style.minHeight = '145px'; expandedContent.classList.remove('hidden'); @@ -600,13 +600,13 @@ function toggleMediaPlayerExpansion() { function extractTrackTitle(filename) { if (!filename) return null; - + // Remove file extension let title = filename.replace(/\.[^/.]+$/, ''); - + // Remove path components, keep only the filename title = title.split('/').pop().split('\\').pop(); - + // Clean up common filename patterns title = title .replace(/^\d+\.?\s*/, '') // Remove track numbers at start @@ -615,34 +615,34 @@ function extractTrackTitle(filename) { .replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info .replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end .trim(); - + return title || null; } function setTrackInfo(track) { currentTrack = track; - + const trackTitleElement = document.getElementById('track-title'); const trackTitle = track.title || 'Unknown Track'; - + // Set up the HTML structure for scrolling trackTitleElement.innerHTML = `${escapeHtml(trackTitle)}`; - + document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist'; document.getElementById('album-name').textContent = track.album || 'Unknown Album'; - + // Check if title needs scrolling (similar to GUI app) setTimeout(() => { checkAndEnableScrolling(trackTitleElement, trackTitle); }, 100); // Allow DOM to settle - + // Enable controls document.getElementById('play-button').disabled = false; document.getElementById('stop-button').disabled = false; - + // Hide no track message document.getElementById('no-track-message').classList.add('hidden'); - + // Auto-expand if collapsed if (!mediaPlayerExpanded) { toggleMediaPlayerExpansion(); @@ -653,18 +653,18 @@ function checkAndEnableScrolling(element, text) { // Remove any existing scrolling class and reset styles element.classList.remove('scrolling'); element.style.removeProperty('--scroll-distance'); - + // Force a layout to get accurate measurements element.offsetWidth; - + // Get the inner text element const titleTextElement = element.querySelector('.title-text'); if (!titleTextElement) return; - + // Check if text is wider than container const containerWidth = element.offsetWidth; const textWidth = titleTextElement.scrollWidth; - + // Enable scrolling if text is significantly wider than container if (textWidth > containerWidth + 15) { const scrollDistance = containerWidth - textWidth; @@ -683,26 +683,26 @@ function clearTrack() { mediaPlayerExpanded = false; const mediaPlayer = document.getElementById('media-player'); const expandedContent = document.getElementById('media-expanded'); - + if (mediaPlayer) mediaPlayer.style.minHeight = '85px'; if (expandedContent) expandedContent.classList.add('hidden'); } - + // Now clear track state currentTrack = null; isPlaying = false; - + const trackTitleElement = document.getElementById('track-title'); trackTitleElement.innerHTML = 'No track'; trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable - + document.getElementById('artist-name').textContent = 'Unknown Artist'; document.getElementById('album-name').textContent = 'Unknown Album'; document.getElementById('play-button').textContent = 'โ–ท'; document.getElementById('play-button').disabled = true; document.getElementById('stop-button').disabled = true; - + // Reset progress bar and time displays const progressBar = document.getElementById('progress-bar'); const progressFill = document.getElementById('progress-fill'); @@ -713,18 +713,18 @@ function clearTrack() { if (progressFill) { progressFill.style.width = '0%'; } - + const currentTimeElement = document.getElementById('current-time'); const totalTimeElement = document.getElementById('total-time'); if (currentTimeElement) currentTimeElement.textContent = '0:00'; if (totalTimeElement) totalTimeElement.textContent = '0:00'; - + // Hide loading animation hideLoadingAnimation(); - + // Show no track message document.getElementById('no-track-message').classList.remove('hidden'); - + console.log('๐Ÿงน Track cleared and media player reset'); } @@ -748,7 +748,7 @@ async function handleStop() { function handleVolumeChange(event) { const volume = event.target.value; updateVolumeSliderAppearance(); - + // Update HTML5 audio player volume if (audioPlayer) { audioPlayer.volume = volume / 100; @@ -758,21 +758,21 @@ function handleVolumeChange(event) { function handleProgressBarChange(event) { // Handle seeking in the audio track if (!audioPlayer || !audioPlayer.duration) return; - + const progress = parseFloat(event.target.value); const newTime = (progress / 100) * audioPlayer.duration; - + console.log(`๐ŸŽฏ Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`); - + try { audioPlayer.currentTime = newTime; - + // Update visual progress immediately const progressFill = document.getElementById('progress-fill'); if (progressFill) { progressFill.style.width = `${progress}%`; } - + // Update time displays immediately const currentTimeElement = document.getElementById('current-time'); if (currentTimeElement) { @@ -808,7 +808,7 @@ function setLoadingProgress(percentage) { const loadingAnimation = document.getElementById('loading-animation'); const progressBar = loadingAnimation.querySelector('.loading-progress'); const loadingText = loadingAnimation.querySelector('.loading-text'); - + loadingAnimation.classList.remove('hidden'); progressBar.style.width = `${percentage}%`; loadingText.textContent = `${Math.round(percentage)}%`; @@ -822,62 +822,62 @@ async function startStream(searchResult) { // Start streaming a track - handles same track toggle and new track streaming try { console.log(`๐ŸŽฎ startStream() called with data:`, searchResult); - + // Check if this is the same track that's currently playing/loading const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null; const newTrackId = `${searchResult.username}:${searchResult.filename}`; - + console.log(`๐ŸŽฎ startStream() called for: ${searchResult.filename}`); console.log(`๐ŸŽฎ Current track ID: ${currentTrackId}`); console.log(`๐ŸŽฎ New track ID: ${newTrackId}`); - + if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) { // Same track clicked while playing - toggle pause console.log("๐Ÿ”„ Toggling playback for same track"); togglePlayback(); return; } - + // Different track or no current track - start new stream console.log("๐ŸŽต Starting new stream"); - + // Stop current streaming/playback if any await stopStream(); - + // Set track info and show loading state setTrackInfo({ title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track', - artist: searchResult.artist || searchResult.username || 'Unknown Artist', + artist: searchResult.artist || searchResult.username || 'Unknown Artist', album: searchResult.album || 'Unknown Album', username: searchResult.username, filename: searchResult.filename }); - + showLoadingAnimation(); setLoadingProgress(0); - + // Start streaming request const response = await fetch(API.stream.start, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(searchResult) }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const data = await response.json(); - + if (!data.success) { throw new Error(data.error || 'Failed to start streaming'); } - + console.log("โœ… Stream started successfully"); - + // Start status polling startStreamStatusPolling(); - + } catch (error) { console.error('Error starting stream:', error); showToast(`Failed to start stream: ${error.message}`, 'error'); @@ -891,11 +891,11 @@ function startStreamStatusPolling() { if (streamStatusPoller) { clearInterval(streamStatusPoller); } - + // Reset polling state streamPollingRetries = 0; streamPollingInterval = 1000; // Reset to 1-second interval - + console.log('๐Ÿ”„ Starting enhanced stream status polling'); updateStreamStatus(); // Initial check streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); @@ -917,27 +917,27 @@ async function updateStreamStatus() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout - + const response = await fetch(API.stream.status, { signal: controller.signal }); - + clearTimeout(timeoutId); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const data = await response.json(); - + // Reset retry count on successful response streamPollingRetries = 0; streamPollingInterval = 1000; // Reset to normal interval - + // Update current stream state currentStream.status = data.status; currentStream.progress = data.progress; - + switch (data.status) { case 'loading': setLoadingProgress(data.progress); @@ -947,7 +947,7 @@ async function updateStreamStatus() { loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; } break; - + case 'queued': // Show queue status with better messaging const queueText = document.querySelector('.loading-text'); @@ -956,14 +956,14 @@ async function updateStreamStatus() { } setLoadingProgress(0); // Reset progress for queue state break; - + case 'ready': // Stream is ready - start audio playback console.log('๐ŸŽต Stream ready, starting audio playback'); stopStreamStatusPolling(); await startAudioPlayback(); break; - + case 'error': console.error('โŒ Streaming error:', data.error_message); stopStreamStatusPolling(); @@ -971,7 +971,7 @@ async function updateStreamStatus() { showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); clearTrack(); break; - + case 'stopped': // Handle stopped state console.log('๐Ÿ›‘ Stream stopped'); @@ -980,11 +980,11 @@ async function updateStreamStatus() { clearTrack(); break; } - + } catch (error) { streamPollingRetries++; console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message); - + if (streamPollingRetries >= maxStreamPollingRetries) { // Too many consecutive failures - give up console.error('โŒ Stream status polling failed after maximum retries'); @@ -996,7 +996,7 @@ async function updateStreamStatus() { // Implement exponential backoff for retries const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff streamPollingInterval = 1000 * backoffMultiplier; - + // Restart polling with new interval if (streamStatusPoller) { clearInterval(streamStatusPoller); @@ -1013,39 +1013,39 @@ async function startAudioPlayback() { if (!audioPlayer) { throw new Error('Audio player not initialized'); } - + // Show loading state while preparing audio const loadingText = document.querySelector('.loading-text'); if (loadingText) { loadingText.textContent = 'Preparing playback...'; } - + // Set audio source with cache-busting timestamp const audioUrl = `/stream/audio?t=${new Date().getTime()}`; console.log(`๐ŸŽต Loading audio from: ${audioUrl}`); - + // Clear any existing source first audioPlayer.pause(); audioPlayer.currentTime = 0; audioPlayer.src = ''; - + // Set new source audioPlayer.src = audioUrl; audioPlayer.load(); // Force reload - + // Wait for audio to be ready with promise-based approach await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Audio loading timeout')); }, 15000); // 15-second timeout - + const onCanPlay = () => { clearTimeout(timeout); audioPlayer.removeEventListener('canplay', onCanPlay); audioPlayer.removeEventListener('error', onError); resolve(); }; - + const onError = (event) => { clearTimeout(timeout); audioPlayer.removeEventListener('canplay', onCanPlay); @@ -1053,77 +1053,77 @@ async function startAudioPlayback() { const error = event.target.error || new Error('Audio loading failed'); reject(error); }; - + audioPlayer.addEventListener('canplay', onCanPlay); audioPlayer.addEventListener('error', onError); - + // If already ready, resolve immediately if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA onCanPlay(); } }); - + console.log('โœ… Audio loaded and ready for playback'); - + // Try to start playback with retry logic let retryCount = 0; const maxRetries = 3; - + while (retryCount < maxRetries) { try { await audioPlayer.play(); console.log('โœ… Audio playback started successfully'); - + // Update UI to playing state hideLoadingAnimation(); setPlayingState(true); - + // Show media player if hidden const noTrackMessage = document.getElementById('no-track-message'); if (noTrackMessage) { noTrackMessage.classList.add('hidden'); } - + // Ensure media player is expanded when playback starts if (!mediaPlayerExpanded) { toggleMediaPlayerExpansion(); } - + // Update volume to current slider value const volumeSlider = document.getElementById('volume-slider'); if (volumeSlider) { audioPlayer.volume = volumeSlider.value / 100; } - + // Enable play/stop buttons const playButton = document.getElementById('play-button'); const stopButton = document.getElementById('stop-button'); if (playButton) playButton.disabled = false; if (stopButton) stopButton.disabled = false; - + return; // Success! - + } catch (playError) { retryCount++; console.warn(`โš ๏ธ Audio play attempt ${retryCount} failed:`, playError.message); - + if (retryCount >= maxRetries) { throw playError; // Re-throw after max retries } - + // Wait before retry with exponential backoff await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); } } - + } catch (error) { console.error('โŒ Error starting audio playback:', error); hideLoadingAnimation(); - + // Provide user-friendly error messages let userMessage = 'Playback failed'; - - if (error.message.includes('no supported source') || + + if (error.message.includes('no supported source') || error.message.includes('Not supported') || error.message.includes('MEDIA_ELEMENT_ERROR')) { userMessage = 'Audio format not supported by your browser. Try downloading instead.'; @@ -1136,7 +1136,7 @@ async function startAudioPlayback() { } else if (error.message.includes('AbortError')) { userMessage = 'Playback was interrupted'; } - + showToast(userMessage, 'error'); clearTrack(); } @@ -1147,31 +1147,31 @@ async function stopStream() { try { // Stop status polling stopStreamStatusPolling(); - + // Stop audio playback if (audioPlayer) { audioPlayer.pause(); audioPlayer.src = ''; } - + // Call backend stop endpoint const response = await fetch(API.stream.stop, { method: 'POST' }); if (response.ok) { const data = await response.json(); console.log('๐Ÿ›‘ Stream stopped:', data.message); } - + // Reset UI state hideLoadingAnimation(); setPlayingState(false); - + // Reset stream state currentStream = { status: 'stopped', progress: 0, track: null }; - + } catch (error) { console.error('Error stopping stream:', error); } @@ -1183,7 +1183,7 @@ function togglePlayback() { console.log('โš ๏ธ No audio player or track to toggle'); return; } - + if (audioPlayer.paused) { audioPlayer.play() .then(() => { @@ -1208,9 +1208,9 @@ function togglePlayback() { function updateAudioProgress() { // Update progress bar based on audio playback time if (!audioPlayer || !audioPlayer.duration) return; - + const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100; - + // Update progress bar const progressBar = document.getElementById('progress-bar'); const progressFill = document.getElementById('progress-fill'); @@ -1221,11 +1221,11 @@ function updateAudioProgress() { progressFill.style.width = `${progress}%`; } } - + // Update time display const currentTimeElement = document.getElementById('current-time'); const totalTimeElement = document.getElementById('total-time'); - + if (currentTimeElement) { currentTimeElement.textContent = formatTime(audioPlayer.currentTime); } @@ -1238,7 +1238,7 @@ function onAudioEnded() { // Handle audio playback completion console.log('๐Ÿ Audio playback ended'); setPlayingState(false); - + // Reset progress to beginning const progressBar = document.getElementById('progress-bar'); const progressFill = document.getElementById('progress-fill'); @@ -1248,12 +1248,12 @@ function onAudioEnded() { if (progressFill) { progressFill.style.width = '0%'; } - + const currentTimeElement = document.getElementById('current-time'); if (currentTimeElement) { currentTimeElement.textContent = '0:00'; } - + // TODO: Auto-advance to next track if queue exists } @@ -1261,11 +1261,11 @@ function onAudioError(event) { // Handle audio playback errors const error = event.target.error; console.error('โŒ Audio error:', error); - + // Don't show error toast if it's just a format/codec issue and retrying if (error && error.code) { console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`); - + // Only show user-facing errors for serious issues if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported console.warn('โš ๏ธ Media format not supported by browser, but streaming may still work'); @@ -1273,14 +1273,14 @@ function onAudioError(event) { return; } } - + hideLoadingAnimation(); - + // Only clear track after a short delay to allow for recovery setTimeout(() => { if (audioPlayer && audioPlayer.error) { let userMessage = 'Audio format not supported by your browser. Try downloading instead.'; - + if (error && error.code) { switch (error.code) { case 1: // MEDIA_ERR_ABORTED @@ -1297,7 +1297,7 @@ function onAudioError(event) { break; } } - + showToast(userMessage, 'error'); clearTrack(); } @@ -1426,9 +1426,9 @@ function initializeDonationWidget() { function toggleDonationAddresses() { const addresses = document.getElementById('donation-addresses'); const toggleButton = document.getElementById('donation-toggle'); - + donationAddressesVisible = !donationAddressesVisible; - + if (donationAddressesVisible) { addresses.classList.remove('hidden'); toggleButton.textContent = 'Hide'; @@ -1617,32 +1617,32 @@ async function loadSettingsData() { try { const response = await fetch(API.settings); const settings = await response.json(); - + // Populate Spotify settings document.getElementById('spotify-client-id').value = settings.spotify?.client_id || ''; document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || ''; document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; - + // Populate Tidal settings document.getElementById('tidal-client-id').value = settings.tidal?.client_id || ''; document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || ''; document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; - + // Add event listeners to update display URLs when input changes - document.getElementById('spotify-redirect-uri').addEventListener('input', function() { + document.getElementById('spotify-redirect-uri').addEventListener('input', function () { document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback'; }); - - document.getElementById('tidal-redirect-uri').addEventListener('input', function() { + + document.getElementById('tidal-redirect-uri').addEventListener('input', function () { document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback'; }); - + // Populate Plex settings document.getElementById('plex-url').value = settings.plex?.base_url || ''; document.getElementById('plex-token').value = settings.plex?.token || ''; - + // Populate Jellyfin settings document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || ''; document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || ''; @@ -1689,7 +1689,7 @@ async function loadSettingsData() { // Populate Database settings document.getElementById('max-workers').value = settings.database?.max_workers || '5'; - + // Populate Metadata Enhancement settings document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false; document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false; @@ -1702,7 +1702,7 @@ async function loadSettingsData() { // Populate Playlist Sync settings document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false; - + // Populate Logging information (read-only) document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; @@ -1764,7 +1764,7 @@ function updateMediaServerFields() { const serverType = document.getElementById('media-server-type').value; const urlInput = document.getElementById('media-server-url'); const tokenInput = document.getElementById('media-server-token'); - + if (serverType === 'plex') { urlInput.placeholder = 'http://localhost:32400'; tokenInput.placeholder = 'Plex Token'; @@ -2097,7 +2097,7 @@ async function saveSettings() { create_backup: document.getElementById('create-backup').checked } }; - + try { showLoadingOverlay('Saving settings...'); @@ -2153,13 +2153,13 @@ async function saveSettings() { async function testConnection(service) { try { showLoadingOverlay(`Testing ${service} connection...`); - + const response = await fetch(API.testConnection, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service }) }); - + const result = await response.json(); if (result.success) { @@ -2186,15 +2186,15 @@ async function testConnection(service) { async function testDashboardConnection(service) { try { showLoadingOverlay(`Testing ${service} service...`); - + const response = await fetch(API.testDashboardConnection, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service }) }); - + const result = await response.json(); - + if (result.success) { showToast(`${service} service verified`, 'success'); } else { @@ -2212,22 +2212,22 @@ async function testDashboardConnection(service) { async function autoDetectPlex() { try { showLoadingOverlay('Auto-detecting Plex server...'); - + const response = await fetch('/api/detect-media-server', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'plex' }) }); - + const result = await response.json(); - + if (result.success) { document.getElementById('plex-url').value = result.found_url; showToast(`Plex server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } - + } catch (error) { console.error('Error auto-detecting Plex:', error); showToast('Failed to auto-detect Plex server', 'error'); @@ -2239,22 +2239,22 @@ async function autoDetectPlex() { async function autoDetectJellyfin() { try { showLoadingOverlay('Auto-detecting Jellyfin server...'); - + const response = await fetch('/api/detect-media-server', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'jellyfin' }) }); - + const result = await response.json(); - + if (result.success) { document.getElementById('jellyfin-url').value = result.found_url; showToast(`Jellyfin server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } - + } catch (error) { console.error('Error auto-detecting Jellyfin:', error); showToast('Failed to auto-detect Jellyfin server', 'error'); @@ -2293,21 +2293,21 @@ async function autoDetectNavidrome() { async function autoDetectSlskd() { try { showLoadingOverlay('Auto-detecting Soulseek (slskd) server...'); - + const response = await fetch('/api/detect-soulseek', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); - + const result = await response.json(); - + if (result.success) { document.getElementById('soulseek-url').value = result.found_url; showToast(`Soulseek server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } - + } catch (error) { console.error('Error auto-detecting Soulseek:', error); showToast('Failed to auto-detect Soulseek server', 'error'); @@ -2376,7 +2376,7 @@ function initializeSearch() { // --- FIX: Corrected the element IDs to match the HTML --- const searchInput = document.getElementById('downloads-search-input'); const searchButton = document.getElementById('downloads-search-btn'); - + // Add this line to get the cancel button const cancelButton = document.getElementById('downloads-cancel-btn'); @@ -2511,8 +2511,8 @@ function initializeSearchModeToggle() { if (dropdown.classList.contains('hidden')) { // Check if there are results to show by looking for actual content const hasResults = results && - !results.classList.contains('hidden') && - results.children.length > 0; + !results.classList.contains('hidden') && + results.children.length > 0; if (hasResults) { showDropdown(); @@ -2574,9 +2574,9 @@ function initializeSearchModeToggle() { // Calculate total const total = (data.db_artists?.length || 0) + - (data.spotify_artists?.length || 0) + - (data.spotify_albums?.length || 0) + - (data.spotify_tracks?.length || 0); + (data.spotify_artists?.length || 0) + + (data.spotify_albums?.length || 0) + + (data.spotify_tracks?.length || 0); // Hide loading loadingState.classList.add('hidden'); @@ -3141,7 +3141,7 @@ function initializeSearchModeToggle() { // Attach download handlers mainResultsArea.querySelectorAll('.download-result-btn').forEach(btn => { - btn.addEventListener('click', async function() { + btn.addEventListener('click', async function () { const result = JSON.parse(this.dataset.result); const type = this.dataset.type; @@ -3223,33 +3223,33 @@ async function performSearch() { showToast('Please enter a search term', 'error'); return; } - + try { showLoadingOverlay('Searching...'); displaySearchResults([]); // Clear previous results - + const response = await fetch(API.search, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) }); - + const data = await response.json(); - + if (data.error) { showToast(`Search error: ${data.error}`, 'error'); return; } - + searchResults = data.results || []; displaySearchResults(searchResults); - + if (searchResults.length === 0) { showToast('No results found', 'error'); } else { showToast(`Found ${searchResults.length} results`, 'success'); } - + } catch (error) { console.error('Error performing search:', error); showToast('Search failed', 'error'); @@ -3260,18 +3260,18 @@ async function performSearch() { function displaySearchResults(results) { const resultsContainer = document.getElementById('search-results'); - + if (!results.length) { resultsContainer.innerHTML = '
No search results
'; return; } - + resultsContainer.innerHTML = results.map((result, index) => { const isAlbum = result.type === 'album'; - const sizeText = isAlbum ? + const sizeText = isAlbum ? `${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` : `${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`; - + return `
@@ -3302,7 +3302,7 @@ function displaySearchResults(results) { function selectResult(index) { const result = searchResults[index]; if (!result) return; - + console.log('Selected result:', result); // Could show detailed view or additional actions here } @@ -3311,16 +3311,16 @@ function selectResult(index) { async function startDownload(index) { const result = searchResults[index]; if (!result) return; - + try { const response = await fetch('/api/downloads/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result) }); - + const data = await response.json(); - + if (data.success) { showToast('Download started', 'success'); } else { @@ -3358,7 +3358,7 @@ async function loadDashboardData() { try { const response = await fetch(API.activity); const data = await response.json(); - + const activityFeed = document.getElementById('activity-feed'); if (data.activities && data.activities.length) { activityFeed.innerHTML = data.activities.map(activity => ` @@ -3368,14 +3368,14 @@ async function loadDashboardData() {
`).join(''); } - + // Initialize wishlist count when dashboard loads await updateWishlistCount(); - + // Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior) stopWishlistCountPolling(); // Ensure no duplicates wishlistCountInterval = setInterval(updateWishlistCount, 30000); - + } catch (error) { console.error('Error loading dashboard data:', error); } @@ -3408,20 +3408,20 @@ async function checkForActiveProcesses() { if (processes.length > 0) { console.log(`๐Ÿ”„ Found ${processes.length} active process(es) from backend. Rehydrating UI...`); - + // Separate download batch processes from YouTube playlist processes const downloadProcesses = processes.filter(p => p.type === 'batch'); const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist'); - + console.log(`๐Ÿ“Š Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`); - + // Rehydrate download modal processes (existing Spotify system) for (const processInfo of downloadProcesses) { if (!activeDownloadProcesses[processInfo.playlist_id]) { rehydrateModal(processInfo); } } - + // Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist() // in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration. console.log(`โ„น๏ธ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`); @@ -3438,49 +3438,49 @@ async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchI */ try { console.log(`๐Ÿ’ง Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`); - + // Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id] const parts = virtualPlaylistId.split('_'); if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') { console.error(`โŒ Invalid virtual playlist ID format: ${virtualPlaylistId}`); return; } - + const artistId = parts[2]; const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores - + console.log(`๐Ÿ” Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`); - + // Fetch the album tracks to get proper artist and album data try { const response = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`); const data = await response.json(); - + if (!data.success || !data.album || !data.tracks) { console.error('โŒ Failed to fetch album data for rehydration:', data.error); return; } - + const album = data.album; const tracks = data.tracks; - + // Extract artist info from the first track (all tracks should have same artist) const artist = { id: artistId, name: tracks[0].artists[0] // Use first artist name from first track }; - + console.log(`โœ… Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`); - + // Create the modal using the same function as normal artist album downloads await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist); - + // Update the rehydrated process with batch info and hide modal for background rehydration const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = 'running'; process.batchId = batchId; - + // Update button states to reflect running status const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); @@ -3494,16 +3494,16 @@ async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchI process.modalElement.style.display = 'none'; console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${album.name}`); } - + console.log(`โœ… Rehydrated artist album modal: ${artist.name} - ${album.name}`); } else { console.error(`โŒ Failed to find rehydrated process for ${virtualPlaylistId}`); } - + } catch (error) { console.error(`โŒ Error fetching album data for rehydration:`, error); } - + } catch (error) { console.error(`โŒ Error rehydrating artist album modal:`, error); } @@ -3876,31 +3876,31 @@ async function rehydrateModal(processInfo, userRequested = false) { // Handle wishlist processes specially if (playlist_id === "wishlist") { console.log(`๐Ÿ’ง [Rehydrate] Handling wishlist modal for active process: ${batch_id}`); - + // Check if modal already exists and is visible const existingProcess = activeDownloadProcesses[playlist_id]; - const modalAlreadyOpen = existingProcess && existingProcess.modalElement && - existingProcess.modalElement.style.display === 'flex'; - + const modalAlreadyOpen = existingProcess && existingProcess.modalElement && + existingProcess.modalElement.style.display === 'flex'; + if (modalAlreadyOpen) { console.log(`๐Ÿ’ง [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`); - + // Update existing process with new batch info existingProcess.status = 'running'; existingProcess.batchId = batch_id; - + // Update UI to reflect running state const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; - + // Ensure polling is active for live updates if (!existingProcess.intervalId) { console.log(`๐Ÿ’ง [Rehydrate] Starting polling for existing modal`); startModalDownloadPolling(playlist_id); } - + console.log(`โœ… [Rehydrate] Successfully updated existing wishlist modal for auto-process`); } else { // Only create modal if user requested it - don't create for background auto-processing @@ -3978,43 +3978,43 @@ async function loadYouTubePlaylistsFromBackend() { // Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration) try { console.log('๐Ÿ“‹ Loading YouTube playlists from backend...'); - + const response = await fetch('/api/youtube/playlists'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch YouTube playlists'); } - + const data = await response.json(); const playlists = data.playlists || []; - + console.log(`๐ŸŽฌ Found ${playlists.length} stored YouTube playlists in backend`); - + if (playlists.length === 0) { console.log('๐Ÿ“‹ No YouTube playlists to hydrate'); return; } - + const container = document.getElementById('youtube-playlist-container'); - + // Create cards for playlists that don't already exist (avoid duplicates) for (const playlistInfo of playlists) { const urlHash = playlistInfo.url_hash; - + // Check if card already exists (from rehydration or previous loading) - if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement && + if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement && document.body.contains(youtubePlaylistStates[urlHash].cardElement)) { console.log(`โญ๏ธ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`); - + // Update existing state with backend data const state = youtubePlaylistStates[urlHash]; state.phase = playlistInfo.phase; state.discoveryProgress = playlistInfo.discovery_progress; state.spotifyMatches = playlistInfo.spotify_matches; state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; - + // Fetch discovery results for existing cards too if they don't have them - if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' && + if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' && (!state.discoveryResults || state.discoveryResults.length === 0)) { try { console.log(`๐Ÿ” Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`); @@ -4032,13 +4032,13 @@ async function loadYouTubePlaylistsFromBackend() { console.warn(`โš ๏ธ Error fetching discovery results for existing card:`, error.message); } } - + continue; } - + console.log(`๐ŸŽฌ Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`); createYouTubeCardFromBackendState(playlistInfo); - + // Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist) if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') { try { @@ -4047,7 +4047,7 @@ async function loadYouTubePlaylistsFromBackend() { if (stateResponse.ok) { const fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); - + // Store discovery results in local state const state = youtubePlaylistStates[urlHash]; if (fullState.discovery_results && state) { @@ -4064,14 +4064,14 @@ async function loadYouTubePlaylistsFromBackend() { } } } - + // Rehydrate download modals for YouTube playlists in downloading/download_complete phases for (const playlistInfo of playlists) { - if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') && + if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') && playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) { - + const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id; - + if (!activeDownloadProcesses[convertedPlaylistId]) { console.log(`๐Ÿ’ง Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`); try { @@ -4079,29 +4079,29 @@ async function loadYouTubePlaylistsFromBackend() { const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults ?.filter(result => result.spotify_data) ?.map(result => result.spotify_data) || []; - + if (spotifyTracks.length > 0) { await openDownloadMissingModalForYouTube( - convertedPlaylistId, - playlistInfo.playlist.name, + convertedPlaylistId, + playlistInfo.playlist.name, spotifyTracks ); - + // Set the modal to running state with the correct batch ID const process = activeDownloadProcesses[convertedPlaylistId]; if (process) { process.status = 'running'; process.batchId = playlistInfo.download_process_id; - + // Update UI to running state const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; - + // Start polling for this process startModalDownloadPolling(convertedPlaylistId); - + // Hide modal since this is background rehydration process.modalElement.style.display = 'none'; console.log(`โœ… Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`); @@ -4115,7 +4115,7 @@ async function loadYouTubePlaylistsFromBackend() { } } } - + console.log(`โœ… Successfully hydrated ${playlists.length} YouTube playlists from backend`); } catch (error) { @@ -4617,15 +4617,15 @@ function createYouTubeCardFromBackendState(playlistInfo) { const urlHash = playlistInfo.url_hash; const playlist = playlistInfo.playlist; const phase = playlistInfo.phase; - + const container = document.getElementById('youtube-playlist-container'); - + // Remove placeholder if it exists const placeholder = container.querySelector('.youtube-playlist-placeholder'); if (placeholder) { placeholder.remove(); } - + // Create card HTML (using EXACT same structure as createYouTubeCard) const cardHtml = `
@@ -4643,9 +4643,9 @@ function createYouTubeCardFromBackendState(playlistInfo) {
`; - + container.insertAdjacentHTML('beforeend', cardHtml); - + // Store state for UI management (but backend remains source of truth) youtubePlaylistStates[urlHash] = { phase: phase, @@ -4658,7 +4658,7 @@ function createYouTubeCardFromBackendState(playlistInfo) { convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id, backendSynced: true // Flag to indicate this came from backend }; - + console.log(`๐Ÿƒ Created YouTube card from backend state: ${playlist.name} (${phase})`); } @@ -4708,14 +4708,14 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { const urlHash = playlistInfo.url_hash; const playlistName = playlistInfo.playlist_name; const phase = playlistInfo.phase; - + console.log(`๐Ÿ’ง Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`); - + try { // First, ensure the card exists (create from backend if needed) if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) { console.log(`๐Ÿƒ Creating missing YouTube card for rehydration: ${playlistName}`); - + // Since playlistInfo from active processes doesn't have full playlist data, // we need to fetch it from the backend first try { @@ -4732,7 +4732,7 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { return; } } - + // Fetch full state from backend to get discovery results let fullState = null; if (phase !== 'fresh' && phase !== 'discovering') { @@ -4754,14 +4754,14 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { state.discoveryProgress = playlistInfo.discovery_progress; state.spotifyMatches = playlistInfo.spotify_matches; state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; - + // Restore discovery results if we have them if (fullState && fullState.discovery_results) { state.discoveryResults = fullState.discovery_results; state.syncPlaylistId = fullState.sync_playlist_id; state.syncProgress = fullState.sync_progress || {}; console.log(`โœ… Restored ${state.discoveryResults.length} discovery results from backend`); - + // Update modal if it already exists const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (existingModal && !existingModal.classList.contains('hidden')) { @@ -4769,11 +4769,11 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { refreshYouTubeDiscoveryModalTable(urlHash); } } - + // Update card display updateYouTubeCardPhase(urlHash, phase); updateYouTubeCardProgress(urlHash, playlistInfo); - + // Handle active polling resumption if (phase === 'discovering') { console.log(`๐Ÿ” Resuming discovery polling for: ${playlistName}`); @@ -4782,7 +4782,7 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { console.log(`๐Ÿ”„ Resuming sync polling for: ${playlistName}`); startYouTubeSyncPolling(urlHash); } - + // Open modal if user requested if (userRequested) { switch (phase) { @@ -4801,9 +4801,9 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { break; } } - + console.log(`โœ… Successfully rehydrated YouTube playlist: ${playlistName}`); - + } catch (error) { console.error(`โŒ Error rehydrating YouTube playlist "${playlistName}":`, error); } @@ -4812,54 +4812,54 @@ async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { async function removeYouTubePlaylistFromBackend(event, urlHash) { // Remove YouTube playlist from backend storage and update UI event.stopPropagation(); // Prevent card click - + const state = youtubePlaylistStates[urlHash]; if (!state) return; - + const playlistName = state.playlist.name; - + try { console.log(`๐Ÿ—‘๏ธ Removing YouTube playlist from backend: ${playlistName}`); - + const response = await fetch(`/api/youtube/delete/${urlHash}`, { method: 'DELETE' }); - + if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete playlist'); } - + // Remove card from UI if (state.cardElement) { state.cardElement.remove(); } - + // Remove from client state delete youtubePlaylistStates[urlHash]; - + // Stop any active polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - + // Close discovery modal if open const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (modal) { modal.remove(); } - + // Show placeholder if no cards left const container = document.getElementById('youtube-playlist-container'); const cards = container.querySelectorAll('.youtube-playlist-card'); if (cards.length === 0) { container.innerHTML = '
No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!
'; } - + showToast(`Removed "${playlistName}" from backend storage`, 'success'); console.log(`โœ… Successfully removed YouTube playlist: ${playlistName}`); - + } catch (error) { console.error(`โŒ Error removing YouTube playlist "${playlistName}":`, error); showToast(`Error removing playlist: ${error.message}`, 'error'); @@ -4869,7 +4869,7 @@ async function removeYouTubePlaylistFromBackend(event, urlHash) { async function loadSpotifyPlaylists() { const container = document.getElementById('spotify-playlist-container'); const refreshBtn = document.getElementById('spotify-refresh-btn'); - + container.innerHTML = `
๐Ÿ”„ Loading playlists...
`; refreshBtn.disabled = true; refreshBtn.textContent = '๐Ÿ”„ Loading...'; @@ -4957,22 +4957,22 @@ function updatePlaylistCardUI(playlistId) { progressBtn.style.backgroundColor = ''; // Reset any custom styling actionBtn.textContent = '๐Ÿ“ฅ Downloading...'; actionBtn.disabled = true; - + // Remove completion styling from card if (card) card.classList.remove('download-complete'); - + } else if (process && process.status === 'complete') { // Process completed: show "ready for review" indicator progressBtn.classList.remove('hidden'); - progressBtn.textContent = '๐Ÿ“‹ View Results'; + progressBtn.textContent = '๐Ÿ“‹ View Results'; progressBtn.style.backgroundColor = '#28a745'; // Green success color progressBtn.style.color = 'white'; actionBtn.textContent = 'โœ… Ready for Review'; actionBtn.disabled = false; // Allow clicking to see results - + // Add completion styling to card if (card) card.classList.add('download-complete'); - + } else { // No process or it's been cleaned up: normal state progressBtn.classList.add('hidden'); @@ -4980,7 +4980,7 @@ function updatePlaylistCardUI(playlistId) { progressBtn.style.color = ''; // Reset styling actionBtn.textContent = 'Sync / Download'; actionBtn.disabled = false; - + // Remove completion styling from card if (card) card.classList.remove('download-complete'); } @@ -4998,7 +4998,7 @@ async function cleanupDownloadProcess(playlistId) { clearInterval(process.poller); process.poller = null; } - + // Mark process as no longer running if (process.status === 'running') { process.status = 'complete'; @@ -5044,7 +5044,7 @@ function togglePlaylistSelection(event) { // Don't toggle if clicking the button if (event.target.tagName === 'BUTTON') return; - + const isSelected = !card.classList.contains('selected'); card.classList.toggle('selected', isSelected); @@ -5122,7 +5122,7 @@ function showPlaylistDetailsModal(playlist) { modal.className = 'modal-overlay'; document.body.appendChild(modal); } - + // Check if there's a completed download missing tracks process for this playlist const activeProcess = activeDownloadProcesses[playlist.id]; const hasCompletedProcess = activeProcess && activeProcess.status === 'complete'; @@ -5174,9 +5174,9 @@ function showPlaylistDetailsModal(playlist) { @@ -5338,14 +5338,14 @@ async function openDownloadMissingModal(playlistId) { } console.log(`๐Ÿ“ฅ Opening Download Missing Tracks modal for playlist: ${playlistId}`); - + closePlaylistDetailsModal(); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) { showToast('Could not find playlist data.', 'error'); return; } - + let tracks = playlistTrackCache[playlistId]; if (!tracks) { try { @@ -5359,10 +5359,10 @@ async function openDownloadMissingModal(playlistId) { return; } } - + currentPlaylistTracks = tracks; currentModalPlaylistId = playlistId; - + let modal = document.createElement('div'); modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID modal.className = 'download-missing-modal'; // **NEW**: Use class for styling @@ -5378,7 +5378,7 @@ async function openDownloadMissingModal(playlistId) { playlist: playlist, tracks: tracks }; - + // Generate hero section for playlist context const heroContext = { type: 'playlist', @@ -5715,14 +5715,14 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam // Generate hero section with dynamic source detection const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : - virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : - virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : + virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + 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_') || virtualPlaylistId.startsWith('listenbrainz_')) { @@ -5883,7 +5883,7 @@ async function closeDownloadMissingModal(playlistId) { if (process.status === 'running') { console.log(`Hiding active download modal for playlist ${playlistId}.`); process.modalElement.style.display = 'none'; - + // Track wishlist modal state changes if (playlistId === 'wishlist') { WishlistModalState.setUserClosed(); // User manually closed during processing @@ -5891,12 +5891,12 @@ async function closeDownloadMissingModal(playlistId) { } } else { console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`); - + // Reset YouTube playlist phase to 'discovered' when modal is closed after completion if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'discovered'); - + // Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix) try { const response = await fetch(`/api/youtube/update_phase/${urlHash}`, { @@ -5908,7 +5908,7 @@ async function closeDownloadMissingModal(playlistId) { phase: 'discovered' }) }); - + if (response.ok) { console.log(`โœ… [Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`); } else { @@ -5918,7 +5918,7 @@ async function closeDownloadMissingModal(playlistId) { console.error(`โŒ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error); } } - + // Reset Beatport chart phase to 'discovered' when modal is closed if (playlistId.startsWith('beatport_')) { const urlHash = playlistId.replace('beatport_', ''); @@ -5957,15 +5957,15 @@ async function closeDownloadMissingModal(playlistId) { // Enhanced Tidal playlist state management (based on GUI sync.py patterns) if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); - + console.log(`๐Ÿงน [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`); console.log(`๐Ÿงน [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]); - + // Clear download-specific state but preserve discovery results (like GUI closeEvent) if (tidalPlaylistStates[tidalPlaylistId]) { const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase; console.log(`๐Ÿงน [Modal Close] Current phase before reset: ${currentPhase}`); - + // Preserve discovery data for future use (like GUI modal behavior) const preservedData = { playlist: tidalPlaylistStates[tidalPlaylistId].playlist, @@ -5974,25 +5974,25 @@ async function closeDownloadMissingModal(playlistId) { discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId }; - + // Clear download-specific state delete tidalPlaylistStates[tidalPlaylistId].download_process_id; delete tidalPlaylistStates[tidalPlaylistId].phase; - + // Restore preserved data and set to discovered phase Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; - + console.log(`๐Ÿงน [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`); console.log(`๐Ÿงน [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`); } else { console.error(`โŒ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`); } - + updateTidalCardPhase(tidalPlaylistId, 'discovered'); console.log(`๐Ÿ”„ [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`); console.log(`๐Ÿ“ [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`); - + // Update backend state to prevent rehydration issues on page refresh try { const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, { @@ -6004,7 +6004,7 @@ async function closeDownloadMissingModal(playlistId) { phase: 'discovered' }) }); - + if (response.ok) { console.log(`โœ… [Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`); } else { @@ -6059,7 +6059,7 @@ async function closeDownloadMissingModal(playlistId) { WishlistModalState.clear(); // Clear all tracking since modal is fully closed console.log('๐Ÿ“ฑ [Modal State] Cleared wishlist modal state on full close'); } - + // Clean up artist download if this is an artist album playlist if (playlistId.startsWith('artist_album_')) { console.log(`๐Ÿงน [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`); @@ -6929,16 +6929,16 @@ async function openDownloadMissingWishlistModal(category = null) { } const tracksData = await tracksResponse.json(); tracks = tracksData.tracks || []; - + } catch (error) { showToast(`Failed to fetch wishlist data: ${error.message}`, 'error'); hideLoadingOverlay(); return; } - + currentPlaylistTracks = tracks; currentModalPlaylistId = playlistId; - + let modal = document.createElement('div'); modal.id = `download-missing-modal-${playlistId}`; // Unique ID modal.className = 'download-missing-modal'; // Use class for styling @@ -7122,14 +7122,14 @@ async function startWishlistMissingTracksProcess(playlistId) { process.batchId = data.batch_id; console.log(`โœ… Wishlist process started successfully. Batch ID: ${data.batch_id}`); - + // Start polling for updates startModalDownloadPolling(playlistId); - + } catch (error) { console.error('Error starting wishlist missing tracks process:', error); showToast(`Error: ${error.message}`, 'error'); - + // Reset UI state on error process.status = 'idle'; // Note: Wishlist processes don't affect sync page refresh button state @@ -7172,7 +7172,7 @@ async function startMissingTracksProcess(playlistId) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'downloading'); } - + // Update Tidal playlist phase to 'downloading' if this is a Tidal playlist if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); @@ -7410,29 +7410,29 @@ function startGlobalDownloadPolling() { console.debug('๐Ÿ”„ [Global Polling] Already running, skipping start'); return; // Prevent duplicate pollers } - + console.log('๐Ÿ”„ [Global Polling] Starting batched download status polling'); - + globalDownloadStatusPoller = setInterval(async () => { // Get all active processes that need polling const activeBatchIds = []; const batchToPlaylistMap = {}; let hasOpenWishlistModal = false; - + Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { if (process.batchId && process.status === 'running') { activeBatchIds.push(process.batchId); batchToPlaylistMap[process.batchId] = playlistId; } - + // Check if there's an open wishlist modal (visible and idle/waiting) - if (playlistId === 'wishlist' && process.modalElement && + if (playlistId === 'wishlist' && process.modalElement && process.modalElement.style.display === 'flex' && (!process.batchId || process.status !== 'running')) { hasOpenWishlistModal = true; } }); - + // Special handling for open wishlist modal - check for new auto-processing if (hasOpenWishlistModal) { try { @@ -7441,7 +7441,7 @@ function startGlobalDownloadPolling() { const data = await response.json(); const processes = data.active_processes || []; const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); - + if (serverWishlistProcess) { console.log('๐Ÿ”„ [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); await rehydrateModal(serverWishlistProcess, false); // false = not user-requested @@ -7451,24 +7451,24 @@ function startGlobalDownloadPolling() { console.debug('โš ๏ธ [Global Polling] Failed to check for wishlist auto-processing:', error); } } - + if (activeBatchIds.length === 0) { console.debug('๐Ÿ“Š [Global Polling] No active processes, continuing polling'); return; } - + try { // Single batched API call for all active processes const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); const response = await fetch(`/api/download_status/batch?${queryParams}`); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const data = await response.json(); console.debug(`๐Ÿ“Š [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); - + // Process each batch's status data using existing logic Object.entries(data.batches).forEach(([batchId, statusData]) => { const playlistId = batchToPlaylistMap[batchId]; @@ -7478,34 +7478,34 @@ function startGlobalDownloadPolling() { } return; } - + // Use existing modal update logic - zero changes needed! processModalStatusUpdate(playlistId, statusData); }); - + // ENHANCED: Reset failure count on successful polling globalPollingFailureCount = 0; - + } catch (error) { console.error('โŒ [Global Polling] Batched request failed:', error); - + // ENHANCED: Implement exponential backoff on failure globalPollingFailureCount++; - + if (globalPollingFailureCount >= 5) { console.error(`๐Ÿšจ [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`); // Don't stop polling - just continue with exponential backoff } - + // Exponential backoff: increase interval temporarily const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000); console.warn(`โš ๏ธ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`); - + // Temporarily adjust the polling interval if (globalDownloadStatusPoller) { clearInterval(globalDownloadStatusPoller); globalDownloadStatusPoller = null; - + // Restart with backoff interval setTimeout(() => { if (Object.keys(activeDownloadProcesses).length > 0) { @@ -7522,29 +7522,29 @@ function startGlobalDownloadPollingWithInterval(interval) { console.debug('๐Ÿ”„ [Global Polling] Already running, skipping start with interval'); return; } - + console.log(`๐Ÿ”„ [Global Polling] Starting with interval ${interval}ms`); - + // Use the exact same logic as startGlobalDownloadPolling but with custom interval globalDownloadStatusPoller = setInterval(async () => { const activeBatchIds = []; const batchToPlaylistMap = {}; let hasOpenWishlistModal = false; - + Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { if (process.batchId && process.status === 'running') { activeBatchIds.push(process.batchId); batchToPlaylistMap[process.batchId] = playlistId; } - + // Check if there's an open wishlist modal (visible and idle/waiting) - if (playlistId === 'wishlist' && process.modalElement && + if (playlistId === 'wishlist' && process.modalElement && process.modalElement.style.display === 'flex' && (!process.batchId || process.status !== 'running')) { hasOpenWishlistModal = true; } }); - + // Special handling for open wishlist modal - check for new auto-processing if (hasOpenWishlistModal) { try { @@ -7553,7 +7553,7 @@ function startGlobalDownloadPollingWithInterval(interval) { const data = await response.json(); const processes = data.active_processes || []; const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); - + if (serverWishlistProcess) { console.log('๐Ÿ”„ [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); await rehydrateModal(serverWishlistProcess, false); // false = not user-requested @@ -7563,23 +7563,23 @@ function startGlobalDownloadPollingWithInterval(interval) { console.debug('โš ๏ธ [Global Polling] Failed to check for wishlist auto-processing:', error); } } - + if (activeBatchIds.length === 0) { console.debug('๐Ÿ“Š [Global Polling] No active processes, continuing polling'); return; } - + try { const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); const response = await fetch(`/api/download_status/batch?${queryParams}`); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const data = await response.json(); console.debug(`๐Ÿ“Š [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); - + Object.entries(data.batches).forEach(([batchId, statusData]) => { const playlistId = batchToPlaylistMap[batchId]; if (!playlistId || statusData.error) { @@ -7590,7 +7590,7 @@ function startGlobalDownloadPollingWithInterval(interval) { } processModalStatusUpdate(playlistId, statusData); }); - + // Success - reset to normal interval if we were backing off globalPollingFailureCount = 0; if (interval !== globalPollingBaseInterval) { @@ -7599,11 +7599,11 @@ function startGlobalDownloadPollingWithInterval(interval) { globalDownloadStatusPoller = null; startGlobalDownloadPolling(); // Restart with normal interval } - + } catch (error) { console.error('โŒ [Global Polling] Request failed:', error); globalPollingFailureCount++; - + if (globalPollingFailureCount >= 5) { console.error(`๐Ÿšจ [Global Polling] Too many failures, continuing with backoff`); // Don't stop polling - just continue with exponential backoff @@ -7628,26 +7628,26 @@ function processModalStatusUpdate(playlistId, data) { console.debug(`โš ๏ธ [Status Update] No process found for ${playlistId}, skipping update`); return; } - + if (data.error) { console.error(`โŒ [Status Update] Error for ${playlistId}: ${data.error}`); return; } - + // ENHANCED: Validate response data to prevent UI corruption if (!data || typeof data !== 'object') { console.error(`โŒ [Status Update] Invalid data for ${playlistId}:`, data); return; } - + // ENHANCED: Validate task data structure if (data.tasks && !Array.isArray(data.tasks)) { console.error(`โŒ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks); return; } - + console.debug(`๐Ÿ“Š [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`); - + // Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only // Auto-show logic has been simplified to prevent conflicts @@ -7670,17 +7670,17 @@ function processModalStatusUpdate(playlistId, data) { } } else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') { console.debug(`๐Ÿ“Š [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`); - + if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') { - document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%'; - document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!'; - if(data.analysis_results) { - updateTrackAnalysisResults(playlistId, data.analysis_results); - const foundCount = data.analysis_results.filter(r => r.found).length; - const missingCount = data.analysis_results.filter(r => !r.found).length; - document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; - document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; - } + document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%'; + document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!'; + if (data.analysis_results) { + updateTrackAnalysisResults(playlistId, data.analysis_results); + const foundCount = data.analysis_results.filter(r => r.found).length; + const missingCount = data.analysis_results.filter(r => !r.found).length; + document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; + document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; + } } const missingTracks = (data.analysis_results || []).filter(r => !r.found); const missingCount = missingTracks.length; @@ -7711,29 +7711,29 @@ function processModalStatusUpdate(playlistId, data) { console.debug(`โŒ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`); return; } - + // V2 SYSTEM: Check for persistent cancel state from backend const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id const cancelRequested = task.cancel_requested || false; const uiState = task.ui_state || 'normal'; - + // Legacy protection for old system compatibility if (row.dataset.locallyCancelled === 'true' && !isV2Task) { failedOrCancelledCount++; return; // Only skip for legacy system tasks } - + // Mark row with V2 system info if (isV2Task) { row.dataset.useV2System = 'true'; row.dataset.cancelRequested = cancelRequested.toString(); row.dataset.uiState = uiState; } - + row.dataset.taskId = task.task_id; const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); - + let statusText = ''; // V2 SYSTEM: Handle UI state override for cancelling tasks if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') { @@ -7750,14 +7750,14 @@ function processModalStatusUpdate(playlistId, data) { default: statusText = `โšช ${task.status}`; break; } } - - if(statusEl) { + + if (statusEl) { statusEl.textContent = statusText; console.debug(`โœ… [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`); } else { console.warn(`โŒ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`); } - + // V2 SYSTEM: Smart button management with persistent state awareness if (actionsEl && !['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) { // Check if we're in a cancelling state @@ -7776,7 +7776,7 @@ function processModalStatusUpdate(playlistId, data) { // ENHANCED: Validate worker counts from server data const serverActiveWorkers = data.active_count || 0; const maxWorkers = data.max_concurrent || 3; - + // V2 SYSTEM: Simplified worker counting - backend is authoritative // Count active tasks, excluding locally cancelled legacy tasks only const clientActiveWorkers = (data.tasks || []).filter(task => { @@ -7784,19 +7784,19 @@ function processModalStatusUpdate(playlistId, data) { const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System; return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled; }).length; - + // Log discrepancies for debugging if (serverActiveWorkers !== clientActiveWorkers) { console.warn(`๐Ÿ” [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`); - + // If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed if (serverActiveWorkers === 0 && clientActiveWorkers > 0) { console.warn(`๐Ÿšจ [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`); } } - + console.debug(`๐Ÿ“Š [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`); - + const totalFinished = completedCount + failedOrCancelledCount; const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0; document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`; @@ -7862,40 +7862,40 @@ function processModalStatusUpdate(playlistId, data) { const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none'); const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated); - + // Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only - + if (data.phase === 'cancelled') { process.status = 'cancelled'; - + // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'discovered'); } - + showToast(`Process cancelled for ${process.playlist.name}.`, 'info'); } else if (data.phase === 'error') { process.status = 'complete'; // Treat as complete to allow cleanup updatePlaylistCardUI(playlistId); // Update card to show ready for review - + // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'discovered'); } - + showToast(`Process for ${process.playlist.name} failed!`, 'error'); } else { process.status = 'complete'; updatePlaylistCardUI(playlistId); // Update card to show ready for review - + // Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'download_complete'); } - + // Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); @@ -7943,29 +7943,29 @@ function processModalStatusUpdate(playlistId, data) { console.log(`โœ… [Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`); } } - + // Handle background wishlist processing completion specially if (isBackgroundWishlist) { console.log(`๐ŸŽ‰ Background wishlist processing complete: ${completedCount} downloaded, ${failedOrCancelledCount} failed`); - + // Reset modal to idle state to prevent "complete" phase disruption setTimeout(() => { resetWishlistModalToIdleState(); // Server-side auto-processing will handle next cycle automatically }, 500); - + return; // Skip normal completion handling } - + // Show completion summary with wishlist stats (matching sync.py behavior) let completionMessage = `Process complete for ${process.playlist.name}!`; let messageType = 'success'; - + // Check for wishlist summary from backend (added when failed/cancelled tracks are processed) if (data.wishlist_summary) { const summary = data.wishlist_summary; completionMessage = `Download process complete! Downloaded: ${completedCount}, Failed/Cancelled: ${failedOrCancelledCount}.`; - + if (summary.tracks_added > 0) { completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`; } else if (summary.total_failed > 0) { @@ -7973,16 +7973,16 @@ function processModalStatusUpdate(playlistId, data) { messageType = 'warning'; } } - + showToast(completionMessage, messageType); } - + document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; - + // Mark process as complete and trigger cleanup check process.status = 'complete'; updatePlaylistCardUI(playlistId); - + // Check if any other processes still need polling checkAndCleanupGlobalPolling(); } @@ -7993,7 +7993,7 @@ function checkAndCleanupGlobalPolling() { // Check if any processes still need polling const hasActivePolling = Object.values(activeDownloadProcesses) .some(p => p.batchId && p.status === 'running'); - + if (!hasActivePolling) { console.debug('๐Ÿงน [Cleanup] No more active processes, continuing polling'); // Keep polling active - no need to stop @@ -8004,21 +8004,21 @@ function checkAndCleanupGlobalPolling() { function startModalDownloadPolling(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process || !process.batchId) return; - + console.log(`๐Ÿ”„ [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`); - + // Clear any existing individual poller (cleanup) if (process.poller) { clearInterval(process.poller); process.poller = null; } - + // Mark process as running to be picked up by global poller process.status = 'running'; - + // Start global polling if not already running startGlobalDownloadPolling(); - + // Create dummy poller for backward compatibility with cleanup functions ensureLegacyCompatibility(playlistId); } @@ -8028,7 +8028,7 @@ function startModalDownloadPolling(playlistId) { function createLegacyPoller(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; - + // Create a dummy interval that just checks if the process is still active // This ensures existing cleanup logic that calls clearInterval(process.poller) works process.poller = setInterval(() => { @@ -8051,31 +8051,31 @@ function ensureLegacyCompatibility(playlistId) { async function updateModalWithLiveDownloadProgress() { try { if (!currentDownloadBatchId) return; - + // Fetch live download data from the downloads API const response = await fetch('/api/downloads/status'); const downloadData = await response.json(); - + if (downloadData.error) return; - + // Get all active and finished downloads - const allDownloads = {...(downloadData.active || {}), ...(downloadData.finished || {})}; - + const allDownloads = { ...(downloadData.active || {}), ...(downloadData.finished || {}) }; + // Update modal tracks that have active downloads const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]'); - + for (const row of modalRows) { const taskId = row.dataset.taskId; if (!taskId) continue; - + // Find corresponding download by checking if filename/title matches const trackName = row.querySelector('.track-name')?.textContent?.trim(); if (!trackName) continue; - + // Search for matching download for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) { const downloadTitle = downloadInfo.filename ? downloadInfo.filename.split(/[\\/]/).pop() : ''; - + // Simple matching - could be improved with better logic if (downloadTitle && trackName && ( downloadTitle.toLowerCase().includes(trackName.toLowerCase()) || @@ -8085,7 +8085,7 @@ async function updateModalWithLiveDownloadProgress() { const statusElement = row.querySelector('.track-download-status'); const progress = downloadInfo.percentComplete || 0; const state = downloadInfo.state || ''; - + if (statusElement && state.includes('InProgress') && progress > 0) { statusElement.textContent = `โฌ Downloading... ${Math.round(progress)}%`; statusElement.className = 'track-download-status download-downloading'; @@ -8093,12 +8093,12 @@ async function updateModalWithLiveDownloadProgress() { statusElement.textContent = 'โœ… Completed'; statusElement.className = 'track-download-status download-complete'; } - + break; // Found a match, stop searching } } } - + } catch (error) { // Silent fail - don't spam console during normal operation } @@ -8116,18 +8116,18 @@ async function cancelAllOperations(playlistId) { process.cancellingAll = true; console.log(`๐Ÿšซ Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`); - + showToast('Cancelling all operations and closing modal...', 'info'); - + // Mark process as complete immediately so polling stops process.status = 'complete'; - + // Stop any active polling if (process.poller) { clearInterval(process.poller); process.poller = null; } - + // Tell server to stop starting new downloads and clean up the batch if (process.batchId) { try { @@ -8143,10 +8143,10 @@ async function cancelAllOperations(playlistId) { console.warn('Error during server batch cancel:', error); } } - + // Close the modal immediately - this will handle cleanup closeDownloadMissingModal(playlistId); - + showToast('Modal closed. Active downloads will finish in background.', 'success'); } @@ -8155,18 +8155,18 @@ function resetToInitialState() { document.getElementById('begin-analysis-btn').style.display = 'inline-block'; document.getElementById('start-downloads-btn').style.display = 'none'; document.getElementById('cancel-all-btn').style.display = 'none'; - + // Reset progress bars document.getElementById('analysis-progress-fill').style.width = '0%'; document.getElementById('download-progress-fill').style.width = '0%'; document.getElementById('analysis-progress-text').textContent = 'Ready to start'; document.getElementById('download-progress-text').textContent = 'Waiting for analysis'; - + // Reset stats document.getElementById('stat-found').textContent = '-'; document.getElementById('stat-missing').textContent = '-'; document.getElementById('stat-downloaded').textContent = '0'; - + // Reset track table const tbody = document.getElementById('download-tracks-tbody'); if (tbody) { @@ -8175,7 +8175,7 @@ function resetToInitialState() { const matchElement = row.querySelector('.track-match-status'); const downloadElement = row.querySelector('.track-download-status'); const actionsElement = row.querySelector('.track-actions'); - + if (matchElement) { matchElement.textContent = '๐Ÿ” Pending'; matchElement.className = 'track-match-status match-checking'; @@ -8189,7 +8189,7 @@ function resetToInitialState() { } }); } - + // Reset state activeAnalysisTaskId = null; analysisResults = []; @@ -8224,51 +8224,51 @@ async function cancelTrackDownloadV2(playlistId, trackIndex) { // Check if already in cancelling state const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`); const currentStatus = statusEl ? statusEl.textContent : ''; - + if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) { console.log(`โš ๏ธ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`); return; } - + console.log(`๐ŸŽฏ [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`); - + // V2 SYSTEM: Set temporary UI state - will be confirmed by server row.dataset.uiState = 'cancelling'; - + // Show loading state only - no optimistic "cancelled" state if (statusEl) { statusEl.textContent = '๐Ÿ”„ Cancelling...'; } - + // Disable the cancel button to prevent double-clicks const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`); if (actionsEl) { actionsEl.innerHTML = 'Cancelling...'; } - + try { const response = await fetch('/api/downloads/cancel_task_v2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_id: playlistId, - track_index: trackIndex + body: JSON.stringify({ + playlist_id: playlistId, + track_index: trackIndex }) }); - + const data = await response.json(); - + if (data.success) { console.log(`โœ… [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`); showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success'); - + // Let the status polling system update the UI with server truth // No manual UI updates - backend is authoritative - + } else { console.error(`โŒ [Cancel V2] Cancel failed: ${data.error}`); showToast(`Cancel failed: ${data.error}`, 'error'); - + // Reset UI to previous state on failure row.dataset.uiState = 'normal'; // Reset UI state if (statusEl) { @@ -8278,11 +8278,11 @@ async function cancelTrackDownloadV2(playlistId, trackIndex) { actionsEl.innerHTML = ``; } } - + } catch (error) { console.error(`โŒ [Cancel V2] Network/API error:`, error); showToast(`Cancel request failed: ${error.message}`, 'error'); - + // Reset UI on network error row.dataset.uiState = 'normal'; // Reset UI state if (statusEl) { @@ -8315,12 +8315,12 @@ async function cancelTrackDownload(playlistId, trackIndex) { showToast('Task not started yet, cannot cancel.', 'warning'); return; } - + // UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions row.dataset.locallyCancelled = 'true'; document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '๐Ÿšซ Cancelling...'; document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-'; - + try { const response = await fetch('/api/downloads/cancel_task', { method: 'POST', @@ -8409,13 +8409,13 @@ async function startPlaylistSync(playlistId) { console.log(`๐Ÿ“ก [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`); const data = await response.json(); console.log(`๐Ÿ“ก [${new Date().toTimeString().split(' ')[0]}] API response data:`, data); - + if (!data.success) throw new Error(data.error); const totalTime = Date.now() - startTime; console.log(`โœ… [${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`); showToast(`Sync started for "${playlist.name}"`, 'success'); - + // Show initial sync state in modal if open const modal = document.getElementById('playlist-details-modal'); if (modal && modal.style.display !== 'none') { @@ -8425,7 +8425,7 @@ async function startPlaylistSync(playlistId) { console.log(`๐Ÿ“Š [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`); } } - + updateCardToSyncing(playlist.id, 0); // Initial state startSyncPolling(playlist.id); @@ -8458,7 +8458,7 @@ function startSyncPolling(playlistId) { console.log(`๐Ÿ“Š Sync progress:`, progress); console.log(` ๐Ÿ“Š Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`); console.log(` ๐Ÿ“Š Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`); - + // Use the actual progress percentage from the sync service updateCardToSyncing(playlistId, progress.progress, progress); // Also update the modal if it's open @@ -8509,7 +8509,7 @@ function startSequentialSync() { // Get playlist order from DOM to maintain display order const playlistCards = document.querySelectorAll('.playlist-card'); const orderedPlaylistIds = []; - + playlistCards.forEach(card => { const playlistId = card.dataset.playlistId; if (selectedPlaylists.has(playlistId)) { @@ -8518,10 +8518,10 @@ function startSequentialSync() { }); console.log(`๐Ÿš€ Starting sequential sync for ${orderedPlaylistIds.length} playlists`); - + // Start sequential sync sequentialSyncManager.start(orderedPlaylistIds); - + // Disable playlist selection during sync disablePlaylistSelection(true); } @@ -8573,14 +8573,14 @@ function updateCardToSyncing(playlistId, percent, progress = null) { let progressText = 'Starting...'; let actualPercent = percent || 0; - + if (progress) { // Create detailed progress text like the GUI const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; const total = progress.total_tracks || 0; const currentStep = progress.current_step || 'Processing'; - + // Calculate actual progress as processed/total, not just successful/total if (total > 0) { const processed = matched + failed; @@ -8589,13 +8589,13 @@ function updateCardToSyncing(playlistId, percent, progress = null) { } else { progressText = currentStep; } - + // If there's a current track being processed, show it if (progress.current_track) { progressText += ` - ${progress.current_track}`; } } - + // Build live status counter HTML (same as modal) let statusCounterHTML = ''; if (progress && progress.total_tracks > 0) { @@ -8604,7 +8604,7 @@ function updateCardToSyncing(playlistId, percent, progress = null) { const total = progress.total_tracks || 0; const processed = matched + failed; const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - + statusCounterHTML = `
โ™ช ${total} @@ -8616,7 +8616,7 @@ function updateCardToSyncing(playlistId, percent, progress = null) {
`; } - + progressBar.innerHTML = ` ${statusCounterHTML}
@@ -8662,33 +8662,33 @@ function updateModalSyncProgress(playlistId, progress) { const modal = document.getElementById('playlist-details-modal'); if (modal && modal.style.display !== 'none') { console.log(`๐Ÿ“Š Updating modal sync progress for ${playlistId}:`, progress); - + // Show sync status display const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`); if (statusDisplay) { statusDisplay.style.display = 'flex'; - + // Update counters (matching GUI exactly) const totalEl = document.getElementById(`modal-total-${playlistId}`); const matchedEl = document.getElementById(`modal-matched-${playlistId}`); const failedEl = document.getElementById(`modal-failed-${playlistId}`); const percentageEl = document.getElementById(`modal-percentage-${playlistId}`); - + const total = progress.total_tracks || 0; const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; - + if (totalEl) totalEl.textContent = total; if (matchedEl) matchedEl.textContent = matched; if (failedEl) failedEl.textContent = failed; - + // Calculate percentage like GUI if (total > 0) { const processed = matched + failed; const percentage = Math.round((processed / total) * 100); if (percentageEl) percentageEl.textContent = percentage; } - + console.log(`๐Ÿ“Š Modal updated: โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${Math.round((matched + failed) / total * 100)}%)`); } else { console.warn(`โŒ Modal sync status display not found for ${playlistId}`); @@ -8711,27 +8711,27 @@ async function loadDownloadsData() { // Event listeners are already set up in initializeSearch() - don't duplicate them const clearButton = document.querySelector('.controls-panel__clear-btn'); - + if (clearButton) { clearButton.addEventListener('click', clearFinishedDownloads); } - + // Start sophisticated polling system (1-second interval like GUI) startDownloadPolling(); - + // Initialize tab management initializeDownloadTabs(); } function startDownloadPolling() { if (isDownloadPollingActive) return; - + console.log('Starting download status polling (1-second interval)'); isDownloadPollingActive = true; - + // Initial call updateDownloadQueues(); - + // Start 1-second polling (matching GUI's 1000ms timer) downloadStatusInterval = setInterval(updateDownloadQueues, 1000); } @@ -8757,16 +8757,16 @@ async function updateDownloadQueues() { const newActive = {}; const newFinished = {}; - + // Terminal states matching GUI logic const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored']; // Process transfers exactly like GUI data.transfers.forEach(item => { - const isTerminal = terminalStates.some(state => + const isTerminal = terminalStates.some(state => item.state && item.state.includes(state) ); - + if (isTerminal) { newFinished[item.id] = item; } else { @@ -8777,14 +8777,14 @@ async function updateDownloadQueues() { // Update global state activeDownloads = newActive; finishedDownloads = newFinished; - + // Render both queues renderQueue('active-queue', activeDownloads, true); renderQueue('finished-queue', finishedDownloads, false); - + // Update tab counts updateTabCounts(); - + // Update stats in the side panel updateDownloadStats(); @@ -8815,7 +8815,7 @@ function renderQueue(containerId, downloads, isActiveQueue) { const bytesTransferred = item.bytesTransferred || 0; const totalBytes = item.size || 0; const speed = item.averageSpeed || 0; - + // Format file size const formatSize = (bytes) => { if (!bytes) return 'Unknown size'; @@ -8828,13 +8828,13 @@ function renderQueue(containerId, downloads, isActiveQueue) { } return `${size.toFixed(1)} ${units[unitIndex]}`; }; - + // Format speed const formatSpeed = (bytesPerSecond) => { if (!bytesPerSecond || bytesPerSecond <= 0) return ''; return `${formatSize(bytesPerSecond)}/s`; }; - + let actionButtonHTML = ''; if (isActiveQueue) { // Active items get progress bar and cancel button @@ -8857,7 +8857,7 @@ function renderQueue(containerId, downloads, isActiveQueue) { if (item.state.includes('Cancelled')) statusClass = 'status--cancelled'; else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed'; else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed'; - + actionButtonHTML = `
${item.state} @@ -8865,7 +8865,7 @@ function renderQueue(containerId, downloads, isActiveQueue) { `; } - + html += `
@@ -8884,10 +8884,10 @@ function renderQueue(containerId, downloads, isActiveQueue) { function updateTabCounts() { const activeCount = Object.keys(activeDownloads).length; const finishedCount = Object.keys(finishedDownloads).length; - + const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]'); const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]'); - + if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`; if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`; } @@ -8895,10 +8895,10 @@ function updateTabCounts() { function updateDownloadStats() { const activeCount = Object.keys(activeDownloads).length; const finishedCount = Object.keys(finishedDownloads).length; - + const activeLabel = document.getElementById('active-downloads-label'); const finishedLabel = document.getElementById('finished-downloads-label'); - + if (activeLabel) activeLabel.textContent = `โ€ข Active Downloads: ${activeCount}`; if (finishedLabel) finishedLabel.textContent = `โ€ข Finished Downloads: ${finishedCount}`; } @@ -8912,7 +8912,7 @@ function initializeDownloadTabs() { function switchDownloadTab(button) { const targetTabId = button.getAttribute('data-tab'); - + // Update buttons document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); button.classList.add('active'); @@ -8931,7 +8931,7 @@ async function cancelDownloadItem(downloadId, username) { body: JSON.stringify({ download_id: downloadId, username: username }) }); const result = await response.json(); - + if (result.success) { showToast('Download cancelled', 'success'); } else { @@ -8949,13 +8949,13 @@ async function clearFinishedDownloads() { showToast('No finished downloads to clear', 'error'); return; } - + try { const response = await fetch('/api/downloads/clear-finished', { method: 'POST' }); const result = await response.json(); - + if (result.success) { showToast('Finished downloads cleared', 'success'); } else { @@ -9021,11 +9021,11 @@ async function performDownloadsSearch() { showToast('No results found', 'error'); } else { document.getElementById('filters-container').classList.remove('hidden'); - + // Count albums and singles like the GUI app let totalAlbums = 0; let totalTracks = 0; - + results.forEach(result => { if (result.result_type === 'album') { totalAlbums++; @@ -9033,7 +9033,7 @@ async function performDownloadsSearch() { totalTracks++; } }); - + statusText.textContent = `โœจ Found ${results.length} results โ€ข ${totalAlbums} albums, ${totalTracks} singles`; showToast(`Found ${results.length} results`, 'success'); } @@ -9064,20 +9064,42 @@ async function performDownloadsSearch() { function displayDownloadsResults(results) { const resultsArea = document.getElementById('search-results-area'); if (!resultsArea) return; - + if (!results.length) { resultsArea.innerHTML = '

No search results found.

'; return; } - + let html = ''; results.forEach((result, index) => { const isAlbum = result.result_type === 'album'; - - if (isAlbum) { + if (result.username === 'youtube') { + const thumbnail = result.thumbnail || ''; + const durationText = result.duration ? formatDuration(result.duration) : ''; + + html += ` +
+
+ +
+
+
${escapeHtml(result.title)}
+
${escapeHtml(result.artist || result.username)} โ€ข ${durationText}
+
+
+ + +
+
+ `; + return; // Skip standard rendering for YouTube result + } + + + if (isAlbum && result.username !== 'youtube') { const trackCount = result.tracks ? result.tracks.length : 0; const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; - + // Generate individual track items let trackListHtml = ''; if (result.tracks && result.tracks.length > 0) { @@ -9101,7 +9123,7 @@ function displayDownloadsResults(results) { `; }); } - + html += `
@@ -9148,7 +9170,7 @@ function displayDownloadsResults(results) { `; } }); - + resultsArea.innerHTML = html; // Store results globally for download functions window.currentSearchResults = results; @@ -9157,18 +9179,18 @@ function displayDownloadsResults(results) { async function downloadTrack(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; - + const track = results[index]; - + try { const response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(track) }); - + const data = await response.json(); - + if (data.success) { showToast(`Download started: ${track.title}`, 'success'); } else { @@ -9183,18 +9205,18 @@ async function downloadTrack(index) { async function downloadAlbum(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; - + const album = results[index]; - + try { const response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(album) }); - + const data = await response.json(); - + if (data.success) { showToast(data.message, 'success'); } else { @@ -9210,10 +9232,10 @@ async function downloadAlbum(index) { function matchedDownloadTrack(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; - + const track = results[index]; console.log('๐ŸŽฏ Starting matched download for single track:', track); - + // Open matching modal for single track openMatchingModal(track, false, null); } @@ -9221,10 +9243,10 @@ function matchedDownloadTrack(index) { function matchedDownloadAlbum(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; - + const album = results[index]; console.log('๐ŸŽฏ Starting matched download for album:', album); - + // Open matching modal for album download openMatchingModal(album, true, album); } @@ -9232,19 +9254,19 @@ function matchedDownloadAlbum(index) { function matchedDownloadAlbumTrack(albumIndex, trackIndex) { const results = window.currentSearchResults; if (!results || !results[albumIndex]) return; - + const album = results[albumIndex]; if (!album.tracks || !album.tracks[trackIndex]) return; - + const track = album.tracks[trackIndex]; - + // Ensure track has necessary properties from parent album track.username = album.username; track.artist = track.artist || album.artist; track.album = album.album_title || album.title; - + console.log('๐ŸŽฏ Starting matched download for album track:', track); - + // Open matching modal for single track (from album context) openMatchingModal(track, false, null); } @@ -9252,10 +9274,10 @@ function matchedDownloadAlbumTrack(albumIndex, trackIndex) { function toggleAlbumExpansion(albumIndex) { const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`); if (!albumCard) return; - + const trackList = albumCard.querySelector('.album-track-list'); const indicator = albumCard.querySelector('.album-expand-indicator'); - + if (trackList.style.display === 'none' || !trackList.style.display) { // Expand trackList.style.display = 'block'; @@ -9272,9 +9294,9 @@ function toggleAlbumExpansion(albumIndex) { async function downloadAlbumTrack(albumIndex, trackIndex) { const results = window.currentSearchResults; if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return; - + const track = results[albumIndex].tracks[trackIndex]; - + try { const response = await fetch('/api/download', { method: 'POST', @@ -9284,9 +9306,9 @@ async function downloadAlbumTrack(albumIndex, trackIndex) { result_type: 'track' }) }); - + const data = await response.json(); - + if (data.success) { showToast(`Download started: ${track.title}`, 'success'); } else { @@ -9307,13 +9329,13 @@ async function streamTrack(index) { try { console.log(`๐ŸŽต streamTrack called with index: ${index}`); console.log(`๐ŸŽต window.currentSearchResults:`, window.currentSearchResults); - + if (!window.currentSearchResults || !window.currentSearchResults[index]) { console.error(`โŒ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); showToast('Track not found', 'error'); return; } - + const result = window.currentSearchResults[index]; console.log(`๐ŸŽต Streaming track:`, result); @@ -9331,9 +9353,9 @@ async function streamTrack(index) { return; } } - + await startStream(result); - + } catch (error) { console.error('Track streaming error:', error); showToast('Failed to start track stream', 'error'); @@ -9346,25 +9368,25 @@ async function streamAlbumTrack(albumIndex, trackIndex) { try { console.log(`๐ŸŽต streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`); console.log(`๐ŸŽต window.currentSearchResults:`, window.currentSearchResults); - + if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) { console.error(`โŒ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); showToast('Album not found', 'error'); return; } - + const album = window.currentSearchResults[albumIndex]; console.log(`๐ŸŽต Album data:`, album); - + if (!album.tracks || !album.tracks[trackIndex]) { console.error(`โŒ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`); showToast('Track not found in album', 'error'); return; } - + const track = album.tracks[trackIndex]; console.log(`๐ŸŽต Streaming album track:`, track); - + // Ensure album tracks have required fields const trackData = { ...track, @@ -9373,7 +9395,7 @@ async function streamAlbumTrack(albumIndex, trackIndex) { artist: track.artist || album.artist, album: track.album || album.title || album.album }; - + console.log(`๐ŸŽต Enhanced track data:`, trackData); // Check for unsupported formats before streaming (YouTube is always MP3, so skip check) @@ -9383,9 +9405,9 @@ async function streamAlbumTrack(albumIndex, trackIndex) { showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error'); return; } - + await startStream(trackData); - + } catch (error) { console.error('Album track streaming error:', error); showToast('Failed to start track stream', 'error'); @@ -9396,16 +9418,16 @@ async function loadArtistsData() { try { const response = await fetch(API.artists); const data = await response.json(); - + const artistsGrid = document.getElementById('artists-grid'); if (data.artists && data.artists.length) { artistsGrid.innerHTML = data.artists.map(artist => `
- ${artist.image ? - `${escapeHtml(artist.name)}` : - '
๐ŸŽต
' - } + ${artist.image ? + `${escapeHtml(artist.name)}` : + '
๐ŸŽต
' + }
${escapeHtml(artist.name)}
@@ -9442,11 +9464,11 @@ let recentToasts = new Map(); function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); - + // Create a unique key for this toast const toastKey = `${type}:${message}`; const now = Date.now(); - + // Check if we've shown this exact toast recently (within 5 seconds) if (recentToasts.has(toastKey)) { const lastShown = recentToasts.get(toastKey); @@ -9455,23 +9477,23 @@ function showToast(message, type = 'success') { return; // Don't show duplicate } } - + // Record this toast recentToasts.set(toastKey, now); - + // Clean up old entries (older than 10 seconds) for (const [key, timestamp] of recentToasts.entries()) { if (now - timestamp > 10000) { recentToasts.delete(key); } } - + const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; - + container.appendChild(toast); - + // Auto-remove after 3 seconds setTimeout(() => { if (container.contains(toast)) { @@ -9512,25 +9534,25 @@ function formatArtists(artists) { async function showVersionInfo() { try { console.log('Fetching version info...'); - + // Fetch version data from API const response = await fetch('/api/version-info'); if (!response.ok) { throw new Error('Failed to fetch version info'); } - + const versionData = await response.json(); console.log('Version data received:', versionData); - + // Populate modal content populateVersionModal(versionData); - + // Show modal const modalOverlay = document.getElementById('version-modal-overlay'); modalOverlay.classList.remove('hidden'); - + console.log('Version modal opened'); - + } catch (error) { console.error('Error showing version info:', error); showToast('Failed to load version information', 'error'); @@ -9549,47 +9571,47 @@ function populateVersionModal(versionData) { console.error('Version content container not found'); return; } - + // Update header with dynamic data const titleElement = document.querySelector('.version-modal-title'); const subtitleElement = document.querySelector('.version-modal-subtitle'); - + if (titleElement) titleElement.textContent = versionData.title; if (subtitleElement) subtitleElement.textContent = versionData.subtitle; - + // Clear existing content container.innerHTML = ''; - + // Create sections versionData.sections.forEach(section => { const sectionDiv = document.createElement('div'); sectionDiv.className = 'version-feature-section'; - + // Section title const titleDiv = document.createElement('div'); titleDiv.className = 'version-section-title'; titleDiv.textContent = section.title; sectionDiv.appendChild(titleDiv); - + // Section description const descDiv = document.createElement('div'); descDiv.className = 'version-section-description'; descDiv.textContent = section.description; sectionDiv.appendChild(descDiv); - + // Features list const featuresList = document.createElement('ul'); featuresList.className = 'version-feature-list'; - + section.features.forEach(feature => { const featureItem = document.createElement('li'); featureItem.className = 'version-feature-item'; featureItem.textContent = feature; featuresList.appendChild(featureItem); }); - + sectionDiv.appendChild(featuresList); - + // Usage note (if present) if (section.usage_note) { const usageDiv = document.createElement('div'); @@ -9597,10 +9619,10 @@ function populateVersionModal(versionData) { usageDiv.textContent = `๐Ÿ’ก ${section.usage_note}`; sectionDiv.appendChild(usageDiv); } - + container.appendChild(sectionDiv); }); - + console.log('Version modal content populated'); } @@ -9892,7 +9914,7 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) { } // Add new enter key handler - discoveryFixEnterHandler = function(e) { + discoveryFixEnterHandler = function (e) { if (e.key === 'Enter') searchDiscoveryFix(); }; trackInput.addEventListener('keypress', discoveryFixEnterHandler); @@ -10834,7 +10856,7 @@ function initializeFilters() { // Using .onclick ensures we only ever have one click handler toggleBtn.onclick = () => { const isExpanded = container.classList.contains('expanded'); - + if (isExpanded) { // Collapse the container container.classList.remove('expanded'); @@ -10883,7 +10905,7 @@ function resetFilters() { currentFilterFormat = 'all'; currentSortBy = 'quality_score'; isSortReversed = false; - + document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active'); document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active'); @@ -10918,7 +10940,7 @@ function applyFiltersAndSort() { valB = calculateRelevanceScore(b, query); return valB - valA; // Higher score is better } - + // Special handling for availability if (currentSortBy === 'availability') { valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1; @@ -10935,23 +10957,23 @@ function applyFiltersAndSort() { const titleB = (b.album_title || b.title || '').toLowerCase(); return titleA.localeCompare(titleB); } - + // Default numeric sort (descending) return valB - valA; }); // Handle sort direction toggle const sortDefaults = { - relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', + relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', upload_speed: 'desc', duration: 'desc', availability: 'desc', title: 'asc', username: 'asc' }; - + const defaultOrder = sortDefaults[currentSortBy] || 'desc'; if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) { processedResults.reverse(); } - + displayDownloadsResults(processedResults); } @@ -10979,7 +11001,7 @@ function calculateRelevanceScore(result, query) { // 4. File Completeness (Bitrate & Duration) (15%) const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0); score += completeness * 0.15; - + return score; } // APPEND THIS JAVASCRIPT SNIPPET (B) @@ -10993,7 +11015,7 @@ function initializeFilters() { // Using .onclick ensures we only ever have one click handler toggleBtn.onclick = () => { const isExpanded = container.classList.contains('expanded'); - + if (isExpanded) { // Collapse the container container.classList.remove('expanded'); @@ -11042,7 +11064,7 @@ function resetFilters() { currentFilterFormat = 'all'; currentSortBy = 'quality_score'; isSortReversed = false; - + document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active'); document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active'); @@ -11077,7 +11099,7 @@ function applyFiltersAndSort() { valB = calculateRelevanceScore(b, query); return valB - valA; // Higher score is better } - + // Special handling for availability if (currentSortBy === 'availability') { valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1; @@ -11094,23 +11116,23 @@ function applyFiltersAndSort() { const titleB = (b.album_title || b.title || '').toLowerCase(); return titleA.localeCompare(titleB); } - + // Default numeric sort (descending) return valB - valA; }); // Handle sort direction toggle const sortDefaults = { - relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', + relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', upload_speed: 'desc', duration: 'desc', availability: 'desc', title: 'asc', username: 'asc' }; - + const defaultOrder = sortDefaults[currentSortBy] || 'desc'; if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) { processedResults.reverse(); } - + displayDownloadsResults(processedResults); } @@ -11138,7 +11160,7 @@ function calculateRelevanceScore(result, query) { // 4. File Completeness (Bitrate & Duration) (15%) const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0); score += completeness * 0.15; - + return score; } @@ -11166,7 +11188,7 @@ let searchTimers = { function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) { console.log('๐ŸŽฏ Opening matching modal for:', searchResult); - + // Store the current matching data currentMatchingData = { searchResult: searchResult, @@ -11176,18 +11198,18 @@ function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = selectedAlbum: null, currentStage: 'artist' }; - + // Show modal const overlay = document.getElementById('matching-modal-overlay'); overlay.classList.remove('hidden'); - + // Reset modal state resetModalState(); - + // Set appropriate title and stage const modalTitle = document.getElementById('matching-modal-title'); const artistStageTitle = document.getElementById('artist-stage-title'); - + if (isAlbumDownload) { modalTitle.textContent = 'Match Album Download to Spotify'; artistStageTitle.textContent = 'Step 1: Select the correct Artist'; @@ -11197,10 +11219,10 @@ function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = artistStageTitle.textContent = 'Select the correct Artist for this Single'; document.getElementById('album-selection-stage').style.display = 'none'; } - + // Generate initial artist suggestions fetchArtistSuggestions(); - + // Setup event listeners setupModalEventListeners(); } @@ -11208,12 +11230,12 @@ function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = function closeMatchingModal() { const overlay = document.getElementById('matching-modal-overlay'); overlay.classList.add('hidden'); - + // Clear timers Object.values(searchTimers).forEach(timer => { if (timer) clearTimeout(timer); }); - + // Reset state currentMatchingData = { searchResult: null, @@ -11229,20 +11251,20 @@ function resetModalState() { // Show artist stage, hide album stage document.getElementById('artist-selection-stage').classList.remove('hidden'); document.getElementById('album-selection-stage').classList.add('hidden'); - + // Clear all suggestion containers document.getElementById('artist-suggestions').innerHTML = ''; document.getElementById('artist-manual-results').innerHTML = ''; document.getElementById('album-suggestions').innerHTML = ''; document.getElementById('album-manual-results').innerHTML = ''; - + // Clear search inputs document.getElementById('artist-search-input').value = ''; document.getElementById('album-search-input').value = ''; - + // Reset button states document.getElementById('confirm-match-btn').disabled = true; - + // Reset selections currentMatchingData.selectedArtist = null; currentMatchingData.selectedAlbum = null; @@ -11253,18 +11275,18 @@ function setupModalEventListeners() { // Search input listeners const artistInput = document.getElementById('artist-search-input'); const albumInput = document.getElementById('album-search-input'); - + artistInput.removeEventListener('input', handleArtistSearch); artistInput.addEventListener('input', handleArtistSearch); - + albumInput.removeEventListener('input', handleAlbumSearch); albumInput.addEventListener('input', handleAlbumSearch); - + // Button listeners const skipBtn = document.getElementById('skip-matching-btn'); const cancelBtn = document.getElementById('cancel-match-btn'); const confirmBtn = document.getElementById('confirm-match-btn'); - + skipBtn.onclick = skipMatching; cancelBtn.onclick = closeMatchingModal; confirmBtn.onclick = confirmMatch; @@ -11273,7 +11295,7 @@ function setupModalEventListeners() { async function fetchArtistSuggestions() { try { showLoadingCards('artist-suggestions', 'Finding artist...'); - + const response = await fetch('/api/match/suggestions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -11284,7 +11306,7 @@ async function fetchArtistSuggestions() { album_result: currentMatchingData.albumResult }) }); - + const data = await response.json(); if (data.suggestions) { renderArtistSuggestions(data.suggestions); @@ -11299,10 +11321,10 @@ async function fetchArtistSuggestions() { async function fetchAlbumSuggestions() { if (!currentMatchingData.selectedArtist) return; - + try { showLoadingCards('album-suggestions', 'Finding album...'); - + const response = await fetch('/api/match/suggestions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -11312,7 +11334,7 @@ async function fetchAlbumSuggestions() { selected_artist: currentMatchingData.selectedArtist }) }); - + const data = await response.json(); if (data.suggestions) { renderAlbumSuggestions(data.suggestions); @@ -11328,12 +11350,12 @@ async function fetchAlbumSuggestions() { function renderArtistSuggestions(suggestions) { const container = document.getElementById('artist-suggestions'); container.innerHTML = ''; - + if (!suggestions.length) { showNoResultsMessage('artist-suggestions', 'No artist matches found'); return; } - + suggestions.forEach(suggestion => { const card = createArtistCard(suggestion.artist, suggestion.confidence); container.appendChild(card); @@ -11343,12 +11365,12 @@ function renderArtistSuggestions(suggestions) { function renderAlbumSuggestions(suggestions) { const container = document.getElementById('album-suggestions'); container.innerHTML = ''; - + if (!suggestions.length) { showNoResultsMessage('album-suggestions', 'No album matches found'); return; } - + suggestions.forEach(suggestion => { const card = createAlbumCard(suggestion.album, suggestion.confidence); container.appendChild(card); @@ -11359,10 +11381,10 @@ function createArtistCard(artist, confidence) { const card = document.createElement('div'); card.className = 'suggestion-card'; card.onclick = () => selectArtist(artist); - + const imageUrl = artist.image_url || ''; const confidencePercent = Math.round(confidence * 100); - + card.innerHTML = `
@@ -11373,14 +11395,14 @@ function createArtistCard(artist, confidence) {
${confidencePercent}% match
`; - + // Set background image if available if (imageUrl) { card.style.backgroundImage = `url(${imageUrl})`; card.style.backgroundSize = 'cover'; card.style.backgroundPosition = 'center'; } - + return card; } @@ -11388,11 +11410,11 @@ function createAlbumCard(album, confidence) { const card = document.createElement('div'); card.className = 'suggestion-card'; card.onclick = () => selectAlbum(album); - + const imageUrl = album.image_url || ''; const confidencePercent = Math.round(confidence * 100); const year = album.release_date ? album.release_date.split('-')[0] : ''; - + card.innerHTML = `
@@ -11403,14 +11425,14 @@ function createAlbumCard(album, confidence) {
${confidencePercent}% match
`; - + // Set background image if available if (imageUrl) { card.style.backgroundImage = `url(${imageUrl})`; card.style.backgroundSize = 'cover'; card.style.backgroundPosition = 'center'; } - + return card; } @@ -11422,15 +11444,15 @@ function selectArtist(artist) { document.querySelectorAll('#artist-manual-results .suggestion-card').forEach(card => { card.classList.remove('selected'); }); - + // Mark new selection event.currentTarget.classList.add('selected'); - + // Store selection currentMatchingData.selectedArtist = artist; - + console.log('๐ŸŽฏ Selected artist:', artist.name); - + if (currentMatchingData.isAlbumDownload) { // Transition to album selection stage transitionToAlbumStage(); @@ -11448,15 +11470,15 @@ function selectAlbum(album) { document.querySelectorAll('#album-manual-results .suggestion-card').forEach(card => { card.classList.remove('selected'); }); - + // Mark new selection event.currentTarget.classList.add('selected'); - + // Store selection currentMatchingData.selectedAlbum = album; - + console.log('๐ŸŽฏ Selected album:', album.name); - + // Enable confirm button document.getElementById('confirm-match-btn').disabled = false; } @@ -11464,34 +11486,34 @@ function selectAlbum(album) { function transitionToAlbumStage() { // Hide artist stage document.getElementById('artist-selection-stage').classList.add('hidden'); - + // Show album stage const albumStage = document.getElementById('album-selection-stage'); albumStage.classList.remove('hidden'); - + // Update selected artist name document.getElementById('selected-artist-name').textContent = currentMatchingData.selectedArtist.name; - + // Update current stage currentMatchingData.currentStage = 'album'; - + // Fetch album suggestions fetchAlbumSuggestions(); } function handleArtistSearch(event) { const query = event.target.value.trim(); - + // Clear previous timer if (searchTimers.artist) { clearTimeout(searchTimers.artist); } - + if (query.length < 2) { document.getElementById('artist-manual-results').innerHTML = ''; return; } - + // Debounce search searchTimers.artist = setTimeout(() => { performArtistSearch(query); @@ -11500,17 +11522,17 @@ function handleArtistSearch(event) { function handleAlbumSearch(event) { const query = event.target.value.trim(); - + // Clear previous timer if (searchTimers.album) { clearTimeout(searchTimers.album); } - + if (query.length < 2) { document.getElementById('album-manual-results').innerHTML = ''; return; } - + // Debounce search searchTimers.album = setTimeout(() => { performAlbumSearch(query); @@ -11549,10 +11571,10 @@ async function performArtistSearch(query) { async function performAlbumSearch(query) { if (!currentMatchingData.selectedArtist) return; - + try { showLoadingCards('album-manual-results', 'Searching albums...'); - + const response = await fetch('/api/match/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -11562,7 +11584,7 @@ async function performAlbumSearch(query) { artist_id: currentMatchingData.selectedArtist.id }) }); - + const data = await response.json(); if (data.results) { renderAlbumSearchResults(data.results); @@ -11578,7 +11600,7 @@ async function performAlbumSearch(query) { function renderArtistSearchResults(results) { const container = document.getElementById('artist-manual-results'); container.innerHTML = ''; - + results.forEach((result, index) => { console.log(`Manual search result ${index}:`, result); console.log(` result.artist:`, result.artist); @@ -11600,7 +11622,7 @@ function renderArtistSearchResults(results) { function renderAlbumSearchResults(results) { const container = document.getElementById('album-manual-results'); container.innerHTML = ''; - + results.forEach(result => { const card = createAlbumCard(result.album, result.confidence); container.appendChild(card); @@ -11619,10 +11641,10 @@ function showNoResultsMessage(containerId, message) { function skipMatching() { console.log('๐ŸŽฏ Skipping matching, proceeding with normal download'); - + // Close modal closeMatchingModal(); - + // Start normal download if (currentMatchingData.isAlbumDownload) { // For albums, we need to download each track @@ -12723,10 +12745,10 @@ function resetWishlistModalToIdleState() { // Reset wishlist modal to idle state after background processing completes const playlistId = 'wishlist'; const process = activeDownloadProcesses[playlistId]; - + if (process) { console.log('๐Ÿ”„ Resetting wishlist modal to idle state...'); - + // Reset button states const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); @@ -12744,30 +12766,30 @@ function resetWishlistModalToIdleState() { if (forceToggleContainer) { forceToggleContainer.style.display = 'flex'; } - + // Reset progress displays const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`); const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`); const downloadText = document.getElementById(`download-progress-text-${playlistId}`); const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`); - + if (analysisText) analysisText.textContent = 'Ready to start'; if (analysisBar) analysisBar.style.width = '0%'; if (downloadText) downloadText.textContent = 'Waiting for analysis'; if (downloadBar) downloadBar.style.width = '0%'; - + // Reset all track rows to pending state const trackRows = document.querySelectorAll(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index]`); trackRows.forEach((row, index) => { const matchCell = row.querySelector(`#match-${playlistId}-${index}`); const downloadCell = row.querySelector(`#download-${playlistId}-${index}`); const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`); - + if (matchCell) matchCell.textContent = '๐Ÿ” Pending'; if (downloadCell) downloadCell.textContent = '-'; if (actionsCell) actionsCell.innerHTML = '-'; }); - + // Reset stats const foundElement = document.getElementById(`stat-found-${playlistId}`); const missingElement = document.getElementById(`stat-missing-${playlistId}`); @@ -12775,7 +12797,7 @@ function resetWishlistModalToIdleState() { if (foundElement) foundElement.textContent = '-'; if (missingElement) missingElement.textContent = '-'; if (downloadedElement) downloadedElement.textContent = '0'; - + // Reset process status process.status = 'idle'; process.batchId = null; @@ -12783,7 +12805,7 @@ function resetWishlistModalToIdleState() { clearInterval(process.poller); process.poller = null; } - + console.log('โœ… Wishlist modal fully reset to idle state'); } else { console.log('โš ๏ธ No wishlist process found to reset'); @@ -12802,10 +12824,10 @@ async function loadDashboardData() { if (metadataButton) { metadataButton.addEventListener('click', handleMetadataUpdateButtonClick); } - + // Check active media server and hide metadata updater if not Plex await checkAndHideMetadataUpdaterForNonPlex(); - + // Check for ongoing metadata update and restore state await checkAndRestoreMetadataUpdateState(); @@ -12841,32 +12863,32 @@ async function loadDashboardData() { // Initial load of stats await fetchAndUpdateDbStats(); - + // Start periodic refresh of stats (every 30 seconds) stopDbStatsPolling(); // Ensure no duplicates dbStatsInterval = setInterval(fetchAndUpdateDbStats, 30000); // Initial load of wishlist count await updateWishlistCount(); - + // Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior) stopWishlistCountPolling(); // Ensure no duplicates wishlistCountInterval = setInterval(updateWishlistCount, 30000); - + // Initial load of service status and system statistics await fetchAndUpdateServiceStatus(); await fetchAndUpdateSystemStats(); - + // Service status is already polled globally (line 311) // System stats polling kept here (dashboard-specific) setInterval(fetchAndUpdateSystemStats, 10000); - + // Initial load of activity feed await fetchAndUpdateActivityFeed(); - + // Start periodic refresh of activity feed (every 5 seconds for responsiveness) setInterval(fetchAndUpdateActivityFeed, 5000); - + // Start periodic toast checking (every 3 seconds) setInterval(checkForActivityToasts, 3000); @@ -12881,7 +12903,7 @@ async function loadDashboardData() { // Check for any active download processes that need rehydration await checkForActiveProcesses(); - + // Automatic wishlist processing now runs server-side } @@ -12891,7 +12913,7 @@ async function fetchAndUpdateDbStats() { try { const response = await fetch('/api/database/stats'); if (!response.ok) return; - + const stats = await response.json(); // This function updates the stat cards in the top grid @@ -12933,7 +12955,7 @@ function updateDbUpdaterCardInfo(stats) { if (albumsStatEl) albumsStatEl.textContent = stats.albums.toLocaleString() || '0'; if (tracksStatEl) tracksStatEl.textContent = stats.tracks.toLocaleString() || '0'; if (sizeStatEl) sizeStatEl.textContent = `${stats.database_size_mb.toFixed(2)} MB`; - + // Update the title of the tool card to show which server is active const toolCardTitle = document.querySelector('#db-updater-card .tool-card-title'); if (toolCardTitle && stats.server_source) { @@ -12948,14 +12970,14 @@ async function updateWishlistCount() { try { const response = await fetch('/api/wishlist/count'); if (!response.ok) return; - + const data = await response.json(); const count = data.count || 0; - + const wishlistButton = document.getElementById('wishlist-button'); if (wishlistButton) { wishlistButton.textContent = `๐ŸŽต Wishlist (${count})`; - + // Update button styling based on count (matching GUI behavior) if (count === 0) { wishlistButton.classList.remove('wishlist-active'); @@ -12965,10 +12987,10 @@ async function updateWishlistCount() { wishlistButton.classList.add('wishlist-active'); } } - + // Check for auto-initiated wishlist processes that user should see immediately await checkForAutoInitiatedWishlistProcess(); - + } catch (error) { console.warn('Could not fetch wishlist count:', error); } @@ -12977,44 +12999,44 @@ async function updateWishlistCount() { async function checkForAutoInitiatedWishlistProcess() { try { const playlistId = 'wishlist'; - + // Only check if we're on the dashboard and no modal is currently visible if (currentPage !== 'dashboard') { return; } - + // Don't override if user has manually closed the modal during auto-processing if (WishlistModalState.wasUserClosed()) { return; } - + // Check for active wishlist processes const response = await fetch('/api/active-processes'); if (!response.ok) return; - + const data = await response.json(); const processes = data.active_processes || []; const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); const clientWishlistProcess = activeDownloadProcesses[playlistId]; - + if (serverWishlistProcess && serverWishlistProcess.auto_initiated) { console.log('๐Ÿค– [Auto-Processing] Detected auto-initiated wishlist process during polling'); - + // Only sync frontend state if needed, but don't auto-show modal - const needsSync = !clientWishlistProcess || + const needsSync = !clientWishlistProcess || clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || !clientWishlistProcess.modalElement || !document.body.contains(clientWishlistProcess.modalElement); - + if (needsSync) { console.log('๐Ÿ”„ [Auto-Processing] Syncing frontend state for auto-processing (background mode)'); await rehydrateModal(serverWishlistProcess, false); // Background sync only } - + // Note: Modal visibility is controlled by user interaction only // User must click wishlist button to see auto-processing progress } - + } catch (error) { console.warn('Error checking for auto-initiated wishlist process:', error); } @@ -13073,9 +13095,9 @@ function updateDbProgressUI(state) { phaseLabel.textContent = state.phase || 'Idle'; progressBar.style.backgroundColor = '#1db954'; // Green for normal } - + if (state.status === 'finished' || state.status === 'error') { - // Final stats refresh after completion/error + // Final stats refresh after completion/error setTimeout(fetchAndUpdateDbStats, 500); } } @@ -13088,7 +13110,7 @@ function updateDbProgressUI(state) { async function loadTidalPlaylists() { const container = document.getElementById('tidal-playlist-container'); const refreshBtn = document.getElementById('tidal-refresh-btn'); - + container.innerHTML = `
๐Ÿ”„ Loading Tidal playlists...
`; refreshBtn.disabled = true; refreshBtn.textContent = '๐Ÿ”„ Loading...'; @@ -13099,13 +13121,13 @@ async function loadTidalPlaylists() { const error = await response.json(); throw new Error(error.error || 'Failed to fetch Tidal playlists'); } - + tidalPlaylists = await response.json(); renderTidalPlaylists(); tidalPlaylistsLoaded = true; console.log(`๐ŸŽต Loaded ${tidalPlaylists.length} Tidal playlists`); - + // Load and apply saved discovery states from backend (like YouTube) await loadTidalPlaylistStatesFromBackend(); @@ -13133,10 +13155,10 @@ function renderTidalPlaylists() { playlist: p }; } - + return createTidalCard(p); }).join(''); - + // Add click handlers to cards tidalPlaylists.forEach(p => { const card = document.getElementById(`tidal-card-${p.id}`); @@ -13149,12 +13171,12 @@ function renderTidalPlaylists() { function createTidalCard(playlist) { const state = tidalPlaylistStates[playlist.id]; const phase = state.phase; - + // Get phase-specific button text (like YouTube cards) let buttonText = getActionButtonText(phase); let phaseText = getPhaseText(phase); let phaseColor = getPhaseColor(phase); - + return `
๐ŸŽต
@@ -13181,38 +13203,38 @@ async function handleTidalCardClick(playlistId) { showToast('Playlist state not found - try refreshing the page', 'error'); return; } - + // Validate required state data if (!state.playlist) { console.error(`โŒ [Card Click] No playlist data found for Tidal playlist: ${playlistId}`); showToast('Playlist data missing - try refreshing the page', 'error'); return; } - + // Validate phase if (!state.phase) { console.warn(`โš ๏ธ [Card Click] No phase set for Tidal playlist ${playlistId} - defaulting to 'fresh'`); state.phase = 'fresh'; } - + console.log(`๐ŸŽต [Card Click] Tidal card clicked: ${playlistId}, Phase: ${state.phase}`); - + if (state.phase === 'fresh') { // No need to fetch data - we already have all tracks from initial load (like sync.py) console.log(`๐ŸŽต Using pre-loaded Tidal playlist data for: ${state.playlist.name}`); console.log(`๐ŸŽต Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`); - + // Open discovery modal - phase will be updated when discovery actually starts openTidalDiscoveryModal(playlistId, state.playlist); - + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { // Reopen existing modal with preserved discovery results (like GUI sync.py) console.log(`๐ŸŽต [Card Click] Opening Tidal discovery modal for ${state.phase} phase`); - + // Validate that we have discovery results to show if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { console.warn(`โš ๏ธ [Card Click] Discovered phase but no discovery results found - attempting to reload from backend`); - + // Try to fetch from backend as fallback try { const stateResponse = await fetch(`/api/tidal/state/${playlistId}`); @@ -13223,7 +13245,7 @@ async function handleTidalCardClick(playlistId) { state.discovery_results = fullState.discovery_results; state.spotify_matches = fullState.spotify_matches || state.spotify_matches; state.discovery_progress = fullState.discovery_progress || state.discovery_progress; - tidalPlaylistStates[playlistId] = {...tidalPlaylistStates[playlistId], ...state}; + tidalPlaylistStates[playlistId] = { ...tidalPlaylistStates[playlistId], ...state }; console.log(`โœ… [Card Click] Restored ${fullState.discovery_results.length} discovery results from backend`); } } @@ -13231,7 +13253,7 @@ async function handleTidalCardClick(playlistId) { console.error(`โŒ [Card Click] Failed to fetch discovery results from backend: ${error}`); } } - + openTidalDiscoveryModal(playlistId, state.playlist); } else if (state.phase === 'downloading' || state.phase === 'download_complete') { // Open download modal if we have the converted playlist ID @@ -13255,7 +13277,7 @@ async function handleTidalCardClick(playlistId) { } else { console.error('โŒ [Card Click] No converted Spotify playlist ID found for Tidal download modal'); console.log('๐Ÿ“Š [Card Click] Available state data:', Object.keys(state)); - + // Fallback: try to open discovery modal if we have discovery results if (state.discovery_results && state.discovery_results.length > 0) { console.log(`๐Ÿ”„ [Card Click] Fallback: Opening discovery modal with ${state.discovery_results.length} results`); @@ -13275,9 +13297,9 @@ async function rehydrateTidalDownloadModal(playlistId, state) { showToast('Cannot open download modal - invalid playlist data', 'error'); return; } - + console.log(`๐Ÿ’ง [Rehydration] Rehydrating Tidal download modal for: ${state.playlist.name}`); - + // Get discovery results from backend if not already loaded if (!state.discovery_results) { console.log(`๐Ÿ” Fetching discovery results from backend for Tidal playlist: ${playlistId}`); @@ -13294,7 +13316,7 @@ async function rehydrateTidalDownloadModal(playlistId, state) { return; } } - + // Extract Spotify tracks from discovery results const spotifyTracks = []; for (const result of state.discovery_results) { @@ -13302,34 +13324,34 @@ async function rehydrateTidalDownloadModal(playlistId, state) { spotifyTracks.push(result.spotify_data); } } - + if (spotifyTracks.length === 0) { console.error('โŒ No Spotify tracks found for download modal'); showToast('No Spotify matches found for download', 'error'); return; } - + const virtualPlaylistId = state.convertedSpotifyPlaylistId; const playlistName = state.playlist.name; // Create the download modal await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); - + // If we have a download process ID, set up the modal for the running state if (state.download_process_id) { const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = state.phase === 'download_complete' ? 'complete' : 'running'; process.batchId = state.download_process_id; - + // Update UI based on phase const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - + if (state.phase === 'downloading') { if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; - + // Start polling for live updates startModalDownloadPolling(virtualPlaylistId); console.log(`๐Ÿ”„ Started polling for active Tidal download: ${state.download_process_id}`); @@ -13337,7 +13359,7 @@ async function rehydrateTidalDownloadModal(playlistId, state) { if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; console.log(`โœ… Showing completed Tidal download results: ${state.download_process_id}`); - + // For completed downloads, fetch the final results once to populate the modal try { const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`); @@ -13361,9 +13383,9 @@ async function rehydrateTidalDownloadModal(playlistId, state) { } } } - + console.log(`โœ… Successfully rehydrated Tidal download modal for: ${state.playlist.name}`); - + } catch (error) { console.error(`โŒ Error rehydrating Tidal download modal:`, error); showToast('Error opening download modal', 'error'); @@ -13376,32 +13398,32 @@ function updateCompletedModalResults(playlistId, downloadData) { * This reuses the existing status polling logic but applies it once for completed state */ console.log(`๐Ÿ“Š [Completed Results] Updating modal ${playlistId} with final download results`); - + // Validate input data if (!downloadData || !downloadData.tasks) { console.error(`โŒ [Completed Results] Invalid download data for playlist ${playlistId}:`, downloadData); return; } - + try { // Update analysis progress to 100% const analysisProgressFill = document.getElementById(`analysis-progress-fill-${playlistId}`); const analysisProgressText = document.getElementById(`analysis-progress-text-${playlistId}`); if (analysisProgressFill) analysisProgressFill.style.width = '100%'; if (analysisProgressText) analysisProgressText.textContent = 'Analysis complete!'; - + // Update analysis results and stats if (downloadData.analysis_results) { updateTrackAnalysisResults(playlistId, downloadData.analysis_results); const foundCount = downloadData.analysis_results.filter(r => r.found).length; const missingCount = downloadData.analysis_results.filter(r => !r.found).length; - + const statFound = document.getElementById(`stat-found-${playlistId}`); const statMissing = document.getElementById(`stat-missing-${playlistId}`); if (statFound) statFound.textContent = foundCount; if (statMissing) statMissing.textContent = missingCount; } - + // Process completed tasks to update individual track statuses const missingTracks = (downloadData.analysis_results || []).filter(r => !r.found); let completedCount = 0; @@ -13410,11 +13432,11 @@ function updateCompletedModalResults(playlistId, downloadData) { (downloadData.tasks || []).forEach(task => { const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`); if (!row) return; - + row.dataset.taskId = task.task_id; const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); - + let statusText = ''; switch (task.status) { case 'pending': statusText = 'โธ๏ธ Pending'; break; @@ -13426,7 +13448,7 @@ function updateCompletedModalResults(playlistId, downloadData) { case 'cancelled': statusText = '๐Ÿšซ Cancelled'; failedOrCancelledCount++; break; default: statusText = `โšช ${task.status}`; break; } - + if (statusEl) statusEl.textContent = statusText; if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks }); @@ -13435,17 +13457,17 @@ function updateCompletedModalResults(playlistId, downloadData) { const totalFinished = completedCount + failedOrCancelledCount; const missingCount = missingTracks.length; const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 100; - + const downloadProgressFill = document.getElementById(`download-progress-fill-${playlistId}`); const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`); const statDownloaded = document.getElementById(`stat-downloaded-${playlistId}`); - + if (downloadProgressFill) downloadProgressFill.style.width = `${progressPercent}%`; if (downloadProgressText) downloadProgressText.textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; if (statDownloaded) statDownloaded.textContent = completedCount; - + console.log(`โœ… [Completed Results] Updated modal with ${completedCount} completed, ${failedOrCancelledCount} failed tasks`); - + } catch (error) { console.error(`โŒ [Completed Results] Error updating completed modal results:`, error); } @@ -13454,29 +13476,29 @@ function updateCompletedModalResults(playlistId, downloadData) { function updateTidalCardPhase(playlistId, phase) { const state = tidalPlaylistStates[playlistId]; if (!state) return; - + state.phase = phase; - + // Re-render the card with new phase const card = document.getElementById(`tidal-card-${playlistId}`); if (card) { const oldButtonText = card.querySelector('.playlist-card-action-btn')?.textContent || 'unknown'; const newCardHtml = createTidalCard(state.playlist); card.outerHTML = newCardHtml; - + // Verify the card was actually updated const updatedCard = document.getElementById(`tidal-card-${playlistId}`); const newButtonText = updatedCard?.querySelector('.playlist-card-action-btn')?.textContent || 'unknown'; - + console.log(`๐Ÿ”„ [Card Update] Re-rendered Tidal card ${playlistId}:`); console.log(` ๐Ÿ“Š Phase: ${phase}`); console.log(` ๐Ÿ”˜ Button text: "${oldButtonText}" โ†’ "${newButtonText}"`); console.log(` โœ… Expected: "${getActionButtonText(phase)}"`); - + if (newButtonText !== getActionButtonText(phase)) { console.error(`โŒ [Card Update] Button text mismatch! Expected "${getActionButtonText(phase)}", got "${newButtonText}"`); } - + // Re-attach click handler const newCard = document.getElementById(`tidal-card-${playlistId}`); if (newCard) { @@ -13485,7 +13507,7 @@ function updateTidalCardPhase(playlistId, phase) { } else { console.error(`โŒ [Card Update] Failed to find new card after rendering: tidal-card-${playlistId}`); } - + // If we have sync progress and we're in sync/sync_complete phase, restore it if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { setTimeout(() => { @@ -13493,21 +13515,21 @@ function updateTidalCardPhase(playlistId, phase) { }, 0); } } - + console.log(`๐ŸŽต Updated Tidal card phase: ${playlistId} -> ${phase}`); } async function openTidalDiscoveryModal(playlistId, playlistData) { console.log(`๐ŸŽต Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`); - + // Create a fake YouTube-style urlHash for the modal system const fakeUrlHash = `tidal_${playlistId}`; - + // Get current Tidal card state to check if discovery is already done or in progress const tidalCardState = tidalPlaylistStates[playlistId]; const isAlreadyDiscovered = tidalCardState && (tidalCardState.phase === 'discovered' || tidalCardState.phase === 'syncing' || tidalCardState.phase === 'sync_complete'); const isCurrentlyDiscovering = tidalCardState && tidalCardState.phase === 'discovering'; - + // Prepare discovery results in the correct format for modal let transformedResults = []; let actualMatches = 0; @@ -13515,10 +13537,10 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { transformedResults = tidalCardState.discovery_results.map((result, index) => { // Check multiple status formats const isFound = result.status === 'found' || - result.status === 'โœ… Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; + result.status === 'โœ… Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; if (isFound) actualMatches++; return { @@ -13538,7 +13560,7 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { }); console.log(`๐ŸŽต Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); } - + // Create YouTube-compatible state structure const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh'; youtubePlaylistStates[fakeUrlHash] = { @@ -13557,37 +13579,37 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { discoveryResults: transformedResults, // Both formats for compatibility discoveryProgress: isAlreadyDiscovered ? 100 : 0 // Frontend format for modal progress display }; - + // Only start discovery if not already discovered AND not currently discovering if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { // Start Tidal discovery process automatically (like sync.py) try { console.log(`๐Ÿ” Starting Tidal discovery for: ${playlistData.name}`); - + const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, { method: 'POST' }); - + const result = await response.json(); - + if (result.error) { console.error('โŒ Error starting Tidal discovery:', result.error); showToast(`Error starting discovery: ${result.error}`, 'error'); return; } - + console.log('โœ… Tidal discovery started, beginning polling...'); - + // Update phase to discovering now that backend discovery is actually started tidalPlaylistStates[playlistId].phase = 'discovering'; updateTidalCardPhase(playlistId, 'discovering'); - + // Update modal phase to match youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; - + // Start polling for progress startTidalDiscoveryPolling(fakeUrlHash, playlistId); - + } catch (error) { console.error('โŒ Error starting Tidal discovery:', error); showToast(`Error starting discovery: ${error.message}`, 'error'); @@ -13603,31 +13625,31 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { } else { console.log('โœ… Using existing results - no need to re-discover'); } - + // Reuse YouTube discovery modal (exact sync.py pattern) openYouTubeDiscoveryModal(fakeUrlHash); } function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { console.log(`๐Ÿ”„ Starting Tidal discovery polling for: ${playlistId}`); - + // Stop any existing polling if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); } - + const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/tidal/discovery/status/${playlistId}`); const status = await response.json(); - + if (status.error) { console.error('โŒ Error polling Tidal discovery status:', status.error); clearInterval(pollInterval); delete activeYouTubePollers[fakeUrlHash]; return; } - + // Transform Tidal results to YouTube modal format first const transformedStatus = { progress: status.progress, @@ -13635,10 +13657,10 @@ function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { spotify_total: status.spotify_total, results: status.results.map((result, index) => { const isFound = result.status === 'found' || - result.status === 'โœ… Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; + result.status === 'โœ… Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; return { index: index, @@ -13656,7 +13678,7 @@ function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { }; }) }; - + // Update fake YouTube state with Tidal discovery results const state = youtubePlaylistStates[fakeUrlHash]; if (state) { @@ -13667,10 +13689,10 @@ function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { state.discovery_results = status.results; // Backend format state.discoveryResults = transformedStatus.results; // Frontend format - for button logic state.phase = status.phase; - + // Update modal with transformed data (reuse YouTube modal update logic) updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); - + // Update Tidal card phase and save discovery results FIRST if (tidalPlaylistStates[playlistId]) { tidalPlaylistStates[playlistId].phase = status.phase; @@ -13679,27 +13701,27 @@ function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { tidalPlaylistStates[playlistId].discovery_progress = status.progress; updateTidalCardPhase(playlistId, status.phase); } - + // Update Tidal card progress AFTER phase update to avoid being overwritten updateTidalCardProgress(playlistId, status); - + console.log(`๐Ÿ”„ Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); } - + // Stop polling when complete if (status.complete) { console.log(`โœ… Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); clearInterval(pollInterval); delete activeYouTubePollers[fakeUrlHash]; } - + } catch (error) { console.error('โŒ Error polling Tidal discovery:', error); clearInterval(pollInterval); delete activeYouTubePollers[fakeUrlHash]; } }, 1000); // Poll every second like YouTube - + // Store poller reference (reuse YouTube poller storage) activeYouTubePollers[fakeUrlHash] = pollInterval; } @@ -13708,35 +13730,35 @@ async function loadTidalPlaylistStatesFromBackend() { // Load all stored Tidal playlist discovery states from backend (similar to YouTube hydration) try { console.log('๐ŸŽต Loading Tidal playlist states from backend...'); - + const response = await fetch('/api/tidal/playlists/states'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch Tidal playlist states'); } - + const data = await response.json(); const states = data.states || []; - + console.log(`๐ŸŽต Found ${states.length} stored Tidal playlist states in backend`); - + if (states.length === 0) { console.log('๐ŸŽต No Tidal playlist states to hydrate'); return; } - + // Apply states to existing playlist cards for (const stateInfo of states) { await applyTidalPlaylistState(stateInfo); } - + // Rehydrate download modals for Tidal playlists in downloading/download_complete phases for (const stateInfo of states) { - if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && + if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { - + const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; - + if (!activeDownloadProcesses[convertedPlaylistId]) { console.log(`๐Ÿ’ง Rehydrating download modal for Tidal playlist: ${stateInfo.playlist_id}`); try { @@ -13746,34 +13768,34 @@ async function loadTidalPlaylistStatesFromBackend() { console.warn(`โš ๏ธ Playlist data not found for rehydration: ${stateInfo.playlist_id}`); continue; } - + // Create the download modal using the Tidal-specific function const spotifyTracks = tidalPlaylistStates[stateInfo.playlist_id]?.discovery_results ?.filter(result => result.spotify_data) ?.map(result => result.spotify_data) || []; - + if (spotifyTracks.length > 0) { await openDownloadMissingModalForTidal( convertedPlaylistId, playlistData.name, spotifyTracks ); - + // Set the modal to running state with the correct batch ID const process = activeDownloadProcesses[convertedPlaylistId]; if (process) { process.status = 'running'; process.batchId = stateInfo.download_process_id; - + // Update UI to running state const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; - + // Start polling for this process startModalDownloadPolling(convertedPlaylistId); - + console.log(`โœ… Rehydrated Tidal download modal for batch ${stateInfo.download_process_id}`); } } else { @@ -13785,9 +13807,9 @@ async function loadTidalPlaylistStatesFromBackend() { } } } - + console.log('โœ… Tidal playlist states loaded and applied'); - + } catch (error) { console.error('โŒ Error loading Tidal playlist states:', error); } @@ -13795,17 +13817,17 @@ async function loadTidalPlaylistStatesFromBackend() { async function applyTidalPlaylistState(stateInfo) { const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; - + try { console.log(`๐ŸŽต Applying saved state for Tidal playlist: ${playlist_id}, Phase: ${phase}`); - + // Find the playlist data from the loaded playlists const playlistData = tidalPlaylists.find(p => p.id === playlist_id); if (!playlistData) { console.warn(`โš ๏ธ Playlist data not found for state ${playlist_id} - skipping`); return; } - + // Update local state if (!tidalPlaylistStates[playlist_id]) { // Initialize state if it doesn't exist @@ -13814,7 +13836,7 @@ async function applyTidalPlaylistState(stateInfo) { phase: 'fresh' }; } - + // Update with backend state tidalPlaylistStates[playlist_id].phase = phase; tidalPlaylistStates[playlist_id].discovery_progress = discovery_progress; @@ -13823,7 +13845,7 @@ async function applyTidalPlaylistState(stateInfo) { tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; tidalPlaylistStates[playlist_id].download_process_id = download_process_id; tidalPlaylistStates[playlist_id].playlist = playlistData; // Ensure playlist data is set - + // Fetch full discovery results for non-fresh playlists (matching YouTube pattern) if (phase !== 'fresh' && phase !== 'discovering') { try { @@ -13832,7 +13854,7 @@ async function applyTidalPlaylistState(stateInfo) { if (stateResponse.ok) { const fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full Tidal state with ${fullState.discovery_results?.length || 0} discovery results`); - + // Store full discovery results in local state (matching YouTube pattern) if (fullState.discovery_results && tidalPlaylistStates[playlist_id]) { tidalPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; @@ -13849,10 +13871,10 @@ async function applyTidalPlaylistState(stateInfo) { console.warn(`โš ๏ธ Error fetching full discovery results for Tidal playlist ${playlistData.name}:`, error.message); } } - + // Update the card UI to reflect the saved state updateTidalCardPhase(playlist_id, phase); - + // Update card progress if we have discovery results if (phase === 'discovered' && tidalPlaylistStates[playlist_id]) { const progressInfo = { @@ -13872,9 +13894,9 @@ async function applyTidalPlaylistState(stateInfo) { const fakeUrlHash = `tidal_${playlist_id}`; startTidalSyncPolling(fakeUrlHash); } - + console.log(`โœ… Applied saved state for Tidal playlist: ${playlist_id} -> ${phase}`); - + } catch (error) { console.error(`โŒ Error applying Tidal playlist state for ${playlist_id}:`, error); } @@ -13883,21 +13905,21 @@ async function applyTidalPlaylistState(stateInfo) { function updateTidalCardProgress(playlistId, progress) { const state = tidalPlaylistStates[playlistId]; if (!state) return; - + const card = document.getElementById(`tidal-card-${playlistId}`); if (!card) return; - + const progressElement = card.querySelector('.playlist-card-progress'); if (!progressElement) return; - + const total = progress.spotify_total || 0; const matches = progress.spotify_matches || 0; const failed = total - matches; const percentage = total > 0 ? Math.round((matches / total) * 100) : 0; - + progressElement.textContent = `โ™ช ${total} / โœ“ ${matches} / โœ— ${failed} / ${percentage}%`; progressElement.classList.remove('hidden'); // Show progress during discovery - + console.log('๐ŸŽต Updated Tidal card progress:', playlistId, `${matches}/${total} (${percentage}%)`); } @@ -13908,36 +13930,36 @@ function updateTidalCardProgress(playlistId, progress) { async function startTidalPlaylistSync(urlHash) { try { console.log('๐ŸŽต Starting Tidal playlist sync:', urlHash); - + const state = youtubePlaylistStates[urlHash]; if (!state || !state.is_tidal_playlist) { console.error('โŒ Invalid Tidal playlist state for sync'); return; } - + const playlistId = state.tidal_playlist_id; const response = await fetch(`/api/tidal/sync/start/${playlistId}`, { method: 'POST' }); - + const result = await response.json(); - + if (result.error) { showToast(`Error starting sync: ${result.error}`, 'error'); return; } - + // Update card and modal to syncing phase updateTidalCardPhase(playlistId, 'syncing'); - + // Update modal buttons if modal is open updateTidalModalButtons(urlHash, 'syncing'); - + // Start sync polling startTidalSyncPolling(urlHash); - + showToast('Tidal playlist sync started!', 'success'); - + } catch (error) { console.error('โŒ Error starting Tidal sync:', error); showToast(`Error starting sync: ${error.message}`, 'error'); @@ -13949,7 +13971,7 @@ function startTidalSyncPolling(urlHash) { if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); } - + const state = youtubePlaylistStates[urlHash]; const playlistId = state.tidal_playlist_id; @@ -13958,25 +13980,25 @@ function startTidalSyncPolling(urlHash) { try { const response = await fetch(`/api/tidal/sync/status/${playlistId}`); const status = await response.json(); - + if (status.error) { console.error('โŒ Error polling Tidal sync status:', status.error); clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; return; } - + // Update card progress with sync stats updateTidalCardSyncProgress(playlistId, status.progress); - + // Update modal sync display if open updateTidalModalSyncProgress(urlHash, status.progress); - + // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - + // Update both states to sync_complete if (tidalPlaylistStates[playlistId]) { tidalPlaylistStates[playlistId].phase = 'sync_complete'; @@ -13984,19 +14006,19 @@ function startTidalSyncPolling(urlHash) { if (youtubePlaylistStates[urlHash]) { youtubePlaylistStates[urlHash].phase = 'sync_complete'; } - + // Update card phase to sync complete updateTidalCardPhase(playlistId, 'sync_complete'); - + // Update modal buttons updateTidalModalButtons(urlHash, 'sync_complete'); - + console.log('โœ… Tidal sync complete:', urlHash); showToast('Tidal playlist sync complete!', 'success'); } else if (status.sync_status === 'error') { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - + // Update both states to discovered (revert on error) if (tidalPlaylistStates[playlistId]) { tidalPlaylistStates[playlistId].phase = 'discovered'; @@ -14004,14 +14026,14 @@ function startTidalSyncPolling(urlHash) { if (youtubePlaylistStates[urlHash]) { youtubePlaylistStates[urlHash].phase = 'discovered'; } - + // Revert to discovered phase on error updateTidalCardPhase(playlistId, 'discovered'); updateTidalModalButtons(urlHash, 'discovered'); - + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); } - + } catch (error) { console.error('โŒ Error polling Tidal sync:', error); if (activeYouTubePollers[urlHash]) { @@ -14032,37 +14054,37 @@ function startTidalSyncPolling(urlHash) { async function cancelTidalSync(urlHash) { try { console.log('โŒ Cancelling Tidal sync:', urlHash); - + const state = youtubePlaylistStates[urlHash]; if (!state || !state.is_tidal_playlist) { console.error('โŒ Invalid Tidal playlist state'); return; } - + const playlistId = state.tidal_playlist_id; const response = await fetch(`/api/tidal/sync/cancel/${playlistId}`, { method: 'POST' }); - + const result = await response.json(); - + if (result.error) { showToast(`Error cancelling sync: ${result.error}`, 'error'); return; } - + // Stop polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - + // Revert to discovered phase updateTidalCardPhase(playlistId, 'discovered'); updateTidalModalButtons(urlHash, 'discovered'); - + showToast('Tidal sync cancelled', 'info'); - + } catch (error) { console.error('โŒ Error cancelling Tidal sync:', error); showToast(`Error cancelling sync: ${error.message}`, 'error'); @@ -14072,15 +14094,15 @@ async function cancelTidalSync(urlHash) { function updateTidalCardSyncProgress(playlistId, progress) { const state = tidalPlaylistStates[playlistId]; if (!state || !state.playlist || !progress) return; - + // Save the progress for later restoration state.lastSyncProgress = progress; - + const card = document.getElementById(`tidal-card-${playlistId}`); if (!card) return; - + const progressElement = card.querySelector('.playlist-card-progress'); - + // Build clean status counter HTML exactly like YouTube cards let statusCounterHTML = ''; if (progress && progress.total_tracks > 0) { @@ -14089,7 +14111,7 @@ function updateTidalCardSyncProgress(playlistId, progress) { const total = progress.total_tracks || 0; const processed = matched + failed; const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - + statusCounterHTML = `
โ™ช ${total} @@ -14101,49 +14123,49 @@ function updateTidalCardSyncProgress(playlistId, progress) {
`; } - + // Only update if we have valid sync progress, otherwise preserve existing discovery results if (statusCounterHTML) { progressElement.innerHTML = statusCounterHTML; } - + console.log(`๐ŸŽต Updated Tidal card sync progress: โ™ช ${progress?.total_tracks || 0} / โœ“ ${progress?.matched_tracks || 0} / โœ— ${progress?.failed_tracks || 0}`); } function updateTidalModalSyncProgress(urlHash, progress) { const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`); if (!statusDisplay || !progress) return; - + console.log(`๐Ÿ“Š Updating Tidal modal sync progress for ${urlHash}:`, progress); - + // Update individual counters exactly like YouTube sync const totalEl = document.getElementById(`tidal-total-${urlHash}`); const matchedEl = document.getElementById(`tidal-matched-${urlHash}`); const failedEl = document.getElementById(`tidal-failed-${urlHash}`); const percentageEl = document.getElementById(`tidal-percentage-${urlHash}`); - + const total = progress.total_tracks || 0; const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; - + if (totalEl) totalEl.textContent = total; if (matchedEl) matchedEl.textContent = matched; if (failedEl) failedEl.textContent = failed; - + // Calculate percentage like YouTube sync if (total > 0) { const processed = matched + failed; const percentage = Math.round((processed / total) * 100); if (percentageEl) percentageEl.textContent = percentage; } - + console.log(`๐Ÿ“Š Tidal modal updated: โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${Math.round((matched + failed) / total * 100)}%)`); } function updateTidalModalButtons(urlHash, phase) { const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (!modal) return; - + const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { footerLeft.innerHTML = getModalActionButtons(urlHash, phase); @@ -14194,7 +14216,7 @@ async function startTidalDownloadMissing(urlHash) { }); } } - + if (spotifyTracks.length === 0) { showToast('No Spotify matches found for download', 'error'); return; @@ -14206,19 +14228,19 @@ async function startTidalDownloadMissing(urlHash) { // Store reference for card navigation (same as YouTube) state.convertedSpotifyPlaylistId = virtualPlaylistId; - + // Close the discovery modal if it's open (same as YouTube) const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (discoveryModal) { discoveryModal.classList.add('hidden'); console.log('๐Ÿ”„ Closed Tidal discovery modal to show download modal'); } - + // Open download missing tracks modal for Tidal playlist await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); - + // Phase will change to 'downloading' when user clicks "Begin Analysis" button - + } catch (error) { console.error('โŒ Error starting download missing tracks:', error); showToast(`Error starting downloads: ${error.message}`, 'error'); @@ -14241,19 +14263,19 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, } console.log(`๐Ÿ“ฅ Opening Download Missing Tracks modal for Tidal playlist: ${virtualPlaylistId}`); - + // Create virtual playlist object for compatibility with existing modal logic const virtualPlaylist = { id: virtualPlaylistId, name: playlistName, track_count: spotifyTracks.length }; - + // Store the tracks in the cache for the modal to use playlistTrackCache[virtualPlaylistId] = spotifyTracks; currentPlaylistTracks = spotifyTracks; currentModalPlaylistId = virtualPlaylistId; - + let modal = document.createElement('div'); modal.id = `download-missing-modal-${virtualPlaylistId}`; modal.className = 'download-missing-modal'; @@ -14272,14 +14294,14 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, // Generate hero section with dynamic source detection (same as YouTube/Beatport) const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : - virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : - virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : + virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; const heroContext = { type: 'playlist', @@ -14574,13 +14596,13 @@ function initializeSyncPage() { if (startSyncBtn) { startSyncBtn.addEventListener('click', startSequentialSync); } - + // Logic for the YouTube parse button const youtubeParseBtn = document.getElementById('youtube-parse-btn'); if (youtubeParseBtn) { youtubeParseBtn.addEventListener('click', parseYouTubePlaylist); } - + // Logic for YouTube URL input (Enter key support) const youtubeUrlInput = document.getElementById('youtube-url-input'); if (youtubeUrlInput) { @@ -14668,19 +14690,19 @@ async function handleDbUpdateButtonClick() { async function handleWishlistButtonClick() { try { const playlistId = 'wishlist'; - + console.log('๐ŸŽต [Wishlist Button] User clicked wishlist button - checking server state first'); - + // STEP 1: Always check server state first to detect any active wishlist processes const response = await fetch('/api/active-processes'); if (!response.ok) { throw new Error(`Failed to fetch active processes: ${response.status}`); } - + const data = await response.json(); const processes = data.active_processes || []; const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); - + // STEP 2: Handle active server process - show current state immediately if (serverWishlistProcess) { console.log('๐ŸŽฏ [Wishlist Button] Server has active wishlist process:', { @@ -14689,17 +14711,17 @@ async function handleWishlistButtonClick() { auto_initiated: serverWishlistProcess.auto_initiated, should_show: serverWishlistProcess.should_show_modal }); - + // Clear any user-closed state since user explicitly requested to see modal WishlistModalState.clearUserClosed(); - + // Check if we need to create/sync the frontend modal const clientWishlistProcess = activeDownloadProcesses[playlistId]; - const needsRehydration = !clientWishlistProcess || + const needsRehydration = !clientWishlistProcess || clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || !clientWishlistProcess.modalElement || !document.body.contains(clientWishlistProcess.modalElement); - + if (needsRehydration) { console.log('๐Ÿ”„ [Wishlist Button] Frontend modal needs sync/creation'); await rehydrateModal(serverWishlistProcess, true); // user-requested = true @@ -14710,25 +14732,25 @@ async function handleWishlistButtonClick() { } return; } - + // STEP 3: No active server process - check wishlist count and create fresh modal console.log('๐Ÿ“ญ [Wishlist Button] No active server process, checking wishlist content'); - + const countResponse = await fetch('/api/wishlist/count'); if (!countResponse.ok) { throw new Error(`Failed to fetch wishlist count: ${countResponse.status}`); } - + const countData = await countResponse.json(); if (countData.count === 0) { showToast('Wishlist is empty. No tracks to download.', 'info'); return; } - + // STEP 4: Open wishlist overview modal (NEW - category selection) console.log(`๐Ÿ†• [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`); await openWishlistOverviewModal(); - + } catch (error) { console.error('โŒ [Wishlist Button] Error handling wishlist button click:', error); showToast(`Error opening wishlist: ${error.message}`, 'error'); @@ -14745,39 +14767,39 @@ async function cleanupWishlist(playlistId) { "This is a safe operation that only removes tracks you already have. " + "Continue with cleanup?" ); - + if (!confirmed) { return; } - + // Disable the cleanup button during the operation const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); if (cleanupBtn) { cleanupBtn.disabled = true; cleanupBtn.textContent = '๐Ÿงน Cleaning...'; } - + const response = await fetch('/api/wishlist/cleanup', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); - + const result = await response.json(); - + if (result.success) { const removedCount = result.removed_count || 0; const processedCount = result.processed_count || 0; - + if (removedCount > 0) { showToast(`Wishlist cleanup completed: ${removedCount} tracks removed (${processedCount} checked)`, 'success'); - + // Refresh the modal content to show updated state setTimeout(() => { openDownloadMissingWishlistModal(); }, 500); - + // Update the wishlist count in the main dashboard await updateWishlistCount(); } else { @@ -14786,7 +14808,7 @@ async function cleanupWishlist(playlistId) { } else { showToast(`Error cleaning wishlist: ${result.error}`, 'error'); } - + } catch (error) { console.error('Error cleaning wishlist:', error); showToast(`Error cleaning wishlist: ${error.message}`, 'error'); @@ -14809,18 +14831,18 @@ async function clearWishlist(playlistId) { "This will permanently remove all failed tracks from the wishlist. " + "This action cannot be undone." ); - + if (!confirmed) { return; } - + // Disable the clear button during the operation const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`); if (clearBtn) { clearBtn.disabled = true; clearBtn.textContent = 'Clearing...'; } - + // Call the clear API endpoint const response = await fetch('/api/wishlist/clear', { method: 'POST', @@ -14828,22 +14850,22 @@ async function clearWishlist(playlistId) { 'Content-Type': 'application/json' } }); - + const result = await response.json(); - + if (result.success) { showToast('Wishlist cleared successfully', 'success'); - + // Close the modal since there are no more tracks closeDownloadMissingModal(playlistId); - + // Update the wishlist count in the main dashboard await updateWishlistCount(); - + } else { showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error'); } - + } catch (error) { console.error('Error clearing wishlist:', error); showToast(`Error clearing wishlist: ${error.message}`, 'error'); @@ -14991,7 +15013,7 @@ function handleBeatportCategoryClick(category) { console.log(`๐ŸŽต Beatport category clicked: ${category}`); // Only handle genres category now - homepage has direct chart buttons - switch(category) { + switch (category) { case 'genres': showBeatportSubView('genres'); loadBeatportGenres(); // Load genres dynamically @@ -15645,7 +15667,7 @@ async function getRebuildPageTrackData(trackDataKey) { // Hook into the loadBeatportTop10Lists function to cache track data const originalLoadBeatportTop10Lists = window.loadBeatportTop10Lists; if (originalLoadBeatportTop10Lists) { - window.loadBeatportTop10Lists = async function() { + window.loadBeatportTop10Lists = async function () { const result = await originalLoadBeatportTop10Lists.apply(this, arguments); // If the load was successful, we can potentially cache the track data @@ -17471,24 +17493,24 @@ async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartTyp async function parseYouTubePlaylist() { const urlInput = document.getElementById('youtube-url-input'); const url = urlInput.value.trim(); - + if (!url) { showToast('Please enter a YouTube playlist URL', 'error'); return; } - + // Validate URL format if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) { showToast('Please enter a valid YouTube playlist URL', 'error'); return; } - + try { console.log('๐ŸŽฌ Parsing YouTube playlist:', url); - + // Create card immediately in 'fresh' phase createYouTubeCard(url, 'fresh'); - + // Parse playlist via API const response = await fetch('/api/youtube/parse', { method: 'POST', @@ -17497,27 +17519,27 @@ async function parseYouTubePlaylist() { }, body: JSON.stringify({ url: url }) }); - + const result = await response.json(); - + if (result.error) { showToast(`Error parsing YouTube playlist: ${result.error}`, 'error'); removeYouTubeCard(url); return; } - + console.log('โœ… YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`); - + // Update card with parsed data and stay in 'fresh' phase updateYouTubeCardData(result.url_hash, result); updateYouTubeCardPhase(result.url_hash, 'fresh'); - + // Clear input urlInput.value = ''; - + // Show success message showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success'); - + } catch (error) { console.error('โŒ Error parsing YouTube playlist:', error); showToast(`Error parsing YouTube playlist: ${error.message}`, 'error'); @@ -17528,15 +17550,15 @@ async function parseYouTubePlaylist() { function createYouTubeCard(url, phase = 'fresh') { const container = document.getElementById('youtube-playlist-container'); const placeholder = container.querySelector('.playlist-placeholder'); - + // Remove placeholder if it exists if (placeholder) { placeholder.style.display = 'none'; } - + // Create temporary URL hash for initial card const tempHash = btoa(url).substring(0, 8); - + const cardHtml = `
โ–ถ
@@ -17553,9 +17575,9 @@ function createYouTubeCard(url, phase = 'fresh') {
`; - + container.insertAdjacentHTML('beforeend', cardHtml); - + // Store temporary state youtubePlaylistStates[tempHash] = { phase: phase, @@ -17563,7 +17585,7 @@ function createYouTubeCard(url, phase = 'fresh') { cardElement: document.getElementById(`youtube-card-${tempHash}`), tempHash: tempHash }; - + console.log('๐Ÿƒ Created YouTube card for URL:', url); } @@ -17578,56 +17600,56 @@ function updateYouTubeCardData(urlHash, playlistData) { delete youtubePlaylistStates[tempState.tempHash]; youtubePlaylistStates[urlHash] = tempState; state = tempState; - + // Update card ID if (state.cardElement) { state.cardElement.id = `youtube-card-${urlHash}`; } } } - + if (!state || !state.cardElement) { console.error('โŒ Could not find YouTube card for hash:', urlHash); return; } - + const card = state.cardElement; - + // Update card content const nameElement = card.querySelector('.playlist-card-name'); const trackCountElement = card.querySelector('.playlist-card-track-count'); - + nameElement.textContent = playlistData.name; trackCountElement.textContent = `${playlistData.tracks.length} tracks`; - + // Store playlist data state.playlist = playlistData; state.urlHash = urlHash; - + // Add click handler for card and action button const handleCardClick = () => handleYouTubeCardClick(urlHash); const actionBtn = card.querySelector('.playlist-card-action-btn'); - + card.addEventListener('click', handleCardClick); actionBtn.addEventListener('click', (e) => { e.stopPropagation(); // Prevent card click handleCardClick(); }); - + console.log('๐Ÿƒ Updated YouTube card data:', playlistData.name); } function updateYouTubeCardPhase(urlHash, phase) { const state = youtubePlaylistStates[urlHash]; if (!state || !state.cardElement) return; - + const card = state.cardElement; const phaseTextElement = card.querySelector('.playlist-card-phase-text'); const actionBtn = card.querySelector('.playlist-card-action-btn'); const progressElement = card.querySelector('.playlist-card-progress'); - + state.phase = phase; - + switch (phase) { case 'fresh': phaseTextElement.textContent = 'Ready to discover'; @@ -17636,7 +17658,7 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.add('hidden'); break; - + case 'discovering': phaseTextElement.textContent = 'Discovering...'; phaseTextElement.style.color = '#ffa500'; // Orange @@ -17644,7 +17666,7 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.remove('hidden'); break; - + case 'discovered': phaseTextElement.textContent = 'Discovery Complete'; phaseTextElement.style.color = '#1db954'; // Green @@ -17652,7 +17674,7 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.add('hidden'); break; - + case 'syncing': phaseTextElement.textContent = 'Syncing...'; phaseTextElement.style.color = '#ffa500'; // Orange @@ -17660,7 +17682,7 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.remove('hidden'); break; - + case 'sync_complete': phaseTextElement.textContent = 'Sync Complete'; phaseTextElement.style.color = '#1db954'; // Green @@ -17668,7 +17690,7 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.add('hidden'); break; - + case 'downloading': phaseTextElement.textContent = 'Downloading...'; phaseTextElement.style.color = '#ffa500'; // Orange @@ -17676,7 +17698,7 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.remove('hidden'); break; - + case 'download_complete': phaseTextElement.textContent = 'Download Complete'; phaseTextElement.style.color = '#1db954'; // Green @@ -17685,14 +17707,14 @@ function updateYouTubeCardPhase(urlHash, phase) { progressElement.classList.add('hidden'); break; } - + console.log('๐Ÿƒ Updated YouTube card phase:', urlHash, phase); } function handleYouTubeCardClick(urlHash) { const state = youtubePlaylistStates[urlHash]; if (!state) return; - + switch (state.phase) { case 'fresh': // First click: Start discovery and open modal @@ -17701,7 +17723,7 @@ function handleYouTubeCardClick(urlHash) { startYouTubeDiscovery(urlHash); openYouTubeDiscoveryModal(urlHash); break; - + case 'discovering': case 'discovered': case 'syncing': @@ -17710,7 +17732,7 @@ function handleYouTubeCardClick(urlHash) { console.log('๐ŸŽฌ Opening YouTube discovery modal:', urlHash); openYouTubeDiscoveryModal(urlHash); break; - + case 'downloading': case 'download_complete': // Open download missing tracks modal @@ -17727,7 +17749,7 @@ function handleYouTubeCardClick(urlHash) { if (fullState.discovery_results) { state.discoveryResults = fullState.discovery_results; console.log(`โœ… Loaded ${state.discoveryResults.length} discovery results`); - + // Now open the modal with the loaded data const playlistName = state.playlist.name; const spotifyTracks = state.discoveryResults @@ -17762,17 +17784,17 @@ function handleYouTubeCardClick(urlHash) { function updateYouTubeCardProgress(urlHash, progress) { const state = youtubePlaylistStates[urlHash]; if (!state || !state.cardElement) return; - + const card = state.cardElement; const progressElement = card.querySelector('.playlist-card-progress'); - + const total = progress.spotify_total || 0; const matches = progress.spotify_matches || 0; const failed = total - matches; const percentage = total > 0 ? Math.round((matches / total) * 100) : 0; - + progressElement.textContent = `โ™ช ${total} / โœ“ ${matches} / โœ— ${failed} / ${percentage}%`; - + console.log('๐Ÿƒ Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`); } @@ -17780,7 +17802,7 @@ function removeYouTubeCard(url) { const state = Object.values(youtubePlaylistStates).find(s => s.url === url); if (state && state.cardElement) { state.cardElement.remove(); - + // Remove from state if (state.urlHash) { delete youtubePlaylistStates[state.urlHash]; @@ -17788,12 +17810,12 @@ function removeYouTubeCard(url) { delete youtubePlaylistStates[state.tempHash]; } } - + // Show placeholder if no cards left const container = document.getElementById('youtube-playlist-container'); const cards = container.querySelectorAll('.youtube-playlist-card'); const placeholder = container.querySelector('.playlist-placeholder'); - + if (cards.length === 0 && placeholder) { placeholder.style.display = 'block'; } @@ -17802,24 +17824,24 @@ function removeYouTubeCard(url) { async function startYouTubeDiscovery(urlHash) { try { console.log('๐Ÿ” Starting YouTube Spotify discovery for:', urlHash); - + const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, { method: 'POST' }); - + const result = await response.json(); - + if (result.error) { showToast(`Error starting discovery: ${result.error}`, 'error'); return; } - + // Start polling for progress startYouTubeDiscoveryPolling(urlHash); - + // Open discovery modal openYouTubeDiscoveryModal(urlHash); - + } catch (error) { console.error('โŒ Error starting YouTube discovery:', error); showToast(`Error starting discovery: ${error.message}`, 'error'); @@ -17831,22 +17853,22 @@ function startYouTubeDiscoveryPolling(urlHash) { if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); } - + const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/youtube/discovery/status/${urlHash}`); const status = await response.json(); - + if (status.error) { console.error('โŒ Error polling YouTube discovery status:', status.error); clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; return; } - + // Update card progress updateYouTubeCardProgress(urlHash, status); - + // Store discovery results and progress in state const state = youtubePlaylistStates[urlHash]; if (state) { @@ -17854,32 +17876,32 @@ function startYouTubeDiscoveryPolling(urlHash) { state.discoveryProgress = status.progress || 0; state.spotifyMatches = status.spotify_matches || 0; } - + // Update modal if open updateYouTubeDiscoveryModal(urlHash, status); - + // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - + // Update card phase to discovered updateYouTubeCardPhase(urlHash, 'discovered'); - + // Update modal buttons to show sync and download buttons updateYouTubeModalButtons(urlHash, 'discovered'); - + console.log('โœ… YouTube discovery complete:', urlHash); showToast('YouTube discovery complete!', 'success'); } - + } catch (error) { console.error('โŒ Error polling YouTube discovery:', error); clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; } }, 1000); - + activeYouTubePollers[urlHash] = pollInterval; } @@ -17932,14 +17954,14 @@ function openYouTubeDiscoveryModal(urlHash) { const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const modalTitle = isTidal ? '๐ŸŽต Tidal Playlist Discovery' : - isBeatport ? '๐ŸŽต Beatport Chart Discovery' : - isListenBrainz ? '๐ŸŽต ListenBrainz Playlist Discovery' : - '๐ŸŽต YouTube Playlist Discovery'; + isBeatport ? '๐ŸŽต Beatport Chart Discovery' : + isListenBrainz ? '๐ŸŽต ListenBrainz Playlist Discovery' : + '๐ŸŽต YouTube Playlist Discovery'; const sourceLabel = isTidal ? 'Tidal' : - isBeatport ? 'Beatport' : - isListenBrainz ? 'LB' : - 'YT'; - + isBeatport ? 'Beatport' : + isListenBrainz ? 'LB' : + 'YT'; + const modalHtml = ` `; - + // Add modal to DOM document.body.insertAdjacentHTML('beforeend', modalHtml); modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - + // Store modal reference state.modalElement = modal; - + // Set initial progress if we have discovery results if (state.discoveryResults && state.discoveryResults.length > 0) { const progressData = { @@ -18093,12 +18115,12 @@ function getModalActionButtons(urlHash, phase, state = null) { const isTidal = state && state.is_tidal_playlist; const isBeatport = state && state.is_beatport_playlist; const isListenBrainz = state && state.is_listenbrainz_playlist; - + // Validate data availability for buttons (support both naming conventions) const hasDiscoveryResults = state && ((state.discoveryResults && state.discoveryResults.length > 0) || (state.discovery_results && state.discovery_results.length > 0)); const hasSpotifyMatches = state && ((state.spotifyMatches > 0) || (state.spotify_matches > 0)); const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId; - + switch (phase) { case 'fresh': case 'discovering': @@ -18123,9 +18145,9 @@ function getModalActionButtons(urlHash, phase, state = null) { if (!hasDiscoveryResults) { return ``; } - + let buttons = ''; - + // Only show sync button if there are Spotify matches if (hasSpotifyMatches) { if (isListenBrainz) { @@ -18152,13 +18174,13 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } } - + if (!buttons) { buttons = ``; } - + return buttons; - + case 'syncing': if (isListenBrainz) { return ` @@ -18209,7 +18231,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
`; } - + case 'sync_complete': let syncCompleteButtons = ''; @@ -18251,7 +18273,7 @@ function getModalActionButtons(urlHash, phase, state = null) { } return syncCompleteButtons; - + default: return ''; } @@ -18360,17 +18382,17 @@ function formatDuration(durationMs) { function generateDiscoveryActionButton(result, identifier, platform) { // Show fix button for not_found, error, or any non-found status const isNotFound = result.status === 'not_found' || - result.status_class === 'not-found' || - result.status === 'โŒ Not Found' || - result.status === 'Not Found'; + result.status_class === 'not-found' || + result.status === 'โŒ Not Found' || + result.status === 'Not Found'; const isError = result.status === 'error' || - result.status_class === 'error' || - result.status === 'โŒ Error'; + result.status_class === 'error' || + result.status === 'โŒ Error'; const isFound = result.status === 'found' || - result.status_class === 'found' || - result.status === 'โœ… Found'; + result.status_class === 'found' || + result.status === 'โœ… Found'; if (isNotFound || isError) { return `