/** * Download Queue Management - JavaScript Application */ class QueueManager { constructor() { this.socket = null; this.refreshInterval = null; this.pendingProgressUpdates = new Map(); // Store progress updates waiting for cards this.init(); } init() { this.initSocket(); this.bindEvents(); this.initTheme(); // Remove polling - use WebSocket events for real-time updates // this.startRefreshTimer(); // ← REMOVED this.loadQueueData(); // Load initial data once } initSocket() { this.socket = io(); // Handle initial connection message from server this.socket.on('connected', (data) => { console.log('WebSocket connection confirmed', data); }); this.socket.on('connect', () => { console.log('Connected to server'); // Subscribe to rooms for targeted updates this.socket.join('downloads'); this.socket.join('download_progress'); this.showToast('Connected to server', 'success'); }); this.socket.on('disconnect', () => { console.log('Disconnected from server'); this.showToast('Disconnected from server', 'warning'); }); // Queue update events - handle both old and new message types this.socket.on('queue_updated', (data) => { this.updateQueueDisplay(data); }); this.socket.on('queue_status', (data) => { // New backend sends queue_status messages if (data.queue_status) { this.updateQueueDisplay(data.queue_status); } else { this.updateQueueDisplay(data); } }); // Download queue events this.socket.on('download_started', () => { this.showToast('Download queue started', 'success'); // Full reload needed - queue structure changed this.loadQueueData(); }); this.socket.on('queue_started', () => { this.showToast('Download queue started', 'success'); // Full reload needed - queue structure changed this.loadQueueData(); }); this.socket.on('download_progress', (data) => { // Update progress in real-time without reloading all data console.log('Received download progress:', data); this.updateDownloadProgress(data); }); // Handle both old and new download completion events const handleDownloadComplete = (data) => { const serieName = data.serie_name || data.serie || 'Unknown'; const episode = data.episode || ''; this.showToast(`Completed: ${serieName}${episode ? ' - Episode ' + episode : ''}`, 'success'); // Clear any pending progress updates for this download const downloadId = data.item_id || data.download_id || data.id; if (downloadId) { this.pendingProgressUpdates.delete(downloadId); } // Full reload needed - item moved from active to completed this.loadQueueData(); }; this.socket.on('download_completed', handleDownloadComplete); this.socket.on('download_complete', handleDownloadComplete); // Handle both old and new download error events const handleDownloadError = (data) => { const message = data.error || data.message || 'Unknown error'; this.showToast(`Download failed: ${message}`, 'error'); // Clear any pending progress updates for this download const downloadId = data.item_id || data.download_id || data.id; if (downloadId) { this.pendingProgressUpdates.delete(downloadId); } // Full reload needed - item moved from active to failed this.loadQueueData(); }; this.socket.on('download_error', handleDownloadError); this.socket.on('download_failed', handleDownloadError); this.socket.on('download_queue_completed', () => { this.showToast('All downloads completed!', 'success'); // Full reload needed - queue state changed this.loadQueueData(); }); this.socket.on('queue_completed', () => { this.showToast('All downloads completed!', 'success'); // Full reload needed - queue state changed this.loadQueueData(); }); this.socket.on('download_stop_requested', () => { this.showToast('Stopping downloads...', 'info'); }); // Handle both old and new queue stopped events const handleQueueStopped = () => { this.showToast('Download queue stopped', 'success'); // Full reload needed - queue state changed this.loadQueueData(); }; this.socket.on('download_stopped', handleQueueStopped); this.socket.on('queue_stopped', handleQueueStopped); // Handle queue paused/resumed this.socket.on('queue_paused', () => { this.showToast('Queue paused', 'info'); // Full reload needed - queue state changed this.loadQueueData(); }); this.socket.on('queue_resumed', () => { this.showToast('Queue resumed', 'success'); // Full reload needed - queue state changed this.loadQueueData(); }); } bindEvents() { // Theme toggle document.getElementById('theme-toggle').addEventListener('click', () => { this.toggleTheme(); }); // Queue management actions document.getElementById('clear-completed-btn').addEventListener('click', () => { this.clearQueue('completed'); }); document.getElementById('clear-failed-btn').addEventListener('click', () => { this.clearQueue('failed'); }); document.getElementById('clear-pending-btn').addEventListener('click', () => { this.clearQueue('pending'); }); document.getElementById('retry-all-btn').addEventListener('click', () => { this.retryAllFailed(); }); // Download controls document.getElementById('start-queue-btn').addEventListener('click', () => { this.startDownload(); }); document.getElementById('stop-queue-btn').addEventListener('click', () => { this.stopDownloads(); }); // Modal events document.getElementById('close-confirm').addEventListener('click', () => { this.hideConfirmModal(); }); document.getElementById('confirm-cancel').addEventListener('click', () => { this.hideConfirmModal(); }); document.querySelector('#confirm-modal .modal-overlay').addEventListener('click', () => { this.hideConfirmModal(); }); // Logout functionality document.getElementById('logout-btn').addEventListener('click', () => { this.logout(); }); } initTheme() { const savedTheme = localStorage.getItem('theme') || 'light'; this.setTheme(savedTheme); } setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); const themeIcon = document.querySelector('#theme-toggle i'); themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun'; } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; this.setTheme(newTheme); } startRefreshTimer() { // Refresh every 2 seconds this.refreshInterval = setInterval(() => { this.loadQueueData(); }, 2000); } async loadQueueData() { try { const response = await this.makeAuthenticatedRequest('/api/queue/status'); if (!response) return; const data = await response.json(); this.updateQueueDisplay(data); // Process any pending progress updates after queue is loaded this.processPendingProgressUpdates(); } catch (error) { console.error('Error loading queue data:', error); } } updateQueueDisplay(data) { // Update statistics this.updateStatistics(data.statistics, data); // Update active downloads this.renderActiveDownloads(data.active_downloads || []); // Update pending queue this.renderPendingQueue(data.pending_queue || []); // Update completed downloads this.renderCompletedDownloads(data.completed_downloads || []); // Update failed downloads this.renderFailedDownloads(data.failed_downloads || []); // Update button states this.updateButtonStates(data); } updateStatistics(stats, data) { // Ensure stats object exists const statistics = stats || {}; document.getElementById('total-items').textContent = statistics.total_items || 0; document.getElementById('pending-items').textContent = (data.pending_queue || []).length; document.getElementById('completed-items').textContent = statistics.completed_items || 0; document.getElementById('failed-items').textContent = statistics.failed_items || 0; // Update section counts document.getElementById('queue-count').textContent = (data.pending_queue || []).length; document.getElementById('completed-count').textContent = statistics.completed_items || 0; document.getElementById('failed-count').textContent = statistics.failed_items || 0; document.getElementById('current-speed').textContent = statistics.current_speed || '0 MB/s'; document.getElementById('average-speed').textContent = statistics.average_speed || '0 MB/s'; // Format ETA const etaElement = document.getElementById('eta-time'); if (statistics.eta) { const eta = new Date(statistics.eta); const now = new Date(); const diffMs = eta - now; if (diffMs > 0) { const hours = Math.floor(diffMs / (1000 * 60 * 60)); const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); etaElement.textContent = `${hours}h ${minutes}m`; } else { etaElement.textContent = 'Calculating...'; } } else { etaElement.textContent = '--:--'; } } /** * Update download progress in real-time * @param {Object} data - Progress data from WebSocket */ updateDownloadProgress(data) { console.log('updateDownloadProgress called with:', JSON.stringify(data, null, 2)); // Extract download ID - prioritize metadata.item_id (actual item ID) // Progress service sends id with "download_" prefix, but we need the actual item ID let downloadId = null; // First try metadata.item_id (this is the actual download item ID) if (data.metadata && data.metadata.item_id) { downloadId = data.metadata.item_id; } // Fallback to other ID fields if (!downloadId) { downloadId = data.item_id || data.download_id; } // If ID starts with "download_", extract the actual ID if (!downloadId && data.id) { if (data.id.startsWith('download_')) { downloadId = data.id.substring(9); // Remove "download_" prefix } else { downloadId = data.id; } } // Check if data is wrapped in another 'data' property if (!downloadId && data.data) { if (data.data.metadata && data.data.metadata.item_id) { downloadId = data.data.metadata.item_id; } else if (data.data.item_id) { downloadId = data.data.item_id; } else if (data.data.id && data.data.id.startsWith('download_')) { downloadId = data.data.id.substring(9); } else { downloadId = data.data.id || data.data.download_id; } data = data.data; // Use nested data } if (!downloadId) { console.warn('No download ID in progress data'); console.warn('Data structure:', data); console.warn('Available keys:', Object.keys(data)); return; } console.log(`Looking for download card with ID: ${downloadId}`); // Find the download card in active downloads const card = document.querySelector(`[data-download-id="${downloadId}"]`); if (!card) { // Card not found - store update and reload queue console.warn(`Download card not found for ID: ${downloadId}`); // Debug: Log all existing download cards const allCards = document.querySelectorAll('[data-download-id]'); console.log(`Found ${allCards.length} download cards:`); allCards.forEach(c => console.log(` - ${c.getAttribute('data-download-id')}`)); // Store this progress update to retry after queue loads console.log(`Storing progress update for ${downloadId} to retry after reload`); this.pendingProgressUpdates.set(downloadId, data); // Reload queue to sync state console.log('Reloading queue to sync state...'); this.loadQueueData(); return; } console.log(`Found download card for ID: ${downloadId}, updating progress`); // Extract progress information - handle both ProgressService and yt-dlp formats const progress = data.progress || data; const percent = progress.percent || 0; // Check if we have detailed yt-dlp progress (downloaded_mb, total_mb, speed_mbps) // or basic ProgressService progress (current, total) let downloaded, total, speed; if (progress.downloaded_mb !== undefined && progress.total_mb !== undefined) { // yt-dlp detailed format downloaded = progress.downloaded_mb.toFixed(1); total = progress.total_mb.toFixed(1); speed = progress.speed_mbps ? progress.speed_mbps.toFixed(1) : '0.0'; } else if (progress.current !== undefined && progress.total !== undefined) { // ProgressService basic format - convert bytes to MB downloaded = (progress.current / (1024 * 1024)).toFixed(1); total = progress.total > 0 ? (progress.total / (1024 * 1024)).toFixed(1) : 'Unknown'; speed = '0.0'; // Speed not available in basic format } else { // Fallback downloaded = '0.0'; total = 'Unknown'; speed = '0.0'; } // Update progress bar const progressFill = card.querySelector('.progress-fill'); if (progressFill) { progressFill.style.width = `${percent}%`; } // Update progress text const progressInfo = card.querySelector('.progress-info'); if (progressInfo) { const percentSpan = progressInfo.querySelector('span:first-child'); const speedSpan = progressInfo.querySelector('.download-speed'); if (percentSpan) { percentSpan.textContent = `${percent.toFixed(1)}% (${downloaded} MB / ${total} MB)`; } if (speedSpan) { speedSpan.textContent = `${speed} MB/s`; } } console.log(`Updated progress for ${downloadId}: ${percent.toFixed(1)}%`); } processPendingProgressUpdates() { if (this.pendingProgressUpdates.size === 0) { return; } console.log(`Processing ${this.pendingProgressUpdates.size} pending progress updates...`); // Process each pending update const processed = []; for (const [downloadId, data] of this.pendingProgressUpdates.entries()) { // Check if card now exists const card = document.querySelector(`[data-download-id="${downloadId}"]`); if (card) { console.log(`Retrying progress update for ${downloadId}`); this.updateDownloadProgress(data); processed.push(downloadId); } else { console.log(`Card still not found for ${downloadId}, will retry on next reload`); } } // Remove processed updates processed.forEach(id => this.pendingProgressUpdates.delete(id)); if (processed.length > 0) { console.log(`Successfully processed ${processed.length} pending updates`); } } renderActiveDownloads(downloads) { const container = document.getElementById('active-downloads'); if (downloads.length === 0) { container.innerHTML = `
No active downloads
${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}
No items in queue
Add episodes from the main page to start downloading${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}
Added: ${addedAt}No completed downloads
${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}
Completed: ${completedAt} (${duration})No failed downloads
${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}
Failed: ${failedAt} ${retryCount > 0 ? `(Retry ${retryCount})` : ''} ${download.error ? `${this.escapeHtml(download.error)}` : ''}