/** * Download Queue Management - JavaScript Application */ class QueueManager { constructor() { this.socket = null; this.refreshInterval = null; this.isReordering = false; this.draggedElement = null; this.draggedId = null; this.init(); } init() { this.initSocket(); this.bindEvents(); this.initTheme(); this.startRefreshTimer(); this.loadQueueData(); this.initDragAndDrop(); } 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); } }); this.socket.on('download_progress_update', (data) => { this.updateDownloadProgress(data); }); // Download queue events this.socket.on('download_started', () => { this.showToast('Download queue started', 'success'); this.loadQueueData(); // Refresh data }); this.socket.on('queue_started', () => { this.showToast('Download queue started', 'success'); this.loadQueueData(); // Refresh data }); this.socket.on('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'); this.loadQueueData(); // Refresh data }; 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'); this.loadQueueData(); // Refresh data }; this.socket.on('download_error', handleDownloadError); this.socket.on('download_failed', handleDownloadError); this.socket.on('download_queue_completed', () => { this.showToast('All downloads completed!', 'success'); this.loadQueueData(); // Refresh data }); 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'); this.loadQueueData(); // Refresh data }; 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'); this.loadQueueData(); }); this.socket.on('queue_resumed', () => { this.showToast('Queue resumed', 'success'); this.loadQueueData(); }); } bindEvents() { // Theme toggle document.getElementById('theme-toggle').addEventListener('click', () => { this.toggleTheme(); }); // Queue management actions document.getElementById('clear-queue-btn').addEventListener('click', () => { this.clearQueue('pending'); }); document.getElementById('clear-completed-btn').addEventListener('click', () => { this.clearQueue('completed'); }); document.getElementById('clear-failed-btn').addEventListener('click', () => { this.clearQueue('failed'); }); document.getElementById('retry-all-btn').addEventListener('click', () => { this.retryAllFailed(); }); // Download controls document.getElementById('start-queue-btn').addEventListener('click', () => { this.startDownloadQueue(); }); document.getElementById('stop-queue-btn').addEventListener('click', () => { this.stopDownloadQueue(); }); // 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); } 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) { document.getElementById('total-items').textContent = stats.total_items || 0; document.getElementById('pending-items').textContent = (data.pending_queue || []).length; document.getElementById('completed-items').textContent = stats.completed_items || 0; document.getElementById('failed-items').textContent = stats.failed_items || 0; // Update section counts document.getElementById('queue-count').textContent = (data.pending_queue || []).length; document.getElementById('completed-count').textContent = stats.completed_items || 0; document.getElementById('failed-count').textContent = stats.failed_items || 0; document.getElementById('current-speed').textContent = stats.current_speed || '0 MB/s'; document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s'; // Format ETA const etaElement = document.getElementById('eta-time'); if (stats.eta) { const eta = new Date(stats.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 = '--:--'; } } renderActiveDownloads(downloads) { const container = document.getElementById('active-downloads'); if (downloads.length === 0) { container.innerHTML = `

No active downloads

`; return; } container.innerHTML = downloads.map(download => this.createActiveDownloadCard(download)).join(''); } createActiveDownloadCard(download) { const progress = download.progress || {}; const progressPercent = progress.percent || 0; const speed = progress.speed_mbps ? `${progress.speed_mbps.toFixed(1)} MB/s` : '0 MB/s'; const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0 MB'; const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown'; return `

${this.escapeHtml(download.serie_name)}

${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}

${progressPercent.toFixed(1)}% (${downloaded} / ${total}) ${speed}
`; } renderPendingQueue(queue) { const container = document.getElementById('pending-queue'); if (queue.length === 0) { container.innerHTML = `

No items in queue

Add episodes from the main page to start downloading
`; return; } container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join(''); // Re-attach drag and drop event listeners this.attachDragListeners(); } createPendingQueueCard(download, index) { const addedAt = new Date(download.added_at).toLocaleString(); const priorityClass = download.priority === 'high' ? 'high-priority' : ''; return `
${index + 1}

${this.escapeHtml(download.serie_name)}

${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}

Added: ${addedAt}
${download.priority === 'high' ? '' : ''}
`; } renderCompletedDownloads(downloads) { const container = document.getElementById('completed-downloads'); if (downloads.length === 0) { container.innerHTML = `

No completed downloads

`; return; } container.innerHTML = downloads.map(download => this.createCompletedDownloadCard(download)).join(''); } createCompletedDownloadCard(download) { const completedAt = new Date(download.completed_at).toLocaleString(); const duration = this.calculateDuration(download.started_at, download.completed_at); return `

${this.escapeHtml(download.serie_name)}

${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})
`; } renderFailedDownloads(downloads) { const container = document.getElementById('failed-downloads'); if (downloads.length === 0) { container.innerHTML = `

No failed downloads

`; return; } container.innerHTML = downloads.map(download => this.createFailedDownloadCard(download)).join(''); } createFailedDownloadCard(download) { const failedAt = new Date(download.completed_at).toLocaleString(); const retryCount = download.retry_count || 0; return `

${this.escapeHtml(download.serie_name)}

${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)}` : ''}
`; } async removeFailedDownload(downloadId) { await this.removeFromQueue(downloadId); } updateButtonStates(data) { const hasActive = (data.active_downloads || []).length > 0; const hasPending = (data.pending_queue || []).length > 0; const hasFailed = (data.failed_downloads || []).length > 0; const hasCompleted = (data.completed_downloads || []).length > 0; // Enable start button only if there are pending items and no active downloads document.getElementById('start-queue-btn').disabled = !hasPending || hasActive; // Show/hide start/stop buttons based on whether downloads are active if (hasActive) { document.getElementById('start-queue-btn').style.display = 'none'; document.getElementById('stop-queue-btn').style.display = 'inline-flex'; document.getElementById('stop-queue-btn').disabled = false; } else { document.getElementById('stop-queue-btn').style.display = 'none'; document.getElementById('start-queue-btn').style.display = 'inline-flex'; } document.getElementById('clear-queue-btn').disabled = !hasPending; document.getElementById('retry-all-btn').disabled = !hasFailed; document.getElementById('clear-completed-btn').disabled = !hasCompleted; document.getElementById('clear-failed-btn').disabled = !hasFailed; } async clearQueue(type) { const titles = { pending: 'Clear Queue', completed: 'Clear Completed Downloads', failed: 'Clear Failed Downloads' }; const messages = { pending: 'Are you sure you want to clear all pending downloads from the queue?', completed: 'Are you sure you want to clear all completed downloads?', failed: 'Are you sure you want to clear all failed downloads?' }; const confirmed = await this.showConfirmModal(titles[type], messages[type]); if (!confirmed) return; try { if (type === 'completed') { const response = await this.makeAuthenticatedRequest('/api/queue/completed', { method: 'DELETE' }); if (!response) return; const data = await response.json(); this.showToast(`Cleared ${data.count} completed downloads`, 'success'); this.loadQueueData(); } else if (type === 'failed') { const response = await this.makeAuthenticatedRequest('/api/queue/failed', { method: 'DELETE' }); if (!response) return; const data = await response.json(); this.showToast(`Cleared ${data.count} failed downloads`, 'success'); this.loadQueueData(); } else if (type === 'pending') { // Get all pending items const pendingCards = document.querySelectorAll('#pending-queue .download-card.pending'); const itemIds = Array.from(pendingCards).map(card => card.dataset.id).filter(id => id); if (itemIds.length === 0) { this.showToast('No pending items to clear', 'info'); return; } const response = await this.makeAuthenticatedRequest('/api/queue/', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_ids: itemIds }) }); if (!response) return; this.showToast(`Cleared ${itemIds.length} pending items`, 'success'); this.loadQueueData(); } } catch (error) { console.error('Error clearing queue:', error); this.showToast('Failed to clear queue', 'error'); } } async retryDownload(downloadId) { try { const response = await this.makeAuthenticatedRequest('/api/queue/retry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_ids: [downloadId] }) // New API expects item_ids array }); if (!response) return; const data = await response.json(); this.showToast(`Retried ${data.retried_count} download(s)`, 'success'); this.loadQueueData(); } catch (error) { console.error('Error retrying download:', error); this.showToast('Failed to retry download', 'error'); } } async retryAllFailed() { const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?'); if (!confirmed) return; try { // Get all failed download IDs const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed'); const itemIds = Array.from(failedCards).map(card => card.dataset.id).filter(id => id); if (itemIds.length === 0) { this.showToast('No failed downloads to retry', 'info'); return; } const response = await this.makeAuthenticatedRequest('/api/queue/retry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_ids: itemIds }) }); if (!response) return; const data = await response.json(); this.showToast(`Retried ${data.retried_count || itemIds.length} download(s)`, 'success'); this.loadQueueData(); } catch (error) { console.error('Error retrying failed downloads:', error); this.showToast('Failed to retry downloads', 'error'); } } async removeFromQueue(downloadId) { try { const response = await this.makeAuthenticatedRequest(`/api/queue/${downloadId}`, { method: 'DELETE' }); if (!response) return; if (response.status === 204) { this.showToast('Download removed from queue', 'success'); this.loadQueueData(); } else { this.showToast(data.message, 'error'); } } catch (error) { console.error('Error removing from queue:', error); this.showToast('Failed to remove from queue', 'error'); } } calculateDuration(startTime, endTime) { const start = new Date(startTime); const end = new Date(endTime); const diffMs = end - start; const minutes = Math.floor(diffMs / (1000 * 60)); const seconds = Math.floor((diffMs % (1000 * 60)) / 1000); return `${minutes}m ${seconds}s`; } async startDownloadQueue() { try { const response = await this.makeAuthenticatedRequest('/api/queue/start', { method: 'POST' }); if (!response) return; const data = await response.json(); if (data.status === 'success') { this.showToast('Download queue started', 'success'); // Update UI document.getElementById('start-queue-btn').style.display = 'none'; document.getElementById('stop-queue-btn').style.display = 'inline-flex'; document.getElementById('stop-queue-btn').disabled = false; } else { this.showToast(`Failed to start queue: ${data.message}`, 'error'); } } catch (error) { console.error('Error starting download queue:', error); this.showToast('Failed to start download queue', 'error'); } } async stopDownloadQueue() { try { const response = await this.makeAuthenticatedRequest('/api/queue/stop', { method: 'POST' }); if (!response) return; const data = await response.json(); if (data.status === 'success') { this.showToast('Download queue stopped', 'success'); // Update UI document.getElementById('stop-queue-btn').style.display = 'none'; document.getElementById('start-queue-btn').style.display = 'inline-flex'; document.getElementById('start-queue-btn').disabled = false; } else { this.showToast(`Failed to stop queue: ${data.message}`, 'error'); } } catch (error) { console.error('Error stopping download queue:', error); this.showToast('Failed to stop download queue', 'error'); } } initDragAndDrop() { // Initialize drag and drop on the pending queue container const container = document.getElementById('pending-queue'); if (container) { container.addEventListener('dragover', this.handleDragOver.bind(this)); container.addEventListener('drop', this.handleDrop.bind(this)); } } attachDragListeners() { // Attach listeners to all draggable items const items = document.querySelectorAll('.draggable-item'); items.forEach(item => { item.addEventListener('dragstart', this.handleDragStart.bind(this)); item.addEventListener('dragend', this.handleDragEnd.bind(this)); item.addEventListener('dragenter', this.handleDragEnter.bind(this)); item.addEventListener('dragleave', this.handleDragLeave.bind(this)); }); } handleDragStart(e) { this.draggedElement = e.currentTarget; this.draggedId = e.currentTarget.dataset.id; e.currentTarget.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', e.currentTarget.innerHTML); } handleDragEnd(e) { e.currentTarget.classList.remove('dragging'); // Remove all drag-over classes document.querySelectorAll('.drag-over').forEach(item => { item.classList.remove('drag-over'); }); } handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } e.dataTransfer.dropEffect = 'move'; return false; } handleDragEnter(e) { if (e.currentTarget.classList.contains('draggable-item') && e.currentTarget !== this.draggedElement) { e.currentTarget.classList.add('drag-over'); } } handleDragLeave(e) { e.currentTarget.classList.remove('drag-over'); } async handleDrop(e) { if (e.stopPropagation) { e.stopPropagation(); } e.preventDefault(); // Get the target element (the item we dropped onto) let target = e.target; while (target && !target.classList.contains('draggable-item')) { target = target.parentElement; if (target === document.getElementById('pending-queue')) { return false; } } if (!target || target === this.draggedElement) { return false; } // Get all items to determine new order const container = document.getElementById('pending-queue'); const items = Array.from(container.querySelectorAll('.draggable-item')); const draggedIndex = items.indexOf(this.draggedElement); const targetIndex = items.indexOf(target); if (draggedIndex === targetIndex) { return false; } // Reorder visually if (draggedIndex < targetIndex) { target.parentNode.insertBefore(this.draggedElement, target.nextSibling); } else { target.parentNode.insertBefore(this.draggedElement, target); } // Update position numbers const updatedItems = Array.from(container.querySelectorAll('.draggable-item')); updatedItems.forEach((item, index) => { const posElement = item.querySelector('.queue-position'); if (posElement) { posElement.textContent = index + 1; } item.dataset.index = index; }); // Get the new order of IDs const newOrder = updatedItems.map(item => item.dataset.id); // Send reorder request to backend await this.reorderQueue(newOrder); return false; } async reorderQueue(newOrder) { try { const response = await this.makeAuthenticatedRequest('/api/queue/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_ids: newOrder }) }); if (!response) return; if (response.ok) { this.showToast('Queue reordered successfully', 'success'); } else { const data = await response.json(); this.showToast(`Failed to reorder: ${data.detail || 'Unknown error'}`, 'error'); // Reload to restore correct order this.loadQueueData(); } } catch (error) { console.error('Error reordering queue:', error); this.showToast('Failed to reorder queue', 'error'); // Reload to restore correct order this.loadQueueData(); } } async makeAuthenticatedRequest(url, options = {}) { // Get JWT token from localStorage const token = localStorage.getItem('access_token'); // Check if token exists if (!token) { window.location.href = '/login'; return null; } // Include Authorization header with Bearer token const requestOptions = { credentials: 'same-origin', ...options, headers: { 'Authorization': `Bearer ${token}`, ...options.headers } }; const response = await fetch(url, requestOptions); if (response.status === 401) { // Token is invalid or expired, clear it and redirect to login localStorage.removeItem('access_token'); localStorage.removeItem('token_expires_at'); window.location.href = '/login'; return null; } return response; } showConfirmModal(title, message) { return new Promise((resolve) => { document.getElementById('confirm-title').textContent = title; document.getElementById('confirm-message').textContent = message; document.getElementById('confirm-modal').classList.remove('hidden'); const handleConfirm = () => { cleanup(); resolve(true); }; const handleCancel = () => { cleanup(); resolve(false); }; const cleanup = () => { document.getElementById('confirm-ok').removeEventListener('click', handleConfirm); document.getElementById('confirm-cancel').removeEventListener('click', handleCancel); this.hideConfirmModal(); }; document.getElementById('confirm-ok').addEventListener('click', handleConfirm); document.getElementById('confirm-cancel').addEventListener('click', handleCancel); }); } hideConfirmModal() { document.getElementById('confirm-modal').classList.add('hidden'); } showToast(message, type = 'info') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = `
${this.escapeHtml(message)}
`; container.appendChild(toast); setTimeout(() => { if (toast.parentElement) { toast.remove(); } }, 5000); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } async logout() { try { const response = await fetch('/api/auth/logout', { method: 'POST' }); const data = await response.json(); if (data.status === 'success') { this.showToast('Logged out successfully', 'success'); setTimeout(() => { window.location.href = '/login'; }, 1000); } else { this.showToast('Logout failed', 'error'); } } catch (error) { console.error('Logout error:', error); this.showToast('Logout failed', 'error'); } } } // Initialize the application when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.queueManager = new QueueManager(); }); // Global reference for inline event handlers window.queueManager = null;