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