// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality // Global state management let currentPage = 'dashboard'; let currentTrack = null; let isPlaying = false; let mediaPlayerExpanded = false; let donationAddressesVisible = false; let searchResults = []; let currentStream = { status: 'stopped', progress: 0, track: null }; // Streaming state management (enhanced functionality) let streamStatusPoller = null; let audioPlayer = null; let streamPollingRetries = 0; let streamPollingInterval = 1000; // Start with 1-second polling const maxStreamPollingRetries = 10; let allSearchResults = []; let currentFilterType = 'all'; let currentFilterFormat = 'all'; let currentSortBy = 'quality_score'; let isSortReversed = false; let searchAbortController = null; let dbStatsInterval = null; let dbUpdateStatusInterval = null; let wishlistCountInterval = null; // --- Add these globals for the Sync Page --- 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 activeDownloadProcesses = {}; let sequentialSyncManager = null; // --- YouTube Playlist State Management --- let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId // --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) --- let tidalPlaylists = []; let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases let tidalPlaylistsLoaded = false; // --- Wishlist Modal Persistence State Management --- const WishlistModalState = { // Track if wishlist modal was visible before page refresh setVisible: function() { localStorage.setItem('wishlist_modal_visible', 'true'); console.log('๐Ÿ“ฑ [Modal State] Wishlist modal marked as visible in localStorage'); }, setHidden: function() { localStorage.setItem('wishlist_modal_visible', 'false'); console.log('๐Ÿ“ฑ [Modal State] Wishlist modal marked as hidden in localStorage'); }, 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() { 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() { localStorage.setItem('wishlist_modal_user_closed', 'true'); console.log('๐Ÿ“ฑ [Modal State] User manually closed wishlist modal during auto-processing'); }, clearUserClosed: function() { localStorage.removeItem('wishlist_modal_user_closed'); console.log('๐Ÿ“ฑ [Modal State] Cleared user closed state'); }, wasUserClosed: function() { const closed = localStorage.getItem('wishlist_modal_user_closed') === 'true'; console.log(`๐Ÿ“ฑ [Modal State] Checking if user closed modal: ${closed}`); return closed; } }; // Sequential Sync Manager Class class SequentialSyncManager { constructor() { this.queue = []; this.currentIndex = 0; this.isRunning = false; this.startTime = null; } start(playlistIds) { if (this.isRunning) { console.warn('Sequential sync already running'); return; } // Convert playlist IDs to ordered array (maintain display order) this.queue = Array.from(playlistIds); this.currentIndex = 0; this.isRunning = true; this.startTime = Date.now(); console.log(`๐Ÿš€ Starting sequential sync for ${this.queue.length} playlists:`, this.queue); this.updateUI(); this.syncNext(); } async syncNext() { if (this.currentIndex >= this.queue.length) { this.complete(); return; } const playlistId = this.queue[this.currentIndex]; const playlist = spotifyPlaylists.find(p => p.id === playlistId); console.log(`๐Ÿ”„ Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`); this.updateUI(); 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'); } // Move to next playlist this.currentIndex++; setTimeout(() => this.syncNext(), 1000); // Small delay between syncs } async waitForSyncCompletion(playlistId) { return new Promise((resolve) => { // Monitor the existing sync poller for completion const checkCompletion = () => { if (!activeSyncPollers[playlistId]) { // Poller stopped = sync completed resolve(); return; } // Check again in 1 second setTimeout(checkCompletion, 1000); }; checkCompletion(); }); } complete() { 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'); } 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'); } updateUI() { const startSyncBtn = document.getElementById('start-sync-btn'); const selectionInfo = document.getElementById('selection-info'); if (!this.isRunning) { // Reset to normal state if (startSyncBtn) { startSyncBtn.textContent = 'Start Sync'; startSyncBtn.disabled = selectedPlaylists.size === 0; } if (selectionInfo) { const count = selectedPlaylists.size; selectionInfo.textContent = count === 0 ? 'Select playlists to sync' : `${count} playlist${count > 1 ? 's' : ''} selected`; } } else { // Show sequential sync status if (startSyncBtn) { startSyncBtn.textContent = 'Cancel Sequential Sync'; startSyncBtn.disabled = false; } if (selectionInfo) { const current = this.currentIndex + 1; const total = this.queue.length; const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]); selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`; } } } } // API endpoints const API = { status: '/status', config: '/config', settings: '/api/settings', testConnection: '/api/test-connection', playlists: '/api/playlists', sync: '/api/sync', search: '/api/search', artists: '/api/artists', activity: '/api/activity', stream: { start: '/api/stream/start', status: '/api/stream/status', toggle: '/api/stream/toggle', stop: '/api/stream/stop' } }; // =============================== // INITIALIZATION // =============================== document.addEventListener('DOMContentLoaded', function() { console.log('SoulSync WebUI initializing...'); // Initialize components initializeNavigation(); initializeMediaPlayer(); initializeDonationWidget(); initializeSyncPage(); // Start periodic updates updateServiceStatus(); setInterval(updateServiceStatus, 5000); // Every 5 seconds // Load initial data loadInitialData(); // Handle window resize to re-check track title scrolling window.addEventListener('resize', function() { if (currentTrack) { const trackTitleElement = document.getElementById('track-title'); const trackTitle = currentTrack.title || 'Unknown Track'; setTimeout(() => { checkAndEnableScrolling(trackTitleElement, trackTitle); }, 100); // Small delay to allow layout to settle } }); console.log('SoulSync WebUI initialized successfully!'); }); // =============================== // NAVIGATION SYSTEM // =============================== function initializeNavigation() { const navButtons = document.querySelectorAll('.nav-button'); navButtons.forEach(button => { button.addEventListener('click', () => { const page = button.getAttribute('data-page'); navigateToPage(page); }); }); } function navigateToPage(pageId) { if (pageId === currentPage) return; // Update navigation buttons document.querySelectorAll('.nav-button').forEach(btn => { btn.classList.remove('active'); }); document.querySelector(`[data-page="${pageId}"]`).classList.add('active'); // Update pages document.querySelectorAll('.page').forEach(page => { page.classList.remove('active'); }); document.getElementById(`${pageId}-page`).classList.add('active'); currentPage = pageId; // Load page-specific data loadPageData(pageId); } // REPLACE your old loadPageData function with this one: // REPLACE your old loadPageData function with this corrected one async function loadPageData(pageId) { try { // Stop any active polling when navigating away stopDbStatsPolling(); stopDbUpdatePolling(); stopWishlistCountPolling(); switch (pageId) { case 'dashboard': stopDownloadPolling(); await loadDashboardData(); break; case 'sync': stopDownloadPolling(); initializeSyncPage(); await loadSyncData(); break; case 'downloads': initializeSearch(); initializeFilters(); await loadDownloadsData(); break; case 'artists': stopDownloadPolling(); await loadArtistsData(); break; case 'settings': initializeSettings(); stopDownloadPolling(); await loadSettingsData(); break; } } catch (error) { console.error(`Error loading ${pageId} data:`, error); showToast(`Failed to load ${pageId} data`, 'error'); } } // =============================== // SERVICE STATUS MONITORING // =============================== async function updateServiceStatus() { try { const response = await fetch(API.status); const data = await response.json(); // Update sidebar status indicators updateStatusIndicator('spotify', data.spotify); updateStatusIndicator('media-server', data.media_server); updateStatusIndicator('soulseek', data.soulseek); // Update media server name const serverName = data.active_media_server === 'plex' ? 'Plex' : 'Jellyfin'; document.getElementById('media-server-name').textContent = serverName; } catch (error) { console.error('Error fetching status:', error); // Set all to disconnected on error updateStatusIndicator('spotify', false); updateStatusIndicator('media-server', false); updateStatusIndicator('soulseek', false); } } function updateStatusIndicator(service, connected) { const indicator = document.getElementById(`${service}-indicator`); const dot = indicator.querySelector('.status-dot'); if (connected) { dot.classList.remove('disconnected'); dot.classList.add('connected'); } else { dot.classList.remove('connected'); dot.classList.add('disconnected'); } } // =============================== // MEDIA PLAYER FUNCTIONALITY // =============================== function initializeMediaPlayer() { const trackTitle = document.getElementById('track-title'); 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) { // Set up audio event listeners audioPlayer.addEventListener('timeupdate', updateAudioProgress); audioPlayer.addEventListener('ended', onAudioEnded); 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) { // Handle seeking progressBar.addEventListener('input', handleProgressBarChange); progressBar.addEventListener('mousedown', () => { progressBar.dataset.seeking = 'true'; }); progressBar.addEventListener('mouseup', () => { 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'); noTrackMessage.classList.add('hidden'); } else { mediaPlayer.style.minHeight = '85px'; expandedContent.classList.add('hidden'); } } 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 .replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns .replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end .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(); } } 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; element.style.setProperty('--scroll-distance', `${scrollDistance}px`); element.classList.add('scrolling'); console.log(`๐Ÿ“œ Enabled scrolling for title: "${text}"`); console.log(`๐Ÿ“œ Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`); } } function clearTrack() { // Force collapse the media player BEFORE clearing currentTrack if (mediaPlayerExpanded) { // Manually collapse since toggleMediaPlayerExpansion() needs currentTrack 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'); if (progressBar) { progressBar.value = 0; delete progressBar.dataset.seeking; } 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'); } function setPlayingState(playing) { isPlaying = playing; const playButton = document.getElementById('play-button'); playButton.textContent = playing ? 'โธ๏ธŽ' : 'โ–ท'; } async function handlePlayPause() { // Use new streaming system toggle function togglePlayback(); } async function handleStop() { // Use new streaming system stop function await stopStream(); clearTrack(); } function handleVolumeChange(event) { const volume = event.target.value; updateVolumeSliderAppearance(); // Update HTML5 audio player volume if (audioPlayer) { audioPlayer.volume = volume / 100; } } 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) { currentTimeElement.textContent = formatTime(newTime); } } catch (error) { console.warn('โš ๏ธ Seek failed:', error.message); // Reset progress bar to current position const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100; event.target.value = actualProgress; const progressFill = document.getElementById('progress-fill'); if (progressFill) { progressFill.style.width = `${actualProgress}%`; } } } function updateVolumeSliderAppearance() { const slider = document.getElementById('volume-slider'); const value = slider.value; slider.style.setProperty('--volume-percent', `${value}%`); } function showLoadingAnimation() { document.getElementById('loading-animation').classList.remove('hidden'); } function hideLoadingAnimation() { document.getElementById('loading-animation').classList.add('hidden'); } 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)}%`; } // =============================== // STREAMING FUNCTIONALITY // =============================== 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', 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'); hideLoadingAnimation(); clearTrack(); } } function startStreamStatusPolling() { // Start polling for stream status updates with retry logic 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); } function stopStreamStatusPolling() { // Stop polling for stream status updates if (streamStatusPoller) { clearInterval(streamStatusPoller); streamStatusPoller = null; streamPollingRetries = 0; streamPollingInterval = 1000; // Reset interval console.log('โน๏ธ Stopped stream status polling'); } } async function updateStreamStatus() { // Poll server for streaming progress and handle state changes with enhanced error recovery 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); // Update loading text with progress const loadingText = document.querySelector('.loading-text'); if (loadingText && data.progress > 0) { loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; } break; case 'queued': // Show queue status with better messaging const queueText = document.querySelector('.loading-text'); if (queueText) { queueText.textContent = 'Queuing with uploader...'; } 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(); hideLoadingAnimation(); showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); clearTrack(); break; case 'stopped': // Handle stopped state console.log('๐Ÿ›‘ Stream stopped'); stopStreamStatusPolling(); hideLoadingAnimation(); 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'); stopStreamStatusPolling(); hideLoadingAnimation(); showToast('Lost connection to streaming server', 'error'); clearTrack(); } else { // 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); streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); console.log(`๐Ÿ”„ Retrying stream status polling with ${streamPollingInterval}ms interval`); } } } } async function startAudioPlayback() { // Start HTML5 audio playback of the streamed file with enhanced state management try { 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); audioPlayer.removeEventListener('error', onError); 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') || error.message.includes('Not supported') || error.message.includes('MEDIA_ELEMENT_ERROR')) { userMessage = 'Audio format not supported by your browser. Try downloading instead.'; } else if (error.message.includes('network') || error.message.includes('fetch')) { userMessage = 'Network error - please check your connection'; } else if (error.message.includes('decode')) { userMessage = 'Audio file is corrupted or incompatible'; } else if (error.message.includes('timeout')) { userMessage = 'Audio loading timeout - file may be too large'; } else if (error.message.includes('AbortError')) { userMessage = 'Playback was interrupted'; } showToast(userMessage, 'error'); clearTrack(); } } async function stopStream() { // Stop streaming and clean up all state 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); } } function togglePlayback() { // Toggle play/pause for currently loaded audio if (!audioPlayer || !currentTrack) { console.log('โš ๏ธ No audio player or track to toggle'); return; } if (audioPlayer.paused) { audioPlayer.play() .then(() => { setPlayingState(true); console.log('โ–ถ๏ธ Resumed playback'); }) .catch(error => { console.error('Error resuming playback:', error); showToast('Failed to resume playback', 'error'); }); } else { audioPlayer.pause(); setPlayingState(false); console.log('โธ๏ธ Paused playback'); } } // =============================== // AUDIO EVENT HANDLERS // =============================== 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'); if (progressBar && !progressBar.dataset.seeking) { progressBar.value = progress; // Update visual progress fill if (progressFill) { 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); } if (totalTimeElement) { totalTimeElement.textContent = formatTime(audioPlayer.duration); } } 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'); if (progressBar) { progressBar.value = 0; } 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 } 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'); // Don't clear track or show error - let retry logic handle it 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 userMessage = 'Playback was stopped'; break; case 2: // MEDIA_ERR_NETWORK userMessage = 'Network error - please try again'; break; case 3: // MEDIA_ERR_DECODE userMessage = 'Audio file is corrupted or incompatible'; break; case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED userMessage = 'Audio format not supported by your browser. Try downloading instead.'; break; } } showToast(userMessage, 'error'); clearTrack(); } }, 2000); } function onAudioLoadStart() { // Handle audio load start console.log('๐Ÿ”„ Audio loading started'); } function onAudioCanPlay() { // Handle when audio can start playing console.log('โœ… Audio ready to play'); } function formatTime(seconds) { // Format seconds as MM:SS if (!seconds || !isFinite(seconds)) return '0:00'; const minutes = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${minutes}:${secs.toString().padStart(2, '0')}`; } // =============================== // AUDIO FORMAT SUPPORT DETECTION // =============================== function getFileExtension(filename) { if (!filename) return ''; const ext = filename.toLowerCase().match(/\.([^.]+)$/); return ext ? ext[1] : ''; } function isAudioFormatSupported(filename) { const ext = getFileExtension(filename); const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats const partialSupport = ['flac', 'aac']; // Depends on browser const unsupported = ['m4a', 'wma', 'ape', 'aiff']; // Generally problematic if (supportedFormats.includes(ext)) { return true; } if (partialSupport.includes(ext)) { // Test if browser can actually play this format return canPlayAudioFormat(ext); } return false; // Unsupported formats } function canPlayAudioFormat(extension) { const audio = document.createElement('audio'); const mimeTypes = { 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg; codecs="vorbis"', 'wav': 'audio/wav', 'flac': 'audio/flac', 'aac': 'audio/aac', 'm4a': 'audio/mp4', 'wma': 'audio/x-ms-wma' }; const mimeType = mimeTypes[extension]; if (!mimeType) return false; const canPlay = audio.canPlayType(mimeType); return canPlay === 'probably' || canPlay === 'maybe'; } // =============================== // DONATION WIDGET // =============================== function initializeDonationWidget() { const toggleButton = document.getElementById('donation-toggle'); toggleButton.addEventListener('click', toggleDonationAddresses); } 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'; } else { addresses.classList.add('hidden'); toggleButton.textContent = 'Show'; } } function openKofi() { window.open('https://ko-fi.com/boulderbadgedad', '_blank'); console.log('Opening Ko-fi link'); } async function copyAddress(address, cryptoName) { try { await navigator.clipboard.writeText(address); showToast(`${cryptoName} address copied to clipboard`, 'success'); console.log(`Copied ${cryptoName} address: ${address}`); } catch (error) { console.error('Failed to copy address:', error); showToast(`Failed to copy ${cryptoName} address`, 'error'); } } // =============================== // SETTINGS FUNCTIONALITY // =============================== function initializeSettings() { // This function is called when the settings page is loaded. // It attaches event listeners to all interactive elements on the page. // Main save button const saveButton = document.getElementById('save-settings'); if (saveButton) { saveButton.addEventListener('click', saveSettings); } // Server toggle buttons const plexToggle = document.getElementById('plex-toggle'); if (plexToggle) { plexToggle.addEventListener('click', () => toggleServer('plex')); } const jellyfinToggle = document.getElementById('jellyfin-toggle'); if (jellyfinToggle) { jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin')); } // Auto-detect buttons const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button'); if (detectSlskdBtn) { detectSlskdBtn.addEventListener('click', autoDetectSlskd); } const detectPlexBtn = document.querySelector('#plex-container .detect-button'); if (detectPlexBtn) { detectPlexBtn.addEventListener('click', autoDetectPlex); } const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button'); if (detectJellyfinBtn) { detectJellyfinBtn.addEventListener('click', autoDetectJellyfin); } // Test connection buttons // Test button event listeners removed - they use onclick attributes in HTML to avoid double firing } 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 || ''; // Populate Tidal settings document.getElementById('tidal-client-id').value = settings.tidal?.client_id || ''; document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || ''; // 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 || ''; // Set active server and toggle visibility const activeServer = settings.active_media_server || 'plex'; toggleServer(activeServer); // Populate Soulseek settings document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || ''; document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || ''; // Populate Download settings (right column) document.getElementById('preferred-quality').value = settings.settings?.audio_quality || 'flac'; document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads'; document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; // 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; // 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'; } catch (error) { console.error('Error loading settings:', error); showToast('Failed to load settings', 'error'); } } 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'; } else { urlInput.placeholder = 'http://localhost:8096'; tokenInput.placeholder = 'Jellyfin API Key'; } } function toggleServer(serverType) { // Update toggle buttons document.getElementById('plex-toggle').classList.remove('active'); document.getElementById('jellyfin-toggle').classList.remove('active'); document.getElementById(`${serverType}-toggle`).classList.add('active'); // Show/hide server containers document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex'); document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin'); } async function saveSettings() { // Determine active server from toggle buttons const activeServer = document.getElementById('plex-toggle').classList.contains('active') ? 'plex' : 'jellyfin'; const settings = { active_media_server: activeServer, spotify: { client_id: document.getElementById('spotify-client-id').value, client_secret: document.getElementById('spotify-client-secret').value }, tidal: { client_id: document.getElementById('tidal-client-id').value, client_secret: document.getElementById('tidal-client-secret').value }, plex: { base_url: document.getElementById('plex-url').value, token: document.getElementById('plex-token').value }, jellyfin: { base_url: document.getElementById('jellyfin-url').value, api_key: document.getElementById('jellyfin-api-key').value }, soulseek: { slskd_url: document.getElementById('soulseek-url').value, api_key: document.getElementById('soulseek-api-key').value, download_path: document.getElementById('download-path').value, transfer_path: document.getElementById('transfer-path').value }, settings: { audio_quality: document.getElementById('preferred-quality').value }, database: { max_workers: parseInt(document.getElementById('max-workers').value) }, metadata_enhancement: { enabled: document.getElementById('metadata-enabled').checked, embed_album_art: document.getElementById('embed-album-art').checked }, playlist_sync: { create_backup: document.getElementById('create-backup').checked } }; try { showLoadingOverlay('Saving settings...'); const response = await fetch(API.settings, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); const result = await response.json(); if (result.success) { showToast('Settings saved successfully', 'success'); // Trigger immediate status update setTimeout(updateServiceStatus, 1000); } else { showToast(`Failed to save settings: ${result.error}`, 'error'); } } catch (error) { console.error('Error saving settings:', error); showToast('Failed to save settings', 'error'); } finally { hideLoadingOverlay(); } } 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) { showToast(`${service} connection successful`, 'success'); } else { showToast(`${service} connection failed: ${result.error}`, 'error'); } } catch (error) { console.error(`Error testing ${service} connection:`, error); showToast(`Failed to test ${service} connection`, 'error'); } finally { hideLoadingOverlay(); } } // Individual Auto-detect functions - same as GUI 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'); } finally { hideLoadingOverlay(); } } 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'); } finally { hideLoadingOverlay(); } } 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'); } finally { hideLoadingOverlay(); } } function cancelDetection(service) { const progressDiv = document.getElementById(`${service}-detection-progress`); progressDiv.classList.add('hidden'); showToast(`${service} detection cancelled`, 'error'); } function updateStatusDisplays() { // Update status displays based on current service status // This would be called after status updates const services = ['spotify', 'media-server', 'soulseek']; services.forEach(service => { const display = document.getElementById(`${service}-status-display`); if (display) { // Status will be updated by the regular status monitoring } }); } async function authenticateTidal() { try { showLoadingOverlay('Starting Tidal authentication...'); // This would trigger the OAuth flow showToast('Tidal authentication started', 'success'); // In a real implementation, this would open the OAuth URL window.open('/auth/tidal', '_blank'); } catch (error) { console.error('Error authenticating Tidal:', error); showToast('Failed to start Tidal authentication', 'error'); } finally { hideLoadingOverlay(); } } function browsePath(pathType) { showToast(`Path browser not available in web interface. Please enter path manually.`, 'error'); } // =============================== // SEARCH FUNCTIONALITY // =============================== 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'); if (searchButton && searchInput) { searchButton.addEventListener('click', performDownloadsSearch); searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') performDownloadsSearch(); }); } // Add this event listener for the cancel button if (cancelButton) { cancelButton.addEventListener('click', () => { if (searchAbortController) { searchAbortController.abort(); // This cancels the fetch request console.log("Search cancelled by user."); } }); } } async function performSearch() { const query = document.getElementById('search-input').value.trim(); if (!query) { 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'); } finally { hideLoadingOverlay(); } } 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 ? `${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 `
${escapeHtml(result.title)}
${escapeHtml(result.artist)}
${result.album ? `
${escapeHtml(result.album)}
` : ''}
${sizeText} by ${escapeHtml(result.username)} ${result.quality ? `${escapeHtml(result.quality)}` : ''}
`; }).join(''); } function selectResult(index) { const result = searchResults[index]; if (!result) return; console.log('Selected result:', result); // Could show detailed view or additional actions here } 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 { showToast(`Download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Error starting download:', error); showToast('Failed to start download', 'error'); } } // =============================== // PAGE DATA LOADING // =============================== async function loadInitialData() { try { // Load dashboard data by default await loadDashboardData(); } catch (error) { console.error('Error loading initial data:', error); } } 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 => `
${activity.time} ${escapeHtml(activity.text)}
`).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); } } // =========================================== // == SYNC PAGE SPOTIFY FUNCTIONALITY == // =========================================== async function loadSyncData() { // This is called when the sync page is navigated to. if (!spotifyPlaylistsLoaded) { await loadSpotifyPlaylists(); } // Load YouTube playlists from backend (always refresh to get latest state) await loadYouTubePlaylistsFromBackend(); } async function checkForActiveProcesses() { try { const response = await fetch('/api/active-processes'); if (!response.ok) return; const data = await response.json(); const processes = data.active_processes || []; 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`); } } catch (error) { console.error('Failed to check for active processes:', error); } } async function rehydrateModal(processInfo, userRequested = false) { const { playlist_id, playlist_name, batch_id } = processInfo; console.log(`๐Ÿ’ง Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`); // Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system if (playlist_id.startsWith('youtube_')) { console.log(`โญ๏ธ Skipping YouTube virtual playlist rehydration - handled by YouTube system`); return; } // Handle wishlist processes specially if (playlist_id === "wishlist") { console.log(`๐Ÿ” Current activeDownloadProcesses keys: [${Object.keys(activeDownloadProcesses).join(', ')}]`); await openDownloadMissingWishlistModal(); const process = activeDownloadProcesses[playlist_id]; if (!process) { console.error('โŒ Failed to create wishlist process in activeDownloadProcesses'); return; } console.log(`โœ… Setting wishlist process state - batchId: ${batch_id}, status: running`); process.status = 'running'; process.batchId = batch_id; // Note: Wishlist processes don't affect sync page refresh button 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'; startModalDownloadPolling(playlist_id); // Enhanced logic: Show modal if user-requested OR if it was previously visible before page refresh const wasModalPreviouslyVisible = WishlistModalState.wasVisible(); const shouldShowModal = userRequested || (wasModalPreviouslyVisible && !WishlistModalState.wasUserClosed()); if (shouldShowModal) { process.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); // Track that modal is now visible WishlistModalState.clearUserClosed(); // Clear any user closed state since we're showing it console.log(`๐Ÿ” Showing wishlist modal - User requested: ${userRequested}, Previously visible: ${wasModalPreviouslyVisible}`); } else { process.modalElement.style.display = 'none'; WishlistModalState.setHidden(); // Track that modal is hidden console.log('๐Ÿ” Hiding wishlist modal for background rehydration'); } return; } // Handle regular Spotify playlist processes let playlistData = spotifyPlaylists.find(p => p.id === playlist_id); if (!playlistData) { console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`); return; } await openDownloadMissingModal(playlist_id); const process = activeDownloadProcesses[playlist_id]; if (!process) return; process.status = 'running'; process.batchId = batch_id; updatePlaylistCardUI(playlist_id); updateRefreshButtonState(); document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block'; startModalDownloadPolling(playlist_id); process.modalElement.style.display = 'none'; } // =================================================================== // YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS // =================================================================== 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 && 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' && (!state.discoveryResults || state.discoveryResults.length === 0)) { try { console.log(`๐Ÿ” Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { const fullState = await stateResponse.json(); if (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 for existing card`); } } } catch (error) { 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 { console.log(`๐Ÿ” Fetching discovery results for: ${playlistInfo.playlist.name}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); 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) { state.discoveryResults = fullState.discovery_results; state.syncPlaylistId = fullState.sync_playlist_id; state.syncProgress = fullState.sync_progress || {}; console.log(`โœ… Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`); } } else { console.warn(`โš ๏ธ Could not fetch discovery results for: ${playlistInfo.playlist.name}`); } } catch (error) { console.warn(`โš ๏ธ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message); } } } // Rehydrate download modals for YouTube playlists in downloading/download_complete phases for (const playlistInfo of playlists) { 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 { // Create the download modal using the YouTube-specific function 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, 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}`); } } else { console.warn(`โš ๏ธ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`); } } catch (error) { console.error(`โŒ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error); } } } } console.log(`โœ… Successfully hydrated ${playlists.length} YouTube playlists from backend`); } catch (error) { console.error('โŒ Error loading YouTube playlists from backend:', error); showToast(`Error loading YouTube playlists: ${error.message}`, 'error'); } } function createYouTubeCardFromBackendState(playlistInfo) { // Create YouTube playlist card from backend state data 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 = `
โ–ถ
${escapeHtml(playlist.name)}
${playlist.tracks.length} tracks ${getPhaseText(phase)}
โ™ช ${playlistInfo.spotify_total} / โœ“ ${playlistInfo.spotify_matches} / โœ— ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}%
`; container.insertAdjacentHTML('beforeend', cardHtml); // Store state for UI management (but backend remains source of truth) youtubePlaylistStates[urlHash] = { phase: phase, url: playlistInfo.url, playlist: playlist, cardElement: document.getElementById(`youtube-card-${urlHash}`), discoveryResults: [], discoveryProgress: playlistInfo.discovery_progress, spotifyMatches: playlistInfo.spotify_matches, 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})`); } function getActionButtonText(phase) { switch (phase) { case 'fresh': return 'Discover'; case 'discovering': return 'View Progress'; case 'discovered': return 'View Results'; case 'syncing': return 'View Sync'; case 'sync_complete': return 'Download'; case 'downloading': return 'View Downloads'; case 'download_complete': return 'Complete'; default: return 'Open'; } } function getPhaseText(phase) { switch (phase) { case 'fresh': return 'Ready to discover'; case 'discovering': return 'Discovering...'; case 'discovered': return 'Discovery Complete'; case 'syncing': return 'Syncing...'; case 'sync_complete': return 'Sync Complete'; case 'downloading': return 'Downloading...'; case 'download_complete': return 'Download Complete'; default: return phase; } } function getPhaseColor(phase) { switch (phase) { case 'fresh': return '#999'; case 'discovering': case 'syncing': case 'downloading': return '#ffa500'; case 'discovered': case 'sync_complete': case 'download_complete': return '#1db954'; default: return '#999'; } } function getProgressWidth(playlistInfo) { if (playlistInfo.phase === 'fresh') return 0; if (playlistInfo.spotify_total === 0) return 0; return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100); } async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { // Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal) 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 { const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { const fullPlaylistState = await stateResponse.json(); createYouTubeCardFromBackendState(fullPlaylistState); } else { console.error(`โŒ Could not fetch full playlist state for card creation: ${playlistName}`); return; // Can't create card without playlist data } } catch (error) { console.error(`โŒ Error fetching playlist state for card creation: ${error.message}`); return; } } // Fetch full state from backend to get discovery results let fullState = null; if (phase !== 'fresh' && phase !== 'discovering') { try { console.log(`๐Ÿ” Fetching full backend state for: ${playlistName}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); } } catch (error) { console.warn(`โš ๏ธ Could not fetch full state for ${playlistName}:`, error.message); } } // Update local state to match backend const state = youtubePlaylistStates[urlHash]; state.phase = phase; 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')) { console.log(`๐Ÿ”„ Refreshing existing modal with restored discovery results`); refreshYouTubeDiscoveryModalTable(urlHash); } } // Update card display updateYouTubeCardPhase(urlHash, phase); updateYouTubeCardProgress(urlHash, playlistInfo); // Handle active discovery polling if (phase === 'discovering') { console.log(`๐Ÿ” Resuming discovery polling for: ${playlistName}`); startYouTubeDiscoveryPolling(urlHash); } // Open modal if user requested if (userRequested) { switch (phase) { case 'discovering': case 'discovered': case 'syncing': case 'sync_complete': openYouTubeDiscoveryModal(urlHash); break; case 'downloading': case 'download_complete': // Open download modal if we have the converted playlist ID if (playlistInfo.converted_spotify_playlist_id) { await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id); } break; } } console.log(`โœ… Successfully rehydrated YouTube playlist: ${playlistName}`); } catch (error) { console.error(`โŒ Error rehydrating YouTube playlist "${playlistName}":`, error); } } 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'); } } 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...'; try { const response = await fetch('/api/spotify/playlists'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch playlists'); } spotifyPlaylists = await response.json(); renderSpotifyPlaylists(); spotifyPlaylistsLoaded = true; await checkForActiveProcesses(); } catch (error) { container.innerHTML = `
โŒ Error: ${error.message}
`; showToast(`Error loading playlists: ${error.message}`, 'error'); } finally { refreshBtn.disabled = false; refreshBtn.textContent = '๐Ÿ”„ Refresh'; } } function renderSpotifyPlaylists() { const container = document.getElementById('spotify-playlist-container'); if (spotifyPlaylists.length === 0) { container.innerHTML = `
No Spotify playlists found.
`; return; } container.innerHTML = spotifyPlaylists.map(p => { let statusClass = 'status-never-synced'; if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; // This HTML structure creates the interactive playlist cards return `
${escapeHtml(p.name)}
${p.track_count} tracks โ€ข ${p.sync_status}
`; }).join(''); } function handleViewProgressClick(event, playlistId) { event.stopPropagation(); // Prevent the card selection from toggling const process = activeDownloadProcesses[playlistId]; if (process && process.modalElement) { // If a process is active, just show its modal console.log(`Re-opening active download modal for playlist ${playlistId}`); process.modalElement.style.display = 'flex'; } } function updatePlaylistCardUI(playlistId) { const process = activeDownloadProcesses[playlistId]; const progressBtn = document.getElementById(`progress-btn-${playlistId}`); const actionBtn = document.getElementById(`action-btn-${playlistId}`); const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); if (!progressBtn || !actionBtn) return; if (process && process.status === 'running') { // A process is running: show the progress button progressBtn.classList.remove('hidden'); progressBtn.textContent = 'View Progress'; 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.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'); progressBtn.style.backgroundColor = ''; // Reset styling progressBtn.style.color = ''; // Reset styling actionBtn.textContent = 'Sync / Download'; actionBtn.disabled = false; // Remove completion styling from card if (card) card.classList.remove('download-complete'); } } async function cleanupDownloadProcess(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; console.log(`Cleaning up download process for playlist ${playlistId}`); // --- THIS IS THE FIX --- // If the process has a batchId, tell the server to clean it up. if (process.batchId) { try { console.log(`๐Ÿš€ Sending cleanup request to server for batch: ${process.batchId}`); await fetch('/api/playlists/cleanup_batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ batch_id: process.batchId }) }); } catch (error) { console.error('Failed to send cleanup request to server:', error); } } // --- END OF FIX --- // Stop client-side polling if (process.poller) { clearInterval(process.poller); } // Remove modal from DOM if (process.modalElement && process.modalElement.parentElement) { process.modalElement.parentElement.removeChild(process.modalElement); } // Remove from client-side global state delete activeDownloadProcesses[playlistId]; // Restore card UI (only for non-wishlist playlists) if (playlistId !== 'wishlist') { updatePlaylistCardUI(playlistId); } updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist } function togglePlaylistSelection(event) { const card = event.currentTarget; const playlistId = card.dataset.playlistId; // Don't toggle if clicking the button if (event.target.tagName === 'BUTTON') return; const isSelected = !card.classList.contains('selected'); card.classList.toggle('selected', isSelected); if (isSelected) { selectedPlaylists.add(playlistId); } else { selectedPlaylists.delete(playlistId); } updateSyncActionsUI(); } function updateSyncActionsUI() { // If sequential sync is running, let the manager handle UI updates if (sequentialSyncManager && sequentialSyncManager.isRunning) { sequentialSyncManager.updateUI(); return; } const selectionInfo = document.getElementById('selection-info'); const startSyncBtn = document.getElementById('start-sync-btn'); const count = selectedPlaylists.size; if (count === 0) { if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync'; if (startSyncBtn) startSyncBtn.disabled = true; } else { if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`; if (startSyncBtn) startSyncBtn.disabled = false; } } async function openPlaylistDetailsModal(event, playlistId) { event.stopPropagation(); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) return; showLoadingOverlay(`Loading playlist: ${playlist.name}...`); try { // --- CACHING LOGIC START --- if (playlistTrackCache[playlistId]) { console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`); // Use the cached tracks instead of fetching const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] }; showPlaylistDetailsModal(fullPlaylist); } else { console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); // Fetch from the server if not in cache const response = await fetch(`/api/spotify/playlist/${playlistId}`); const fullPlaylist = await response.json(); if (fullPlaylist.error) throw new Error(fullPlaylist.error); // Store the fetched tracks in the cache playlistTrackCache[playlistId] = fullPlaylist.tracks; console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`); showPlaylistDetailsModal(fullPlaylist); } // --- CACHING LOGIC END --- } catch (error) { showToast(`Error: ${error.message}`, 'error'); } finally { hideLoadingOverlay(); } } function showPlaylistDetailsModal(playlist) { // Create modal if it doesn't exist let modal = document.getElementById('playlist-details-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'playlist-details-modal'; 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'; modal.innerHTML = ` `; modal.style.display = 'flex'; } function closePlaylistDetailsModal() { const modal = document.getElementById('playlist-details-modal'); if (modal) { modal.style.display = 'none'; } } function formatDuration(ms) { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; } // =============================== // DOWNLOAD MISSING TRACKS MODAL // =============================== let activeAnalysisTaskId = null; let currentPlaylistTracks = []; let analysisResults = []; let missingTracks = []; // New variables for enhanced modal functionality let currentDownloadBatchId = null; let modalDownloadPoller = null; let currentModalPlaylistId = null; // PHASE 2: Local cancelled track management (GUI PARITY) let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks async function openDownloadMissingModal(playlistId) { // **NEW**: Check if a process is already active for this playlist if (activeDownloadProcesses[playlistId]) { console.log(`Modal for ${playlistId} already exists. Showing it.`); closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal const process = activeDownloadProcesses[playlistId]; if (process.modalElement) { // Show helpful message if it's a completed process if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; } return; // Don't create a new one } 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 { const response = await fetch(`/api/spotify/playlist/${playlistId}`); const fullPlaylist = await response.json(); if (fullPlaylist.error) throw new Error(fullPlaylist.error); tracks = fullPlaylist.tracks; playlistTrackCache[playlistId] = tracks; } catch (error) { showToast(`Failed to fetch tracks: ${error.message}`, 'error'); 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 modal.style.display = 'none'; // Start hidden document.body.appendChild(modal); // **NEW**: Register the new process in our global state tracker activeDownloadProcesses[playlistId] = { status: 'idle', // idle, running, complete, cancelled modalElement: modal, poller: null, batchId: null, playlist: playlist, tracks: tracks }; modal.innerHTML = `

Download Missing Tracks - ${escapeHtml(playlist.name)}

×
${tracks.length}
Total Tracks
-
Found in Library
-
Missing Tracks
0
Downloaded
๐Ÿ” Library Analysis Ready to start
โฌ Downloads Waiting for analysis

๐Ÿ“‹ Track Analysis & Download Status

${tracks.map((track, index) => ` `).join('')}
# Track Artist Duration Library Match Download Status Actions
${index + 1} ${escapeHtml(track.name)} ${track.artists.join(', ')} ${formatDuration(track.duration_ms)} ๐Ÿ” Pending - -
`; modal.style.display = 'flex'; } async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks) { // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); const process = activeDownloadProcesses[virtualPlaylistId]; if (process.modalElement) { if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; } return; } console.log(`๐Ÿ“ฅ Opening Download Missing Tracks modal for YouTube 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'; modal.style.display = 'none'; document.body.appendChild(modal); // Register the new process in our global state tracker using the same structure as Spotify activeDownloadProcesses[virtualPlaylistId] = { status: 'idle', modalElement: modal, poller: null, batchId: null, playlist: virtualPlaylist, tracks: spotifyTracks }; // Use the exact same modal HTML structure as the existing Spotify modal modal.innerHTML = `

Download Missing Tracks - ${escapeHtml(playlistName)}

×
${spotifyTracks.length}
Total Tracks
-
Found in Library
-
Missing Tracks
0
Downloaded
๐Ÿ” Library Analysis Ready to start
โฌ Downloads Waiting for analysis

๐Ÿ“‹ Track Analysis & Download Status

${spotifyTracks.map((track, index) => ` `).join('')}
# Track Artist Duration Library Match Download Status Actions
${index + 1} ${escapeHtml(track.name)} ${track.artists.join(', ')} ${formatDuration(track.duration_ms)} ๐Ÿ” Pending - -
`; modal.style.display = 'flex'; } function closeDownloadMissingModal(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) { // If somehow called without a process, try to find and remove the element const modal = document.getElementById(`download-missing-modal-${playlistId}`); if (modal && modal.parentElement) { modal.parentElement.removeChild(modal); } return; } // If the process is running, just hide the modal. // If it's idle, complete, or cancelled, perform a full cleanup. 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 console.log('๐Ÿ“ฑ [Modal State] User manually closed wishlist modal during processing'); } } 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'); } // Reset Tidal playlist phase to 'discovered' when modal is closed after completion if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); updateTidalCardPhase(tidalPlaylistId, 'discovered'); } // Clear wishlist modal state when modal is fully closed if (playlistId === 'wishlist') { WishlistModalState.clear(); // Clear all tracking since modal is fully closed console.log('๐Ÿ“ฑ [Modal State] Cleared wishlist modal state on full close'); } cleanupDownloadProcess(playlistId); } } async function openDownloadMissingWishlistModal() { const playlistId = "wishlist"; // Use a consistent ID for wishlist // Check if a process is already active for the wishlist if (activeDownloadProcesses[playlistId]) { console.log(`Modal for wishlist already exists. Showing it.`); const process = activeDownloadProcesses[playlistId]; if (process.modalElement) { // Show helpful message if it's a completed process if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); // Track that modal is now visible } return; // Don't create a new one } console.log(`๐Ÿ“ฅ Opening Download Missing Tracks modal for wishlist`); // Fetch actual wishlist tracks from the server let tracks; try { const response = await fetch('/api/wishlist/count'); const countData = await response.json(); if (countData.count === 0) { showToast('Wishlist is empty. No tracks to download.', 'info'); return; } // Fetch the actual wishlist tracks for display const tracksResponse = await fetch('/api/wishlist/tracks'); if (!tracksResponse.ok) { throw new Error('Failed to fetch wishlist tracks'); } const tracksData = await tracksResponse.json(); tracks = tracksData.tracks || []; } catch (error) { showToast(`Failed to fetch wishlist data: ${error.message}`, 'error'); 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 modal.style.display = 'none'; // Start hidden document.body.appendChild(modal); // Register the new process in our global state tracker activeDownloadProcesses[playlistId] = { status: 'idle', // idle, running, complete, cancelled modalElement: modal, poller: null, batchId: null, playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object tracks: tracks }; modal.innerHTML = `

Download Missing Tracks - Wishlist

×
${tracks.length}
Total Tracks
-
Found in Library
-
Missing Tracks
0
Downloaded
๐Ÿ” Library Analysis Ready to start
โฌ Downloads Waiting for analysis

๐Ÿ“‹ Track Analysis & Download Status

${tracks.map((track, index) => ` `).join('')}
# Track Artist Library Match Download Status Actions
${index + 1} ${escapeHtml(track.name)} ${formatArtists(track.artists)} ๐Ÿ” Pending - -
`; modal.style.display = 'flex'; WishlistModalState.setVisible(); // Track that new wishlist modal is now visible } async function startWishlistMissingTracksProcess(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; console.log(`๐Ÿš€ Kicking off wishlist missing tracks process`); try { process.status = 'running'; // Note: Wishlist processes don't affect sync page refresh button state document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; const response = await fetch('/api/wishlist/download_missing', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!data.success) { // Special handling for rate limit if (response.status === 429) { throw new Error(`${data.error} Try closing some other download processes first.`); } throw new Error(data.error); } 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 document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; } } async function startMissingTracksProcess(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; console.log(`๐Ÿš€ Kicking off unified missing tracks process for playlist: ${playlistId}`); try { process.status = 'running'; updatePlaylistCardUI(playlistId); updateRefreshButtonState(); // Update YouTube playlist phase to 'downloading' if this is a YouTube playlist if (playlistId.startsWith('youtube_')) { 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_', ''); if (tidalPlaylistStates[tidalPlaylistId]) { tidalPlaylistStates[tidalPlaylistId].phase = 'downloading'; updateTidalCardPhase(tidalPlaylistId, 'downloading'); console.log(`๐Ÿ”„ Updated Tidal playlist ${tidalPlaylistId} to downloading phase`); } } document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: process.tracks, playlist_name: process.playlist.name }) }); const data = await response.json(); if (!data.success) { // Special handling for rate limit if (response.status === 429) { throw new Error(`${data.error} Try closing some other download processes first.`); } throw new Error(data.error); } process.batchId = data.batch_id; startModalDownloadPolling(playlistId); } catch (error) { showToast(`Failed to start process: ${error.message}`, 'error'); process.status = 'cancelled'; cleanupDownloadProcess(playlistId); } } function updateTrackAnalysisResults(playlistId, results) { // Update match results for all rows (tracks are now pre-populated) for (const result of results) { const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`); if (matchElement) { matchElement.textContent = result.found ? 'โœ… Found' : 'โŒ Missing'; matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`; } } } function startModalDownloadPolling(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process || !process.batchId) return; if (process.poller) clearInterval(process.poller); process.poller = setInterval(async () => { if (!activeDownloadProcesses[playlistId]) { clearInterval(process.poller); return; } try { const response = await fetch(`/api/playlists/${process.batchId}/download_status`); const data = await response.json(); if (data.error) throw new Error(data.error); // Auto-show wishlist modal during active auto-processing const isWishlist = (playlistId === 'wishlist'); const isAutoInitiated = data.auto_initiated || false; const isModalHidden = process.modalElement && process.modalElement.style.display === 'none'; if (isWishlist && isAutoInitiated && isModalHidden && currentPage === 'dashboard' && !WishlistModalState.wasUserClosed()) { console.log('๐Ÿค– [Polling] Auto-showing wishlist modal during active auto-processing'); process.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); } if (data.phase === 'analysis') { const progress = data.analysis_progress; const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0; document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`; document.getElementById(`analysis-progress-text-${playlistId}`).textContent = `${progress.processed}/${progress.total} tracks analyzed`; if (data.analysis_results) { updateTrackAnalysisResults(playlistId, data.analysis_results); // Update stats when we first get 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; } } else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') { 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; } } const missingTracks = (data.analysis_results || []).filter(r => !r.found); const missingCount = missingTracks.length; let completedCount = 0; let failedOrCancelledCount = 0; (data.tasks || []).forEach(task => { const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${task.track_index}"]`); if (!row) return; // Stronger protection: Don't override locally cancelled tracks with any backend updates if (row.dataset.locallyCancelled === 'true') { failedOrCancelledCount++; return; // Completely skip processing this task to avoid any UI conflicts } 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; case 'searching': statusText = '๐Ÿ” Searching...'; break; case 'downloading': statusText = `โฌ Downloading... ${Math.round(task.progress || 0)}%`; break; case 'completed': statusText = 'โœ… Completed'; completedCount++; break; case 'failed': statusText = 'โŒ Failed'; failedOrCancelledCount++; break; case 'cancelled': statusText = '๐Ÿšซ Cancelled'; failedOrCancelledCount++; break; default: statusText = `โšช ${task.status}`; break; } if(statusEl) statusEl.textContent = statusText; if (actionsEl && !['completed', 'failed', 'cancelled'].includes(task.status) && actionsEl.innerHTML === '-') { actionsEl.innerHTML = ``; } if (actionsEl && ['completed', 'failed', 'cancelled'].includes(task.status)) { actionsEl.innerHTML = '-'; } }); const totalFinished = completedCount + failedOrCancelledCount; const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0; document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`; document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount; if (data.phase === 'complete' || data.phase === 'error' || (missingCount > 0 && totalFinished >= missingCount)) { // Enhanced check for background auto-processing for wishlist const isWishlist = (playlistId === 'wishlist'); 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); // Auto-show modal for wishlist auto-processing if user is on dashboard and hasn't closed it if (isWishlist && isAutoInitiated && isModalHidden && currentPage === 'dashboard' && !WishlistModalState.wasUserClosed()) { console.log('๐Ÿค– [Polling] Auto-showing wishlist modal for live updates during auto-processing'); process.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); showToast('Auto-processing wishlist - showing live updates', 'info', 2000); } 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_', ''); if (tidalPlaylistStates[tidalPlaylistId]) { tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete'; updateTidalCardPhase(tidalPlaylistId, 'download_complete'); console.log(`โœ… Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`); } } // Handle background wishlist processing completion specially if (isBackgroundWishlist) { console.log(`๐ŸŽ‰ Background wishlist processing complete: ${completedCount} downloaded, ${failedOrCancelledCount} failed`); // Clean up polling first clearInterval(process.poller); // 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) { completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`; messageType = 'warning'; } } showToast(completionMessage, messageType); } document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; clearInterval(process.poller); process.poller = null; updatePlaylistCardUI(playlistId); } } } catch (error) { console.error(`Polling error for ${playlistId}:`, error); } }, 500); } 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 || {})}; // 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()) || trackName.toLowerCase().includes(downloadTitle.toLowerCase()) )) { // Update the track with live download progress 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'; } else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) { 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 } } async function cancelAllOperations(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; // Prevent multiple cancel all operations if (process.cancellingAll) { console.log(`โš ๏ธ Cancel All already in progress for ${playlistId}`); return; } 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 { // Cancel the batch (stops new downloads from starting) const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, { method: 'POST' }); if (cancelResponse.ok) { const cancelData = await cancelResponse.json(); console.log(`โœ… Server stopped new downloads for batch ${process.batchId}`); } } catch (error) { 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'); } function resetToInitialState() { // Reset UI 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) { const rows = tbody.querySelectorAll('tr'); rows.forEach((row, index) => { 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'; } if (downloadElement) { downloadElement.textContent = '-'; downloadElement.className = 'track-download-status'; } if (actionsElement) { actionsElement.textContent = '-'; } }); } // Reset state activeAnalysisTaskId = null; analysisResults = []; missingTracks = []; } async function cancelTrackDownload(playlistId, trackIndex) { const process = activeDownloadProcesses[playlistId]; if (!process) return; const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${trackIndex}"]`); if (!row) return; // Prevent double cancellation if (row.dataset.locallyCancelled === 'true') { return; // Already cancelled locally } const taskId = row.dataset.taskId; if (!taskId) { 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }) }); const data = await response.json(); if (data.success) { // Update final UI state after successful cancellation document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '๐Ÿšซ Cancelled'; showToast('Download cancelled and added to wishlist.', 'info'); } else { throw new Error(data.error); } } catch (error) { // Reset UI state if cancellation failed row.dataset.locallyCancelled = 'false'; document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = 'โŒ Cancel Failed'; showToast(`Could not cancel task: ${error.message}`, 'error'); } } // Find and REPLACE the old startPlaylistSyncFromModal function async function startPlaylistSync(playlistId) { const startTime = Date.now(); console.log(`๐Ÿš€ [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) { console.error(`โŒ Could not find playlist data for ID: ${playlistId}`); showToast('Could not find playlist data.', 'error'); return; } console.log(`โœ… Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`); // Ensure we have the full track list before starting let tracks = playlistTrackCache[playlistId]; if (!tracks) { const trackFetchStart = Date.now(); console.log(`๐Ÿ”„ [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`); try { const response = await fetch(`/api/spotify/playlist/${playlistId}`); const fullPlaylist = await response.json(); if (fullPlaylist.error) throw new Error(fullPlaylist.error); tracks = fullPlaylist.tracks; playlistTrackCache[playlistId] = tracks; // Cache it const trackFetchTime = Date.now() - trackFetchStart; console.log(`โœ… [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`); } catch (error) { console.error(`โŒ Failed to fetch tracks:`, error); showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error'); return; } } else { console.log(`โœ… [${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`); } // DON'T close the modal - let it show live progress like the GUI try { const syncStartTime = Date.now(); console.log(`๐Ÿ”„ [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`); const response = await fetch('/api/sync/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_id: playlist.id, playlist_name: playlist.name, tracks: tracks // Send the full track list }) }); const syncRequestTime = Date.now() - syncStartTime; 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') { const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`); if (statusDisplay) { statusDisplay.style.display = 'flex'; console.log(`๐Ÿ“Š [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`); } } updateCardToSyncing(playlist.id, 0); // Initial state startSyncPolling(playlist.id); } catch (error) { console.error(`โŒ Failed to start sync:`, error); showToast(`Failed to start sync: ${error.message}`, 'error'); updateCardToDefault(playlist.id); } } // Add these new helper functions to script.js function startSyncPolling(playlistId) { // Clear any existing poller for this playlist if (activeSyncPollers[playlistId]) { clearInterval(activeSyncPollers[playlistId]); } // Start a new poller that checks every 2 seconds console.log(`๐Ÿ”„ Starting sync polling for playlist: ${playlistId}`); activeSyncPollers[playlistId] = setInterval(async () => { try { console.log(`๐Ÿ“Š Polling sync status for: ${playlistId}`); const response = await fetch(`/api/sync/status/${playlistId}`); const state = await response.json(); console.log(`๐Ÿ“Š Poll response:`, state); if (state.status === 'syncing') { const progress = state.progress; 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 updateModalSyncProgress(playlistId, progress); } else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') { console.log(`๐Ÿ Sync completed with status: ${state.status}`); stopSyncPolling(playlistId); updateCardToDefault(playlistId, state); // Also update the modal if it's open closePlaylistDetailsModal(); // Close modal on completion/error } } catch (error) { console.error(`โŒ Error polling sync status for ${playlistId}:`, error); stopSyncPolling(playlistId); updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' }); } }, 2000); // Poll every 2 seconds updateRefreshButtonState(); } function stopSyncPolling(playlistId) { if (activeSyncPollers[playlistId]) { clearInterval(activeSyncPollers[playlistId]); delete activeSyncPollers[playlistId]; } updateRefreshButtonState(); } // Sequential Sync Functions function startSequentialSync() { // Initialize manager if needed if (!sequentialSyncManager) { sequentialSyncManager = new SequentialSyncManager(); } // Check if already running - if so, cancel if (sequentialSyncManager.isRunning) { sequentialSyncManager.cancel(); return; } // Validate selection if (selectedPlaylists.size === 0) { showToast('No playlists selected for sync', 'error'); return; } // 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)) { orderedPlaylistIds.push(playlistId); } }); console.log(`๐Ÿš€ Starting sequential sync for ${orderedPlaylistIds.length} playlists`); // Start sequential sync sequentialSyncManager.start(orderedPlaylistIds); // Disable playlist selection during sync disablePlaylistSelection(true); } function disablePlaylistSelection(disabled) { const checkboxes = document.querySelectorAll('.playlist-checkbox'); checkboxes.forEach(checkbox => { checkbox.disabled = disabled; }); } function hasActiveOperations() { const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; // Only check non-wishlist download processes for sync page refresh button const hasActiveDownloads = Object.entries(activeDownloadProcesses) .filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist .some(([_, process]) => process.status === 'running'); const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; return hasActiveSyncs || hasActiveDownloads || hasSequentialSync; } function updateRefreshButtonState() { const refreshBtn = document.getElementById('spotify-refresh-btn'); if (!refreshBtn) return; if (hasActiveOperations()) { refreshBtn.disabled = true; // Provide context-specific text const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; if (hasActiveSyncs || hasSequentialSync) { refreshBtn.textContent = '๐Ÿ”„ Syncing...'; } else { refreshBtn.textContent = '๐Ÿ“ฅ Downloading...'; } } else { refreshBtn.disabled = false; refreshBtn.textContent = '๐Ÿ”„ Refresh'; } } function updateCardToSyncing(playlistId, percent, progress = null) { const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); if (!card) return; const progressBar = card.querySelector('.sync-progress-indicator'); progressBar.style.display = 'block'; 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; actualPercent = Math.round((processed / total) * 100); progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`; } 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) { const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; const total = progress.total_tracks || 0; const processed = matched + failed; const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; statusCounterHTML = `
โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${percentage}%)
`; } progressBar.innerHTML = ` ${statusCounterHTML}
${progressText}
`; } function updateCardToDefault(playlistId, finalState = null) { const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); if (!card) return; const progressBar = card.querySelector('.sync-progress-indicator'); progressBar.style.display = 'none'; progressBar.innerHTML = ''; const statusEl = card.querySelector('.playlist-card-status'); if (finalState) { if (finalState.status === 'finished') { statusEl.textContent = `Synced: Just now`; statusEl.className = 'playlist-card-status status-synced'; showToast(`Sync complete for "${card.querySelector('.playlist-card-name').textContent}"`, 'success'); } else { statusEl.textContent = `Sync Failed`; statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error'); } } } // Update the modal's sync progress display (matches GUI functionality) 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}`); } } else { console.log(`๐Ÿ“Š Modal not open for ${playlistId}, skipping update`); } } // Download tracking state management - matching GUI functionality let activeDownloads = {}; let finishedDownloads = {}; let downloadStatusInterval = null; let isDownloadPollingActive = false; async function loadDownloadsData() { // Downloads page loads search results dynamically console.log('Downloads page loaded'); // Connect downloads search button const searchButton = document.getElementById('downloads-search-btn'); const searchInput = document.getElementById('downloads-search-input'); const clearButton = document.querySelector('.controls-panel__clear-btn'); if (searchButton && searchInput) { searchButton.addEventListener('click', performDownloadsSearch); searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') performDownloadsSearch(); }); } 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); } function stopDownloadPolling() { if (downloadStatusInterval) { clearInterval(downloadStatusInterval); downloadStatusInterval = null; } isDownloadPollingActive = false; console.log('Stopped download status polling'); } async function updateDownloadQueues() { try { const response = await fetch('/api/downloads/status'); const data = await response.json(); if (data.error) { console.error("Error fetching download status:", data.error); return; } 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 => item.state && item.state.includes(state) ); if (isTerminal) { newFinished[item.id] = item; } else { newActive[item.id] = item; } }); // 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(); } catch (error) { // Only log errors occasionally to avoid console spam if (Math.random() < 0.1) { console.error("Failed to update download queues:", error); } } } function renderQueue(containerId, downloads, isActiveQueue) { const container = document.getElementById(containerId); if (!container) return; const downloadIds = Object.keys(downloads); if (downloadIds.length === 0) { container.innerHTML = `
${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}
`; return; } let html = ''; for (const id of downloadIds) { const item = downloads[id]; const title = item.filename ? item.filename.split(/[\\/]/).pop() : 'Unknown File'; const progress = item.percentComplete || 0; 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'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } 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 actionButtonHTML = `
${item.state} - ${progress.toFixed(1)}% ${speed > 0 ? `โ€ข ${formatSpeed(speed)}` : ''} ${totalBytes > 0 ? `โ€ข ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
`; } else { // Finished items get status and open button let statusClass = ''; 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}
`; } html += `
${title}
from ${item.username}
${actionButtonHTML}
`; } container.innerHTML = html; } 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})`; } 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}`; } function initializeDownloadTabs() { const tabButtons = document.querySelectorAll('.tab-btn'); tabButtons.forEach(btn => { btn.addEventListener('click', () => switchDownloadTab(btn)); }); } 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'); // Update content panes document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active')); const targetQueue = document.getElementById(targetTabId); if (targetQueue) targetQueue.classList.add('active'); } async function cancelDownloadItem(downloadId, username) { try { const response = await fetch('/api/downloads/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ download_id: downloadId, username: username }) }); const result = await response.json(); if (result.success) { showToast('Download cancelled', 'success'); } else { showToast(`Failed to cancel: ${result.error}`, 'error'); } } catch (error) { console.error('Error cancelling download:', error); showToast('Error sending cancel request', 'error'); } } async function clearFinishedDownloads() { const finishedCount = Object.keys(finishedDownloads).length; if (finishedCount === 0) { 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 { showToast(`Failed to clear: ${result.error}`, 'error'); } } catch (error) { console.error('Error clearing finished downloads:', error); showToast('Error sending clear request', 'error'); } } // REPLACE the old performDownloadsSearch function with this new one. async function performDownloadsSearch() { const query = document.getElementById('downloads-search-input').value.trim(); if (!query) { showToast('Please enter a search term', 'error'); return; } // --- UI Element References --- const searchInput = document.getElementById('downloads-search-input'); const searchButton = document.getElementById('downloads-search-btn'); const cancelButton = document.getElementById('downloads-cancel-btn'); const statusText = document.getElementById('search-status-text'); const spinner = document.querySelector('.spinner-animation'); const dots = document.querySelector('.dots-animation'); // --- Start a new AbortController for this search --- searchAbortController = new AbortController(); try { // --- 1. Update UI to "Searching" State --- searchInput.disabled = true; searchButton.disabled = true; cancelButton.classList.remove('hidden'); spinner.classList.remove('hidden'); dots.classList.remove('hidden'); statusText.textContent = `Searching for '${query}'...`; displayDownloadsResults([]); // Clear previous results // --- 2. Perform the Fetch Request --- const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), signal: searchAbortController.signal // Link fetch to the AbortController }); const data = await response.json(); if (data.error) { throw new Error(data.error); } const results = data.results || []; allSearchResults = results; resetFilters(); applyFiltersAndSort(); // --- 3. Update UI with Success State --- if (results.length === 0) { statusText.textContent = `No results found for '${query}'`; 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++; } else { totalTracks++; } }); statusText.textContent = `โœจ Found ${results.length} results โ€ข ${totalAlbums} albums, ${totalTracks} singles`; showToast(`Found ${results.length} results`, 'success'); } } catch (error) { // --- 4. Handle Errors, Including Cancellation --- if (error.name === 'AbortError') { // This specific error is thrown when the user clicks "Cancel" statusText.textContent = 'Search was cancelled.'; showToast('Search cancelled', 'info'); displayDownloadsResults([]); // Clear any partial results } else { console.error('Search failed:', error); statusText.textContent = `Search failed: ${error.message}`; showToast('Search failed', 'error'); } } finally { // --- 5. Clean Up UI Regardless of Outcome --- searchInput.disabled = false; searchButton.disabled = false; cancelButton.classList.add('hidden'); spinner.classList.add('hidden'); dots.classList.add('hidden'); searchAbortController = null; // Clear the controller } } 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) { 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) { result.tracks.forEach((track, trackIndex) => { const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : ''; trackListHtml += `
${escapeHtml(track.title || `Track ${trackIndex + 1}`)}
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} โ€ข ${trackSize} โ€ข ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
`; }); } html += `
โ–ถ
๐Ÿ’ฟ
${escapeHtml(result.album_title || result.title || 'Unknown Album')}
by ${escapeHtml(result.artist || 'Unknown Artist')}
${trackCount} tracks โ€ข ${totalSize} โ€ข ${escapeHtml(result.quality || 'Mixed')}
Shared by ${escapeHtml(result.username || 'Unknown')}
`; } else { const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; const bitrateText = result.bitrate ? `${result.bitrate}kbps` : ''; html += `
๐ŸŽต
${escapeHtml(result.title || 'Unknown Title')}
by ${escapeHtml(result.artist || 'Unknown Artist')}
${sizeText} โ€ข ${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
Shared by ${escapeHtml(result.username || 'Unknown')}
`; } }); resultsArea.innerHTML = html; // Store results globally for download functions window.currentSearchResults = 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 { showToast(`Download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Download error:', error); showToast('Failed to start download', 'error'); } } 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 { showToast(`Album download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Album download error:', error); showToast('Failed to start album download', 'error'); } } // Matched download functions 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); } 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); } 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); } 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'; indicator.textContent = 'โ–ผ'; albumCard.classList.add('expanded'); } else { // Collapse trackList.style.display = 'none'; indicator.textContent = 'โ–ถ'; albumCard.classList.remove('expanded'); } } 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...track, result_type: 'track' }) }); const data = await response.json(); if (data.success) { showToast(`Download started: ${track.title}`, 'success'); } else { showToast(`Track download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Track download error:', error); showToast('Failed to start track download', 'error'); } } // =============================== // STREAMING WRAPPER FUNCTIONS // =============================== async function streamTrack(index) { // Stream a single track from search results 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); // Check for unsupported formats before streaming if (result.filename && !isAudioFormatSupported(result.filename)) { const format = getFileExtension(result.filename); showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error'); return; } await startStream(result); } catch (error) { console.error('Track streaming error:', error); showToast('Failed to start track stream', 'error'); } } async function streamAlbumTrack(albumIndex, trackIndex) { // Stream a specific track from an album 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, username: track.username || album.username, filename: track.filename || track.path, artist: track.artist || album.artist, album: track.album || album.title || album.album }; console.log(`๐ŸŽต Enhanced track data:`, trackData); // Check for unsupported formats before streaming if (trackData.filename && !isAudioFormatSupported(trackData.filename)) { const format = getFileExtension(trackData.filename); 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'); } } 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)}` : '
๐ŸŽต
' }
${escapeHtml(artist.name)}
${artist.album_count || 0} albums
`).join(''); } else { artistsGrid.innerHTML = '
No artists found
'; } } catch (error) { console.error('Error loading artists data:', error); document.getElementById('artists-grid').innerHTML = '
Error loading artists
'; } } // =============================== // UTILITY FUNCTIONS // =============================== function showLoadingOverlay(message = 'Loading...') { const overlay = document.getElementById('loading-overlay'); const messageElement = overlay.querySelector('.loading-message'); messageElement.textContent = message; overlay.classList.remove('hidden'); } function hideLoadingOverlay() { document.getElementById('loading-overlay').classList.add('hidden'); } function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); 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)) { container.removeChild(toast); } }, 3000); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatArtists(artists) { if (!artists || !Array.isArray(artists)) { return 'Unknown Artist'; } // Handle both string arrays and object arrays with 'name' property const artistNames = artists.map(artist => { if (typeof artist === 'string') { return artist; } else if (artist && typeof artist === 'object' && artist.name) { return artist.name; } else { return 'Unknown Artist'; } }); return artistNames.join(', ') || 'Unknown Artist'; } 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'); } } function closeVersionModal() { const modalOverlay = document.getElementById('version-modal-overlay'); modalOverlay.classList.add('hidden'); console.log('Version modal closed'); } function populateVersionModal(versionData) { const container = document.getElementById('version-content-container'); if (!container) { 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'); usageDiv.className = 'version-usage-note'; usageDiv.textContent = `๐Ÿ’ก ${section.usage_note}`; sectionDiv.appendChild(usageDiv); } container.appendChild(sectionDiv); }); console.log('Version modal content populated'); } // =============================== // ADDITIONAL STYLES FOR SEARCH RESULTS // =============================== // Add dynamic styles for search results (since they're created dynamically) const additionalStyles = ` `; // Inject additional styles document.head.insertAdjacentHTML('beforeend', additionalStyles); // Make functions available globally for onclick handlers window.openMatchingModal = openMatchingModal; window.closeMatchingModal = closeMatchingModal; window.selectArtist = selectArtist; window.selectAlbum = selectAlbum; window.navigateToPage = navigateToPage; window.openKofi = openKofi; window.copyAddress = copyAddress; window.showVersionInfo = showVersionInfo; window.closeVersionModal = closeVersionModal; window.testConnection = testConnection; window.autoDetectPlex = autoDetectPlex; window.autoDetectJellyfin = autoDetectJellyfin; window.autoDetectSlskd = autoDetectSlskd; window.toggleServer = toggleServer; window.authenticateTidal = authenticateTidal; window.browsePath = browsePath; window.selectResult = selectResult; window.startStream = startStream; window.streamTrack = streamTrack; window.streamAlbumTrack = streamAlbumTrack; window.startDownload = startDownload; window.downloadTrack = downloadTrack; window.downloadAlbum = downloadAlbum; window.toggleAlbumExpansion = toggleAlbumExpansion; window.downloadAlbumTrack = downloadAlbumTrack; window.switchDownloadTab = switchDownloadTab; window.cancelDownloadItem = cancelDownloadItem; window.clearFinishedDownloads = clearFinishedDownloads; window.matchedDownloadTrack = matchedDownloadTrack; window.matchedDownloadAlbum = matchedDownloadAlbum; window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack; // Download Missing Tracks Modal functions window.openDownloadMissingModal = openDownloadMissingModal; window.closeDownloadMissingModal = closeDownloadMissingModal; window.startMissingTracksProcess = startMissingTracksProcess; window.cancelAllOperations = cancelAllOperations; window.cancelTrackDownload = cancelTrackDownload; window.handleViewProgressClick = handleViewProgressClick; // Wishlist Modal functions window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal; window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess; window.handleWishlistButtonClick = handleWishlistButtonClick; // Helper functions window.escapeHtml = escapeHtml; window.formatArtists = formatArtists; // APPEND THIS JAVASCRIPT SNIPPET (B) function initializeFilters() { const toggleBtn = document.getElementById('filter-toggle-btn'); const container = document.getElementById('filters-container'); const content = document.getElementById('filter-content'); if (toggleBtn && container && content) { // 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'); toggleBtn.textContent = 'โท Filters'; } else { // Expand the container content.classList.remove('hidden'); // Make sure content is visible for animation container.classList.add('expanded'); toggleBtn.textContent = 'โถ Filters'; } }; } // This part is correct and doesn't need to change document.querySelectorAll('.filter-btn').forEach(button => { button.addEventListener('click', handleFilterClick); }); } function handleFilterClick(event) { const button = event.target; const filterType = button.dataset.filterType; const value = button.dataset.value; if (filterType === 'type') currentFilterType = value; if (filterType === 'format') currentFilterFormat = value; if (filterType === 'sort') currentSortBy = value; if (button.id === 'sort-order-btn') { isSortReversed = !isSortReversed; button.textContent = isSortReversed ? 'โ†‘' : 'โ†“'; } document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => { btn.classList.remove('active'); }); if (filterType) { // Don't try to activate the sort order button button.classList.add('active'); } applyFiltersAndSort(); } function resetFilters() { currentFilterType = 'all'; 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'); document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active'); document.getElementById('sort-order-btn').textContent = 'โ†“'; } function applyFiltersAndSort() { let processedResults = [...allSearchResults]; const query = document.getElementById('downloads-search-input').value.trim().toLowerCase(); // 1. Filter by Type if (currentFilterType !== 'all') { processedResults = processedResults.filter(r => r.result_type === currentFilterType); } // 2. Filter by Format if (currentFilterFormat !== 'all') { processedResults = processedResults.filter(r => { const quality = (r.dominant_quality || r.quality || '').toLowerCase(); return quality === currentFilterFormat; }); } // 3. Sort Results processedResults.sort((a, b) => { let valA, valB; // Special handling for relevance sort if (currentSortBy === 'relevance') { valA = calculateRelevanceScore(a, query); 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; valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1; return valB - valA; } valA = a[currentSortBy] || 0; valB = b[currentSortBy] || 0; if (typeof valA === 'string') { // For name/title sort, use the correct property const titleA = (a.album_title || a.title || '').toLowerCase(); 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', 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); } function calculateRelevanceScore(result, query) { let score = 0.0; const queryTerms = query.split(' ').filter(t => t.length > 1); // 1. Search Term Matching (40%) let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase(); let termMatches = 0; for (const term of queryTerms) { if (searchableText.includes(term)) { termMatches++; } } score += (termMatches / queryTerms.length) * 0.40; // 2. Quality Score (25%) score += (result.quality_score || 0) * 0.25; // 3. User Reliability (Availability & Speed) (20%) const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5; score += reliability * 0.20; // 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) function initializeFilters() { const toggleBtn = document.getElementById('filter-toggle-btn'); const container = document.getElementById('filters-container'); const content = document.getElementById('filter-content'); if (toggleBtn && container && content) { // 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'); toggleBtn.textContent = 'โท Filters'; } else { // Expand the container content.classList.remove('hidden'); // Make sure content is visible for animation container.classList.add('expanded'); toggleBtn.textContent = 'โถ Filters'; } }; } // This part is correct and doesn't need to change document.querySelectorAll('.filter-btn').forEach(button => { button.addEventListener('click', handleFilterClick); }); } function handleFilterClick(event) { const button = event.target; const filterType = button.dataset.filterType; const value = button.dataset.value; if (filterType === 'type') currentFilterType = value; if (filterType === 'format') currentFilterFormat = value; if (filterType === 'sort') currentSortBy = value; if (button.id === 'sort-order-btn') { isSortReversed = !isSortReversed; button.textContent = isSortReversed ? 'โ†‘' : 'โ†“'; } document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => { btn.classList.remove('active'); }); if (filterType) { // Don't try to activate the sort order button button.classList.add('active'); } applyFiltersAndSort(); } function resetFilters() { currentFilterType = 'all'; 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'); document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active'); document.getElementById('sort-order-btn').textContent = 'โ†“'; } function applyFiltersAndSort() { let processedResults = [...allSearchResults]; const query = document.getElementById('downloads-search-input').value.trim().toLowerCase(); // 1. Filter by Type if (currentFilterType !== 'all') { processedResults = processedResults.filter(r => r.result_type === currentFilterType); } // 2. Filter by Format if (currentFilterFormat !== 'all') { processedResults = processedResults.filter(r => { const quality = (r.dominant_quality || r.quality || '').toLowerCase(); return quality === currentFilterFormat; }); } // 3. Sort Results processedResults.sort((a, b) => { let valA, valB; // Special handling for relevance sort if (currentSortBy === 'relevance') { valA = calculateRelevanceScore(a, query); 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; valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1; return valB - valA; } valA = a[currentSortBy] || 0; valB = b[currentSortBy] || 0; if (typeof valA === 'string') { // For name/title sort, use the correct property const titleA = (a.album_title || a.title || '').toLowerCase(); 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', 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); } function calculateRelevanceScore(result, query) { let score = 0.0; const queryTerms = query.split(' ').filter(t => t.length > 1); // 1. Search Term Matching (40%) let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase(); let termMatches = 0; for (const term of queryTerms) { if (searchableText.includes(term)) { termMatches++; } } score += (termMatches / queryTerms.length) * 0.40; // 2. Quality Score (25%) score += (result.quality_score || 0) * 0.25; // 3. User Reliability (Availability & Speed) (20%) const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5; score += reliability * 0.20; // 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; } // Add to global scope for onclick window.handleFilterClick = handleFilterClick; // =============================== // MATCHED DOWNLOADS MODAL // =============================== // Global state for matching modal let currentMatchingData = { searchResult: null, isAlbumDownload: false, albumResult: null, selectedArtist: null, selectedAlbum: null, currentStage: 'artist' // 'artist' or 'album' }; let searchTimers = { artist: null, album: null }; function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) { console.log('๐ŸŽฏ Opening matching modal for:', searchResult); // Store the current matching data currentMatchingData = { searchResult: searchResult, isAlbumDownload: isAlbumDownload, albumResult: albumResult, selectedArtist: null, 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'; document.getElementById('album-selection-stage').style.display = 'block'; } else { modalTitle.textContent = 'Match Download to Spotify'; 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(); } 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, isAlbumDownload: false, albumResult: null, selectedArtist: null, selectedAlbum: null, currentStage: 'artist' }; } 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; currentMatchingData.currentStage = 'artist'; } 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; } async function fetchArtistSuggestions() { try { showLoadingCards('artist-suggestions', 'Finding artist...'); const response = await fetch('/api/match/suggestions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ search_result: currentMatchingData.searchResult, context: 'artist', is_album: currentMatchingData.isAlbumDownload, album_result: currentMatchingData.albumResult }) }); const data = await response.json(); if (data.suggestions) { renderArtistSuggestions(data.suggestions); } else { showNoResultsMessage('artist-suggestions', 'No artist suggestions found'); } } catch (error) { console.error('Error fetching artist suggestions:', error); showNoResultsMessage('artist-suggestions', 'Error loading suggestions'); } } 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' }, body: JSON.stringify({ search_result: currentMatchingData.searchResult, context: 'album', selected_artist: currentMatchingData.selectedArtist }) }); const data = await response.json(); if (data.suggestions) { renderAlbumSuggestions(data.suggestions); } else { showNoResultsMessage('album-suggestions', 'No album suggestions found'); } } catch (error) { console.error('Error fetching album suggestions:', error); showNoResultsMessage('album-suggestions', 'Error loading suggestions'); } } 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); }); } 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); }); } 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 = `
${escapeHtml(artist.name)}
${artist.genres && artist.genres.length ? escapeHtml(artist.genres.slice(0, 2).join(', ')) : 'Artist'}
${confidencePercent}% match
`; // Set background image if available if (imageUrl) { card.style.backgroundImage = `url(${imageUrl})`; card.style.backgroundSize = 'cover'; card.style.backgroundPosition = 'center'; } return card; } 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 = `
${escapeHtml(album.name)}
${album.album_type ? escapeHtml(album.album_type.charAt(0).toUpperCase() + album.album_type.slice(1)) : 'Album'}${year ? ` โ€ข ${year}` : ''}
${confidencePercent}% match
`; // Set background image if available if (imageUrl) { card.style.backgroundImage = `url(${imageUrl})`; card.style.backgroundSize = 'cover'; card.style.backgroundPosition = 'center'; } return card; } function selectArtist(artist) { // Clear previous selections document.querySelectorAll('#artist-suggestions .suggestion-card').forEach(card => { card.classList.remove('selected'); }); 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(); } else { // Enable confirm button for single downloads document.getElementById('confirm-match-btn').disabled = false; } } function selectAlbum(album) { // Clear previous selections document.querySelectorAll('#album-suggestions .suggestion-card').forEach(card => { card.classList.remove('selected'); }); 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; } 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); }, 400); } 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); }, 400); } async function performArtistSearch(query) { try { showLoadingCards('artist-manual-results', 'Searching artists...'); const response = await fetch('/api/match/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: query, context: 'artist' }) }); const data = await response.json(); if (data.results) { renderArtistSearchResults(data.results); } else { showNoResultsMessage('artist-manual-results', 'No artists found'); } } catch (error) { console.error('Error searching artists:', error); showNoResultsMessage('artist-manual-results', 'Error searching artists'); } } 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' }, body: JSON.stringify({ query: query, context: 'album', artist_id: currentMatchingData.selectedArtist.id }) }); const data = await response.json(); if (data.results) { renderAlbumSearchResults(data.results); } else { showNoResultsMessage('album-manual-results', 'No albums found'); } } catch (error) { console.error('Error searching albums:', error); showNoResultsMessage('album-manual-results', 'Error searching albums'); } } function renderArtistSearchResults(results) { const container = document.getElementById('artist-manual-results'); container.innerHTML = ''; results.forEach(result => { const card = createArtistCard(result.artist, result.confidence); container.appendChild(card); }); } 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); }); } function showLoadingCards(containerId, message) { const container = document.getElementById(containerId); container.innerHTML = `
${message}
`; } function showNoResultsMessage(containerId, message) { const container = document.getElementById(containerId); container.innerHTML = `
${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 showToast('โฌ‡๏ธ Starting album download (unmatched)', 'info'); // This would need to be implemented to download all album tracks } else { // Single track download startDownload(window.currentSearchResults.indexOf(currentMatchingData.searchResult)); } } async function confirmMatch() { if (!currentMatchingData.selectedArtist) { showToast('โš ๏ธ Please select an artist first', 'error'); return; } if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) { showToast('โš ๏ธ Please select an album first', 'error'); return; } const confirmBtn = document.getElementById('confirm-match-btn'); const originalText = confirmBtn.textContent; // FIX: Declare outside try block try { console.log('๐ŸŽฏ Confirming match with:', { artist: currentMatchingData.selectedArtist.name, album: currentMatchingData.selectedAlbum?.name }); confirmBtn.disabled = true; confirmBtn.textContent = 'Starting...'; // --- THIS IS THE CRITICAL FIX --- // Determine the correct data to send. For albums, we send the full albumResult // which contains the complete list of tracks. const downloadPayload = currentMatchingData.isAlbumDownload ? currentMatchingData.albumResult : currentMatchingData.searchResult; // --- END OF FIX --- const response = await fetch('/api/download/matched', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ search_result: downloadPayload, // Send the correct payload spotify_artist: currentMatchingData.selectedArtist, spotify_album: currentMatchingData.selectedAlbum || null }) }); const data = await response.json(); if (data.success) { showToast(`๐ŸŽฏ Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); closeMatchingModal(); } else { throw new Error(data.error || 'Failed to start matched download'); } } catch (error) { console.error('Error starting matched download:', error); showToast(`โŒ Error starting matched download: ${error.message}`, 'error'); // Re-enable confirm button on failure confirmBtn.disabled = false; confirmBtn.textContent = originalText; } } function matchedDownloadTrack(trackIndex) { const results = window.currentSearchResults; if (!results || !results[trackIndex]) { console.error('Could not find track for matched download:', trackIndex); showToast('Error preparing matched download.', 'error'); return; } const trackData = results[trackIndex]; // It's a single track, so isAlbumDownload is false and there's no album context. openMatchingModal(trackData, false, null); } function matchedDownloadAlbum(albumIndex) { const results = window.currentSearchResults; if (!results || !results[albumIndex]) { console.error('Could not find album for matched download:', albumIndex); showToast('Error preparing matched download.', 'error'); return; } const albumData = results[albumIndex]; // The first track is used as a reference for the initial artist search. const firstTrack = albumData.tracks ? albumData.tracks[0] : albumData; openMatchingModal(firstTrack, true, albumData); } function matchedDownloadAlbumTrack(albumIndex, trackIndex) { const results = window.currentSearchResults; if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) { console.error('Could not find album track for matched download:', albumIndex, trackIndex); showToast('Error preparing matched download.', 'error'); return; } const albumData = results[albumIndex]; const trackData = albumData.tracks[trackIndex]; // This is the definitive fix. // The second argument MUST be 'false' to treat this as a single track download, // which prevents the modal from asking for an album selection. openMatchingModal(trackData, false, albumData); } // =========================================== // == DASHBOARD DATABASE UPDATER FUNCTIONALITY == // =========================================== // --- State and Polling Management --- function stopDbStatsPolling() { if (dbStatsInterval) { clearInterval(dbStatsInterval); dbStatsInterval = null; } } function stopDbUpdatePolling() { if (dbUpdateStatusInterval) { clearInterval(dbUpdateStatusInterval); dbUpdateStatusInterval = null; } } function stopWishlistCountPolling() { if (wishlistCountInterval) { clearInterval(wishlistCountInterval); wishlistCountInterval = null; } } 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}`); if (beginBtn) { beginBtn.style.display = 'inline-block'; beginBtn.disabled = false; beginBtn.textContent = 'Begin Analysis'; } if (cancelBtn) { cancelBtn.style.display = 'none'; } // 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-${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}`); const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`); if (foundElement) foundElement.textContent = '-'; if (missingElement) missingElement.textContent = '-'; if (downloadedElement) downloadedElement.textContent = '0'; // Reset process status process.status = 'idle'; process.batchId = null; if (process.poller) { clearInterval(process.poller); process.poller = null; } console.log('โœ… Wishlist modal fully reset to idle state'); } else { console.log('โš ๏ธ No wishlist process found to reset'); } } async function loadDashboardData() { // Attach event listeners for the DB updater tool const updateButton = document.getElementById('db-update-button'); if (updateButton) { updateButton.addEventListener('click', handleDbUpdateButtonClick); } // Attach event listener for the wishlist button const wishlistButton = document.getElementById('wishlist-button'); if (wishlistButton) { wishlistButton.addEventListener('click', handleWishlistButtonClick); } // 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); // Also check the status of any ongoing update when the page loads await checkAndUpdateDbProgress(); // Check for any active download processes that need rehydration await checkForActiveProcesses(); // Automatic wishlist processing now runs server-side } // --- Data Fetching and UI Updates --- 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 updateDashboardStatCards(stats); // This function updates the info within the DB Updater tool card updateDbUpdaterCardInfo(stats); } catch (error) { console.warn('Could not fetch DB stats:', error); } } function updateDashboardStatCards(stats) { // You can expand this later to update the main stat cards // For now, we focus on the updater tool itself. } function updateDbUpdaterCardInfo(stats) { // Update the detailed stats within the DB Updater tool card const lastRefreshEl = document.getElementById('db-last-refresh'); const artistsStatEl = document.getElementById('db-stat-artists'); const albumsStatEl = document.getElementById('db-stat-albums'); const tracksStatEl = document.getElementById('db-stat-tracks'); const sizeStatEl = document.getElementById('db-stat-size'); if (lastRefreshEl) { if (stats.last_full_refresh) { const date = new Date(stats.last_full_refresh); lastRefreshEl.textContent = date.toLocaleString(); } else { lastRefreshEl.textContent = 'Never'; } } if (artistsStatEl) artistsStatEl.textContent = stats.artists.toLocaleString() || '0'; 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) { const serverName = stats.server_source.charAt(0).toUpperCase() + stats.server_source.slice(1); toolCardTitle.textContent = `${serverName} Database Updater`; } } // --- Wishlist Count Functions --- 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'); wishlistButton.classList.add('wishlist-inactive'); } else { wishlistButton.classList.remove('wishlist-inactive'); 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); } } 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'); // Check if we need to show the modal (no existing modal or modal is hidden) const needsModalDisplay = !clientWishlistProcess || !clientWishlistProcess.modalElement || clientWishlistProcess.modalElement.style.display === 'none' || !document.body.contains(clientWishlistProcess.modalElement); if (needsModalDisplay) { console.log('๐Ÿค– [Auto-Processing] Auto-showing wishlist modal for auto-processing'); // Rehydrate or create the modal if (clientWishlistProcess && clientWishlistProcess.modalElement && document.body.contains(clientWishlistProcess.modalElement)) { // Just show existing modal clientWishlistProcess.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); } else { // Rehydrate the modal with userRequested=false but show it due to auto-processing await rehydrateModal(serverWishlistProcess, false); // Don't mark as user-requested // But then force show it since it's auto-initiated and should be visible if (activeDownloadProcesses[playlistId] && activeDownloadProcesses[playlistId].modalElement) { activeDownloadProcesses[playlistId].modalElement.style.display = 'flex'; WishlistModalState.setVisible(); console.log('๐Ÿค– [Auto-Processing] Forced display of rehydrated modal for auto-processing'); } } // Show a toast to inform user about auto-processing showToast('Auto-processing wishlist tracks...', 'info', 3000); } } } catch (error) { console.warn('Error checking for auto-initiated wishlist process:', error); } } async function checkAndUpdateDbProgress() { try { const response = await fetch('/api/database/update/status'); if (!response.ok) return; const state = await response.json(); updateDbProgressUI(state); if (state.status === 'running') { // If an update is running, start polling for progress stopDbUpdatePolling(); dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000); } } catch (error) { console.warn('Could not fetch DB update status:', error); } } function updateDbProgressUI(state) { const button = document.getElementById('db-update-button'); const phaseLabel = document.getElementById('db-phase-label'); const progressLabel = document.getElementById('db-progress-label'); const progressBar = document.getElementById('db-progress-bar'); const refreshSelect = document.getElementById('db-refresh-type'); if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return; if (state.status === 'running') { button.textContent = 'Stop Update'; button.disabled = false; refreshSelect.disabled = true; phaseLabel.textContent = state.phase || 'Processing...'; progressLabel.textContent = `${state.processed} / ${state.total} artists (${state.progress.toFixed(1)}%)`; progressBar.style.width = `${state.progress}%`; } else { // idle, finished, or error stopDbUpdatePolling(); button.textContent = 'Update Database'; button.disabled = false; refreshSelect.disabled = false; if (state.status === 'error') { phaseLabel.textContent = `Error: ${state.error_message}`; progressBar.style.backgroundColor = '#ff4444'; // Red for error } else { 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 setTimeout(fetchAndUpdateDbStats, 500); } } } // =================================================================== // TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors) // =================================================================== 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...'; try { const response = await fetch('/api/tidal/playlists'); if (!response.ok) { 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(); } catch (error) { container.innerHTML = `
โŒ Error: ${error.message}
`; showToast(`Error loading Tidal playlists: ${error.message}`, 'error'); } finally { refreshBtn.disabled = false; refreshBtn.textContent = '๐Ÿ”„ Refresh'; } } function renderTidalPlaylists() { const container = document.getElementById('tidal-playlist-container'); if (tidalPlaylists.length === 0) { container.innerHTML = `
No Tidal playlists found.
`; return; } container.innerHTML = tidalPlaylists.map(p => { // Initialize state if not exists (fresh state like sync.py) if (!tidalPlaylistStates[p.id]) { tidalPlaylistStates[p.id] = { phase: 'fresh', playlist: p }; } return createTidalCard(p); }).join(''); // Add click handlers to cards tidalPlaylists.forEach(p => { const card = document.getElementById(`tidal-card-${p.id}`); if (card) { card.addEventListener('click', () => handleTidalCardClick(p.id)); } }); } 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 `
๐ŸŽต
${escapeHtml(playlist.name)}
${playlist.track_count} tracks ${phaseText}
`; } async function handleTidalCardClick(playlistId) { const state = tidalPlaylistStates[playlistId]; if (!state) return; console.log(`๐ŸŽต 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 (like sync.py) openTidalDiscoveryModal(playlistId, state.playlist); } else if (state.phase === 'downloading' || state.phase === 'download_complete') { // Open download modal if we have the converted playlist ID if (state.convertedSpotifyPlaylistId) { console.log(`๐Ÿ” Opening download modal for Tidal playlist: ${state.playlist.name}`); // Check if modal already exists, if not create it if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; if (process.modalElement) { process.modalElement.style.display = 'flex'; } } else { // Need to create the download modal - fetch the discovery results await rehydrateTidalDownloadModal(playlistId, state); } } else { console.error('โŒ No converted Spotify playlist ID found for Tidal download modal'); showToast('Unable to open download modal - missing playlist data', 'error'); } } } async function rehydrateTidalDownloadModal(playlistId, state) { try { console.log(`๐Ÿ’ง 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}`); const stateResponse = await fetch(`/api/tidal/state/${playlistId}`); if (stateResponse.ok) { const fullState = await stateResponse.json(); state.discovery_results = fullState.discovery_results; state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; state.download_process_id = fullState.download_process_id; console.log(`โœ… Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`); } else { console.error('โŒ Failed to fetch Tidal discovery results from backend'); showToast('Error loading playlist data', 'error'); return; } } // Extract Spotify tracks from discovery results const spotifyTracks = []; for (const result of state.discovery_results) { if (result.spotify_data) { 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 = `[Tidal] ${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}`); } else if (state.phase === 'download_complete') { if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; console.log(`โœ… Showing completed Tidal download results: ${state.download_process_id}`); } } } 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'); } } 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 newCardHtml = createTidalCard(state.playlist); card.outerHTML = newCardHtml; // Re-attach click handler const newCard = document.getElementById(`tidal-card-${playlistId}`); if (newCard) { newCard.addEventListener('click', () => handleTidalCardClick(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(() => { updateTidalCardSyncProgress(playlistId, state.lastSyncProgress); }, 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; if (isAlreadyDiscovered && tidalCardState.discovery_results) { transformedResults = tidalCardState.discovery_results.map((result, index) => { const isFound = result.status === 'found'; if (isFound) actualMatches++; return { index: index, yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown', yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', status: isFound ? 'โœ… Found' : 'โŒ Not Found', status_class: isFound ? 'found' : 'not-found', spotify_track: result.spotify_data ? result.spotify_data.name : '-', spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-', spotify_album: result.spotify_data ? result.spotify_data.album : '-' }; }); console.log(`๐ŸŽต Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); } // Create YouTube-compatible state structure const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh'; youtubePlaylistStates[fakeUrlHash] = { phase: modalPhase, playlist: { name: playlistData.name, tracks: playlistData.tracks }, is_tidal_playlist: true, // Flag to identify this as Tidal tidal_playlist_id: playlistId, discovery_progress: isAlreadyDiscovered ? 100 : 0, spotify_matches: isAlreadyDiscovered ? actualMatches : 0, // Backend format (snake_case) spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, // Frontend format (camelCase) - for button logic spotify_total: playlistData.tracks.length, discovery_results: transformedResults, 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'); } } else if (isCurrentlyDiscovering) { // Resume polling if discovery is already in progress (like YouTube) console.log(`๐Ÿ”„ Resuming Tidal discovery polling for: ${playlistData.name}`); startTidalDiscoveryPolling(fakeUrlHash, playlistId); } else if (tidalCardState && tidalCardState.phase === 'syncing') { // Resume sync polling if sync is in progress console.log(`๐Ÿ”„ Resuming Tidal sync polling for: ${playlistData.name}`); startTidalSyncPolling(fakeUrlHash); } 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, spotify_matches: status.spotify_matches, spotify_total: status.spotify_total, results: status.results.map((result, index) => ({ index: index, yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown', yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', status: result.status === 'found' ? 'โœ… Found' : 'โŒ Not Found', status_class: result.status === 'found' ? 'found' : 'not-found', spotify_track: result.spotify_data ? result.spotify_data.name : '-', spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-', spotify_album: result.spotify_data ? result.spotify_data.album : '-' })) }; // Update fake YouTube state with Tidal discovery results const state = youtubePlaylistStates[fakeUrlHash]; if (state) { state.discovery_progress = status.progress; // Backend format state.discoveryProgress = status.progress; // Frontend format - for modal progress display state.spotify_matches = status.spotify_matches; // Backend format state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic 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; tidalPlaylistStates[playlistId].discovery_results = status.results; tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches; 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; } 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') && 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 { // Get the playlist data const playlistData = tidalPlaylists.find(p => p.id === stateInfo.playlist_id); if (!playlistData) { 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, `[Tidal] ${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 { console.warn(`โš ๏ธ No Spotify tracks found for Tidal playlist rehydration: ${stateInfo.playlist_id}`); } } catch (error) { console.error(`โŒ Error rehydrating Tidal download modal for ${stateInfo.playlist_id}:`, error); } } } } console.log('โœ… Tidal playlist states loaded and applied'); } catch (error) { console.error('โŒ Error loading Tidal playlist states:', error); } } 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 tidalPlaylistStates[playlist_id] = { playlist: playlistData, phase: 'fresh' }; } // Update with backend state tidalPlaylistStates[playlist_id].phase = phase; tidalPlaylistStates[playlist_id].discovery_progress = discovery_progress; tidalPlaylistStates[playlist_id].spotify_matches = spotify_matches; tidalPlaylistStates[playlist_id].discovery_results = discovery_results; 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 { console.log(`๐Ÿ” Fetching full discovery results for Tidal playlist: ${playlistData.name}`); const stateResponse = await fetch(`/api/tidal/state/${playlist_id}`); 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; tidalPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; tidalPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; tidalPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; console.log(`โœ… Restored ${fullState.discovery_results.length} discovery results for Tidal playlist: ${playlistData.name}`); } } else { console.warn(`โš ๏ธ Could not fetch full discovery results for Tidal playlist: ${playlistData.name}`); } } catch (error) { 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 = { spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, spotify_matches: tidalPlaylistStates[playlist_id].spotify_matches || 0 }; updateTidalCardProgress(playlist_id, progressInfo); } console.log(`โœ… Applied saved state for Tidal playlist: ${playlist_id} -> ${phase}`); } catch (error) { console.error(`โŒ Error applying Tidal playlist state for ${playlist_id}:`, error); } } 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}%)`); } // =============================== // TIDAL SYNC FUNCTIONALITY // =============================== 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'); } } function startTidalSyncPolling(urlHash) { // Stop any existing polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); } const state = youtubePlaylistStates[urlHash]; const playlistId = state.tidal_playlist_id; const pollInterval = setInterval(async () => { 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'; } 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'; } 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); clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; } }, 1000); activeYouTubePollers[urlHash] = pollInterval; } 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'); } } 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) { const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; const total = progress.total_tracks || 0; const processed = matched + failed; const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; statusCounterHTML = `
โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${percentage}%)
`; } progressElement.innerHTML = statusCounterHTML || '
๐Ÿ”„ Starting...
'; 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); } } async function startTidalDownloadMissing(urlHash) { try { console.log('๐Ÿ” Starting download missing tracks for Tidal playlist:', urlHash); const state = youtubePlaylistStates[urlHash]; if (!state || !state.is_tidal_playlist) { console.error('โŒ Invalid Tidal playlist state for download'); return; } // Get the actual Tidal playlist ID const tidalPlaylistId = state.tidal_playlist_id; const tidalState = tidalPlaylistStates[tidalPlaylistId]; if (!tidalState || !tidalState.discovery_results) { showToast('No discovery results available for download', 'error'); return; } // Convert Tidal discovery results to Spotify tracks format (same as YouTube) const spotifyTracks = []; for (const result of tidalState.discovery_results) { if (result.spotify_data) { spotifyTracks.push(result.spotify_data); } } if (spotifyTracks.length === 0) { showToast('No Spotify matches found for download', 'error'); return; } // Create a virtual playlist for the download system const virtualPlaylistId = `tidal_${tidalPlaylistId}`; const playlistName = `[Tidal] ${tidalState.playlist.name}`; // Store reference for card navigation (same as YouTube) tidalState.convertedSpotifyPlaylistId = virtualPlaylistId; 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.style.display = 'none'; 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'); } } async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) { // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); const process = activeDownloadProcesses[virtualPlaylistId]; if (process.modalElement) { if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; } return; } 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'; modal.style.display = 'none'; document.body.appendChild(modal); // Register the new process in our global state tracker using the same structure as Spotify activeDownloadProcesses[virtualPlaylistId] = { status: 'idle', modalElement: modal, poller: null, batchId: null, playlist: virtualPlaylist, tracks: spotifyTracks }; // Use the exact same modal HTML structure as the existing Spotify modal modal.innerHTML = `

Download Missing Tracks - ${escapeHtml(playlistName)}

×
${spotifyTracks.length}
Total Tracks
-
Found in Library
-
Missing Tracks
0
Downloaded
๐Ÿ” Library Analysis Ready to start
โฌ Downloads Waiting for analysis

๐Ÿ“‹ Track Analysis & Download Status

${spotifyTracks.map((track, index) => ` `).join('')}
# Track Artist Duration Library Match Download Status Actions
${index + 1} ${escapeHtml(track.name)} ${track.artists.join(', ')} ${formatDuration(track.duration_ms)} ๐Ÿ” Pending - -
`; modal.style.display = 'flex'; } // =============================== // SYNC PAGE FUNCTIONALITY (REDESIGNED) // =============================== function initializeSyncPage() { // Logic for tab switching const tabButtons = document.querySelectorAll('.sync-tab-button'); tabButtons.forEach(button => { button.addEventListener('click', () => { const tabId = button.dataset.tab; // Update button active state tabButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); // Update content active state document.querySelectorAll('.sync-tab-content').forEach(content => { content.classList.remove('active'); }); document.getElementById(`${tabId}-tab-content`).classList.add('active'); }); }); // Logic for the Spotify refresh button const refreshBtn = document.getElementById('spotify-refresh-btn'); if (refreshBtn) { // Remove any old listeners to be safe, then add the new one refreshBtn.removeEventListener('click', loadSpotifyPlaylists); refreshBtn.addEventListener('click', loadSpotifyPlaylists); } // Logic for the Tidal refresh button const tidalRefreshBtn = document.getElementById('tidal-refresh-btn'); if (tidalRefreshBtn) { tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists); tidalRefreshBtn.addEventListener('click', loadTidalPlaylists); } // Logic for the Start Sync button const startSyncBtn = document.getElementById('start-sync-btn'); 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) { youtubeUrlInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { parseYouTubePlaylist(); } }); } } // --- Event Handlers --- // --- Find and REPLACE the existing handleDbUpdateButtonClick function --- async function handleDbUpdateButtonClick() { const button = document.getElementById('db-update-button'); const currentAction = button.textContent; if (currentAction === 'Update Database') { const refreshSelect = document.getElementById('db-refresh-type'); const isFullRefresh = refreshSelect.value === 'full'; if (isFullRefresh) { // Replicates the QMessageBox confirmation from the GUI const confirmed = confirm("โš ๏ธ Full Refresh Warning!\n\nThis will clear and rebuild the database for the active server. It can take a long time. Are you sure you want to proceed?"); if (!confirmed) return; } try { button.disabled = true; button.textContent = 'Starting...'; const response = await fetch('/api/database/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ full_refresh: isFullRefresh }) }); if (response.ok) { showToast('Database update started!', 'success'); // Start polling immediately to get live status checkAndUpdateDbProgress(); } else { const errorData = await response.json(); showToast(`Error: ${errorData.error}`, 'error'); button.disabled = false; button.textContent = 'Update Database'; } } catch (error) { showToast('Failed to start update process.', 'error'); button.disabled = false; button.textContent = 'Update Database'; } } else { // "Stop Update" try { const response = await fetch('/api/database/update/stop', { method: 'POST' }); if (response.ok) { showToast('Stop request sent.', 'info'); } else { showToast('Failed to send stop request.', 'error'); } } catch (error) { showToast('Error sending stop request.', 'error'); } } } async function handleWishlistButtonClick() { try { const playlistId = 'wishlist'; // Always check server state first to detect any active wishlist processes const response = await fetch('/api/active-processes'); if (response.ok) { const data = await response.json(); const processes = data.active_processes || []; // Look for active wishlist process on server const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); const clientWishlistProcess = activeDownloadProcesses[playlistId]; if (serverWishlistProcess) { console.log('Server has active wishlist process:', serverWishlistProcess.batch_id); // Check if this is an auto-initiated batch (from auto-processing) const isAutoInitiated = serverWishlistProcess.auto_initiated || false; if (isAutoInitiated) { console.log('๐Ÿค– [Auto-Processing] Detected auto-initiated wishlist batch'); // Clear any user closed state since user is actively requesting to see it WishlistModalState.clearUserClosed(); } // Check if client state is properly synchronized with server const isClientStateMismatched = !clientWishlistProcess || clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || clientWishlistProcess.status !== 'running'; const isModalElementMissing = !clientWishlistProcess?.modalElement || !document.body.contains(clientWishlistProcess.modalElement); if (isClientStateMismatched || isModalElementMissing) { console.log('Server/client wishlist state mismatch or missing modal, rehydrating...'); console.log('Client batch ID:', clientWishlistProcess?.batchId, 'Server batch ID:', serverWishlistProcess.batch_id); console.log('Client status:', clientWishlistProcess?.status, 'Modal exists:', !isModalElementMissing); await rehydrateModal(serverWishlistProcess, true); // Force rehydration with user request return; } else { // Client state is properly synchronized, just show existing modal console.log('Found properly synced wishlist process, showing modal'); if (clientWishlistProcess.modalElement) { clientWishlistProcess.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); // Track that modal is now visible return; } } } } // No active wishlist process on server, proceed with normal modal creation // First check if there are any tracks in the wishlist const countResponse = await fetch('/api/wishlist/count'); const countData = await countResponse.json(); if (countData.count === 0) { showToast('Wishlist is empty. No tracks to download.', 'info'); return; } // Open the wishlist download modal for new process console.log('No active server process, creating new wishlist modal'); await openDownloadMissingWishlistModal(); } catch (error) { console.error('Error handling wishlist button click:', error); showToast(`Error opening wishlist: ${error.message}`, 'error'); } } async function clearWishlist(playlistId) { try { // Show confirmation dialog const confirmed = confirm( "Clear Wishlist\n\n" + "Are you sure you want to clear the entire wishlist?\n\n" + "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', headers: { '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'); } finally { // Re-enable the clear button const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`); if (clearBtn) { clearBtn.disabled = false; clearBtn.textContent = '๐Ÿ—‘๏ธ Clear Wishlist'; } } } // =============================== // YOUTUBE PLAYLIST FUNCTIONALITY // =============================== 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', headers: { 'Content-Type': 'application/json' }, 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'); removeYouTubeCard(url); } } 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 = `
โ–ถ
Parsing YouTube playlist...
-- tracks Loading...
`; container.insertAdjacentHTML('beforeend', cardHtml); // Store temporary state youtubePlaylistStates[tempHash] = { phase: phase, url: url, cardElement: document.getElementById(`youtube-card-${tempHash}`), tempHash: tempHash }; console.log('๐Ÿƒ Created YouTube card for URL:', url); } function updateYouTubeCardData(urlHash, playlistData) { // Find the card by URL or temp hash let state = youtubePlaylistStates[urlHash]; if (!state) { // Look for temporary card by URL const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url); if (tempState) { // Update the state with real hash 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'; phaseTextElement.style.color = '#999'; actionBtn.textContent = 'Start Discovery'; actionBtn.disabled = false; progressElement.classList.add('hidden'); break; case 'discovering': phaseTextElement.textContent = 'Discovering...'; phaseTextElement.style.color = '#ffa500'; // Orange actionBtn.textContent = 'View Progress'; actionBtn.disabled = false; progressElement.classList.remove('hidden'); break; case 'discovered': phaseTextElement.textContent = 'Discovery Complete'; phaseTextElement.style.color = '#1db954'; // Green actionBtn.textContent = 'View Details'; actionBtn.disabled = false; progressElement.classList.add('hidden'); break; case 'syncing': phaseTextElement.textContent = 'Syncing...'; phaseTextElement.style.color = '#ffa500'; // Orange actionBtn.textContent = 'View Progress'; actionBtn.disabled = false; progressElement.classList.remove('hidden'); break; case 'sync_complete': phaseTextElement.textContent = 'Sync Complete'; phaseTextElement.style.color = '#1db954'; // Green actionBtn.textContent = 'View Details'; actionBtn.disabled = false; progressElement.classList.add('hidden'); break; case 'downloading': phaseTextElement.textContent = 'Downloading...'; phaseTextElement.style.color = '#ffa500'; // Orange actionBtn.textContent = 'View Downloads'; actionBtn.disabled = false; progressElement.classList.remove('hidden'); break; case 'download_complete': phaseTextElement.textContent = 'Download Complete'; phaseTextElement.style.color = '#1db954'; // Green actionBtn.textContent = 'View Results'; actionBtn.disabled = false; 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 console.log('๐ŸŽฌ Starting YouTube discovery for first time:', urlHash); updateYouTubeCardPhase(urlHash, 'discovering'); startYouTubeDiscovery(urlHash); openYouTubeDiscoveryModal(urlHash); break; case 'discovering': case 'discovered': case 'syncing': case 'sync_complete': // Open discovery modal with current state console.log('๐ŸŽฌ Opening YouTube discovery modal:', urlHash); openYouTubeDiscoveryModal(urlHash); break; case 'downloading': case 'download_complete': // Open download missing tracks modal console.log('๐ŸŽฌ Opening download modal for YouTube playlist:', urlHash); // Need to get playlist ID from converted Spotify data const spotifyPlaylistId = state.convertedSpotifyPlaylistId; if (spotifyPlaylistId) { // Check if we have discovery results, if not load them first if (!state.discoveryResults || state.discoveryResults.length === 0) { console.log('๐Ÿ” Loading discovery results for download modal...'); fetch(`/api/youtube/state/${urlHash}`) .then(response => response.json()) .then(fullState => { 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 = `[YouTube] ${state.playlist.name}`; const spotifyTracks = state.discoveryResults .filter(result => result.spotify_data) .map(result => result.spotify_data); openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks); } else { console.error('โŒ No discovery results found for downloads'); showToast('Unable to open download modal - no discovery data', 'error'); } }) .catch(error => { console.error('โŒ Error loading discovery results:', error); showToast('Error loading playlist data', 'error'); }); } else { // Use the YouTube-specific function to maintain proper state linking const playlistName = `[YouTube] ${state.playlist.name}`; const spotifyTracks = state.discoveryResults .filter(result => result.spotify_data) .map(result => result.spotify_data); openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks); } } else { console.error('โŒ No converted Spotify playlist ID found for downloads'); showToast('Unable to open download modal - missing playlist data', 'error'); } break; } } 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}%)`); } 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]; } else if (state.tempHash) { 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'; } } 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'); } } function startYouTubeDiscoveryPolling(urlHash) { // Stop any existing polling 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) { state.discoveryResults = status.results || []; 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; } function stopYouTubeDiscoveryPolling(urlHash) { if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; console.log('โน Stopped YouTube discovery polling for:', urlHash); } } function openYouTubeDiscoveryModal(urlHash) { const state = youtubePlaylistStates[urlHash]; if (!state || !state.playlist) { console.error('โŒ No YouTube playlist data found for hash:', urlHash); return; } console.log('๐ŸŽต Opening YouTube discovery modal for:', state.playlist.name); // Check if modal already exists let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (modal) { // Modal exists, just show it modal.classList.remove('hidden'); console.log('๐Ÿ”„ Showing existing modal with preserved state'); // Resume polling if discovery is in progress if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) { console.log('๐Ÿ”„ Resuming discovery polling...'); startYouTubeDiscoveryPolling(urlHash); } } else { // Create new modal (support both YouTube and Tidal like sync.py) const isTidal = state.is_tidal_playlist; const modalTitle = isTidal ? '๐ŸŽต Tidal Playlist Discovery' : '๐ŸŽต YouTube Playlist Discovery'; const sourceLabel = isTidal ? 'Tidal' : '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 = { progress: state.discoveryProgress || 0, spotify_matches: state.spotifyMatches || 0, spotify_total: state.playlist.tracks.length, results: state.discoveryResults }; updateYouTubeDiscoveryModal(urlHash, progressData); } console.log('โœจ Created new modal with current state'); } } function getModalActionButtons(urlHash, phase, state = null) { // Get state if not provided if (!state) { state = youtubePlaylistStates[urlHash]; } const isTidal = state && state.is_tidal_playlist; // Validate data availability for buttons const hasDiscoveryResults = state && state.discoveryResults && state.discoveryResults.length > 0; const hasSpotifyMatches = state && state.spotifyMatches > 0; const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId; switch (phase) { case 'discovered': // Only show buttons if we actually have discovery data if (!hasDiscoveryResults) { return ``; } let buttons = ''; // Only show sync button if there are Spotify matches if (hasSpotifyMatches) { if (isTidal) { buttons += ``; } else { buttons += ``; } } // Only show download button if we have matches or a converted playlist ID if (hasSpotifyMatches || hasConvertedPlaylistId) { if (isTidal) { buttons += ``; } else { buttons += ``; } } if (!buttons) { buttons = ``; } return buttons; case 'syncing': if (isTidal) { return `
โ™ช 0 / โœ“ 0 / โœ— 0 (0%)
`; } else { return `
โ™ช 0 / โœ“ 0 / โœ— 0 (0%)
`; } case 'sync_complete': let syncCompleteButtons = ''; // Only show sync button if there are Spotify matches if (hasSpotifyMatches) { if (isTidal) { syncCompleteButtons += ``; } else { syncCompleteButtons += ``; } } // Only show download button if we have matches or a converted playlist ID if (hasSpotifyMatches || hasConvertedPlaylistId) { if (isTidal) { syncCompleteButtons += ``; } else { syncCompleteButtons += ``; } } if (isTidal) { // Tidal doesn't have a reset function yet, but could be added // syncCompleteButtons += ``; } else { syncCompleteButtons += ``; } return syncCompleteButtons; default: return ''; } } function getModalDescription(phase, isTidal = false) { const source = isTidal ? 'Tidal' : 'YouTube'; switch (phase) { case 'fresh': return `Ready to discover clean Spotify metadata for ${source} tracks...`; case 'discovering': return `Discovering clean Spotify metadata for ${source} tracks...`; case 'discovered': return 'Discovery complete! View the results below.'; default: return `Discovering clean Spotify metadata for ${source} tracks...`; } } function getInitialProgressText(phase, isTidal = false) { switch (phase) { case 'fresh': return 'Click Start Discovery to begin...'; case 'discovering': return 'Starting discovery...'; case 'discovered': return 'Discovery completed!'; default: return 'Starting discovery...'; } } function generateTableRowsFromState(state, urlHash) { const isTidal = state.is_tidal_playlist; if (state.discoveryResults && state.discoveryResults.length > 0) { // Generate rows from existing discovery results return state.discoveryResults.map((result, index) => ` ${result.yt_track} ${result.yt_artist} ${result.status} ${result.spotify_track || '-'} ${result.spotify_artist || '-'} ${result.spotify_album || '-'} ${isTidal ? '' : `${result.duration}`} `).join(''); } else { // Generate initial rows from playlist tracks return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash); } } function generateInitialTableRows(tracks, isTidal = false, urlHash = '') { return tracks.map((track, index) => ` ${track.name} ${track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'} ๐Ÿ” Pending... - - - ${isTidal ? '' : `${formatDuration(track.duration_ms)}`} `).join(''); } function formatDuration(durationMs) { if (!durationMs) return '0:00'; const minutes = Math.floor(durationMs / 60000); const seconds = Math.floor((durationMs % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; } function updateYouTubeDiscoveryModal(urlHash, status) { const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`); const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`); const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`); if (!progressBar || !progressText || !tableBody) { console.warn(`โš ๏ธ Missing modal elements for ${urlHash}:`, { progressBar: !!progressBar, progressText: !!progressText, tableBody: !!tableBody }); return; } // Update progress bar progressBar.style.width = `${status.progress}%`; progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`; // Update table rows status.results.forEach(result => { const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`); if (!row) return; const statusCell = row.querySelector('.discovery-status'); const spotifyTrackCell = row.querySelector('.spotify-track'); const spotifyArtistCell = row.querySelector('.spotify-artist'); const spotifyAlbumCell = row.querySelector('.spotify-album'); statusCell.textContent = result.status; statusCell.className = `discovery-status ${result.status_class}`; spotifyTrackCell.textContent = result.spotify_track || '-'; spotifyArtistCell.textContent = result.spotify_artist || '-'; spotifyAlbumCell.textContent = result.spotify_album || '-'; }); // Update action buttons if discovery is complete (progress = 100%) if (status.progress >= 100) { const state = youtubePlaylistStates[urlHash]; if (state && state.phase === 'discovered') { const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); if (actionButtonsContainer) { actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); console.log(`โœจ Updated action buttons for completed discovery: ${urlHash}`); } } } } function refreshYouTubeDiscoveryModalTable(urlHash) { const state = youtubePlaylistStates[urlHash]; if (!state || !state.modalElement) { console.warn(`โš ๏ธ Cannot refresh modal table: no state or modal for ${urlHash}`); return; } console.log(`๐Ÿ”„ Refreshing modal table with ${state.discoveryResults?.length || 0} discovery results`); // Update the table body with new discovery results const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`); if (tableBody) { tableBody.innerHTML = generateTableRowsFromState(state, urlHash); console.log(`โœ… Modal table refreshed with discovery data`); } else { console.warn(`โš ๏ธ Could not find table body for modal ${urlHash}`); } // Update the progress bar and footer buttons too if (state.discoveryResults && state.discoveryResults.length > 0) { const progressData = { progress: state.discoveryProgress || 100, spotify_matches: state.spotifyMatches || 0, spotify_total: state.playlist.tracks.length, results: state.discoveryResults }; updateYouTubeDiscoveryModal(urlHash, progressData); } } function closeYouTubeDiscoveryModal(urlHash) { const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (modal) { // Hide modal instead of removing it to preserve state modal.classList.add('hidden'); console.log('๐Ÿšช Hidden YouTube discovery modal (preserving state):', urlHash); } // Keep modal reference and all state intact // Discovery polling continues in background if active } // =============================== // YOUTUBE SYNC FUNCTIONALITY // =============================== async function startYouTubePlaylistSync(urlHash) { try { console.log('๐Ÿ”„ Starting YouTube playlist sync:', urlHash); const response = await fetch(`/api/youtube/sync/start/${urlHash}`, { 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 updateYouTubeCardPhase(urlHash, 'syncing'); // Update modal buttons if modal is open updateYouTubeModalButtons(urlHash, 'syncing'); // Start sync polling startYouTubeSyncPolling(urlHash); showToast('YouTube playlist sync started!', 'success'); } catch (error) { console.error('โŒ Error starting YouTube sync:', error); showToast(`Error starting sync: ${error.message}`, 'error'); } } function startYouTubeSyncPolling(urlHash) { // Stop any existing polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); } const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/youtube/sync/status/${urlHash}`); const status = await response.json(); if (status.error) { console.error('โŒ Error polling YouTube sync status:', status.error); clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; return; } // Update card progress with sync stats updateYouTubeCardSyncProgress(urlHash, status.progress); // Update modal sync display if open updateYouTubeModalSyncProgress(urlHash, status.progress); // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; // Update card phase to sync complete updateYouTubeCardPhase(urlHash, 'sync_complete'); // Update modal buttons updateYouTubeModalButtons(urlHash, 'sync_complete'); console.log('โœ… YouTube sync complete:', urlHash); showToast('YouTube playlist sync complete!', 'success'); } else if (status.sync_status === 'error') { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; // Revert to discovered phase on error updateYouTubeCardPhase(urlHash, 'discovered'); updateYouTubeModalButtons(urlHash, 'discovered'); showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); } } catch (error) { console.error('โŒ Error polling YouTube sync:', error); clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; } }, 1000); activeYouTubePollers[urlHash] = pollInterval; } async function cancelYouTubeSync(urlHash) { try { console.log('โŒ Cancelling YouTube sync:', urlHash); const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, { 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 updateYouTubeCardPhase(urlHash, 'discovered'); updateYouTubeModalButtons(urlHash, 'discovered'); showToast('YouTube sync cancelled', 'info'); } catch (error) { console.error('โŒ Error cancelling YouTube sync:', error); showToast(`Error cancelling sync: ${error.message}`, 'error'); } } function updateYouTubeCardSyncProgress(urlHash, progress) { const state = youtubePlaylistStates[urlHash]; if (!state || !state.cardElement || !progress) return; const card = state.cardElement; const progressElement = card.querySelector('.playlist-card-progress'); // Build clean status counter HTML exactly like Spotify cards let statusCounterHTML = ''; if (progress && progress.total_tracks > 0) { const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; const total = progress.total_tracks || 0; const processed = matched + failed; const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; statusCounterHTML = `
โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${percentage}%)
`; } progressElement.innerHTML = statusCounterHTML || '
๐Ÿ”„ Starting...
'; console.log(`๐Ÿ”„ Updated YouTube sync progress: โ™ช ${progress?.total_tracks || 0} / โœ“ ${progress?.matched_tracks || 0} / โœ— ${progress?.failed_tracks || 0}`); } function updateYouTubeModalSyncProgress(urlHash, progress) { const statusDisplay = document.getElementById(`youtube-sync-status-${urlHash}`); if (!statusDisplay || !progress) return; console.log(`๐Ÿ“Š Updating YouTube modal sync progress for ${urlHash}:`, progress); // Update individual counters exactly like Spotify sync const totalEl = document.getElementById(`youtube-total-${urlHash}`); const matchedEl = document.getElementById(`youtube-matched-${urlHash}`); const failedEl = document.getElementById(`youtube-failed-${urlHash}`); const percentageEl = document.getElementById(`youtube-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 Spotify sync if (total > 0) { const processed = matched + failed; const percentage = Math.round((processed / total) * 100); if (percentageEl) percentageEl.textContent = percentage; } console.log(`๐Ÿ“Š YouTube modal updated: โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${Math.round((matched + failed) / total * 100)}%)`); } function updateYouTubeModalButtons(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); } } // =============================== // YOUTUBE DOWNLOAD MISSING TRACKS // =============================== async function startYouTubeDownloadMissing(urlHash) { try { console.log('๐Ÿ” Starting download missing tracks for YouTube playlist:', urlHash); const state = youtubePlaylistStates[urlHash]; if (!state || !state.discoveryResults) { showToast('No discovery results available for download', 'error'); return; } // Convert YouTube results to a format compatible with the download modal const spotifyTracks = state.discoveryResults .filter(result => result.spotify_data) .map(result => result.spotify_data); if (spotifyTracks.length === 0) { showToast('No Spotify matches found for download', 'error'); return; } // Create a virtual playlist for the download system const virtualPlaylistId = `youtube_${urlHash}`; const playlistName = `[YouTube] ${state.playlist.name}`; // Store reference for card navigation state.convertedSpotifyPlaylistId = virtualPlaylistId; // Open download missing tracks modal for YouTube playlist await openDownloadMissingModalForYouTube(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'); } } async function resetYouTubePlaylist(urlHash) { const state = youtubePlaylistStates[urlHash]; if (!state) return; try { console.log(`๐Ÿ”„ Resetting YouTube playlist to fresh state: ${state.playlist.name}`); // Call backend reset endpoint const response = await fetch(`/api/youtube/reset/${urlHash}`, { method: 'POST' }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to reset playlist'); } // Stop any active polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } // Update client state to match backend reset state.phase = 'fresh'; state.discoveryResults = []; state.discoveryProgress = 0; state.spotifyMatches = 0; state.syncPlaylistId = null; state.syncProgress = {}; state.convertedSpotifyPlaylistId = null; // Update card to reflect fresh state updateYouTubeCardPhase(urlHash, 'fresh'); updateYouTubeCardProgress(urlHash, { discovery_progress: 0, spotify_matches: 0, spotify_total: state.playlist.tracks.length }); // Close modal closeYouTubeDiscoveryModal(urlHash); showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); console.log(`โœ… Successfully reset YouTube playlist: ${state.playlist.name}`); } catch (error) { console.error(`โŒ Error resetting YouTube playlist:`, error); showToast(`Error resetting playlist: ${error.message}`, 'error'); } } // --- Global Cleanup on Page Unload --- // Note: Automatic wishlist processing now runs server-side and continues even when browser is closed