/** * AniWorld Manager - Main JavaScript Application * Implements Fluent UI design principles with modern web app functionality */ class AniWorldApp { constructor() { this.socket = null; this.selectedSeries = new Set(); this.seriesData = []; this.filteredSeriesData = []; this.isConnected = false; this.isDownloading = false; this.isPaused = false; this.localization = new Localization(); this.showMissingOnly = false; this.sortAlphabetical = false; this.init(); } async init() { await this.checkAuthentication(); this.initSocket(); this.bindEvents(); this.loadSeries(); this.initTheme(); this.updateConnectionStatus(); } async checkAuthentication() { // Don't check authentication if we're already on login or setup pages const currentPath = window.location.pathname; if (currentPath === '/login' || currentPath === '/setup') { return; } try { // First check if we have a token const token = localStorage.getItem('access_token'); console.log('checkAuthentication: token exists =', !!token); if (!token) { console.log('checkAuthentication: No token found, redirecting to /login'); window.location.href = '/login'; return; } // Build request with token const headers = { 'Authorization': `Bearer ${token}` }; const response = await fetch('/api/auth/status', { headers }); console.log('checkAuthentication: response status =', response.status); if (!response.ok) { console.log('checkAuthentication: Response not OK, status =', response.status); throw new Error(`HTTP ${response.status}`); } const data = await response.json(); console.log('checkAuthentication: data =', data); if (!data.configured) { // No master password set, redirect to setup console.log('checkAuthentication: Not configured, redirecting to /setup'); window.location.href = '/setup'; return; } if (!data.authenticated) { // Not authenticated, redirect to login console.log('checkAuthentication: Not authenticated, redirecting to /login'); localStorage.removeItem('access_token'); localStorage.removeItem('token_expires_at'); window.location.href = '/login'; return; } // User is authenticated, show logout button console.log('checkAuthentication: Authenticated successfully'); const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) { logoutBtn.style.display = 'block'; } } catch (error) { console.error('Authentication check failed:', error); // On error, clear token and redirect to login localStorage.removeItem('access_token'); localStorage.removeItem('token_expires_at'); window.location.href = '/login'; } } async logout() { try { const response = await this.makeAuthenticatedRequest('/api/auth/logout', { method: 'POST' }); // Clear tokens from localStorage localStorage.removeItem('access_token'); localStorage.removeItem('token_expires_at'); if (response && response.ok) { const data = await response.json(); if (data.status === 'ok') { this.showToast('Logged out successfully', 'success'); } else { this.showToast('Logged out', 'success'); } } else { // Even if the API fails, we cleared the token locally this.showToast('Logged out', 'success'); } setTimeout(() => { window.location.href = '/login'; }, 1000); } catch (error) { console.error('Logout error:', error); // Clear token even on error localStorage.removeItem('access_token'); localStorage.removeItem('token_expires_at'); this.showToast('Logged out', 'success'); setTimeout(() => { window.location.href = '/login'; }, 1000); } } toggleMissingOnlyFilter() { this.showMissingOnly = !this.showMissingOnly; const button = document.getElementById('show-missing-only'); button.setAttribute('data-active', this.showMissingOnly); button.classList.toggle('active', this.showMissingOnly); const icon = button.querySelector('i'); const text = button.querySelector('span'); if (this.showMissingOnly) { icon.className = 'fas fa-filter-circle-xmark'; text.textContent = 'Show All Series'; } else { icon.className = 'fas fa-filter'; text.textContent = 'Missing Episodes Only'; } this.applyFiltersAndSort(); this.renderSeries(); this.clearSelection(); // Clear selection when filter changes } toggleAlphabeticalSort() { this.sortAlphabetical = !this.sortAlphabetical; const button = document.getElementById('sort-alphabetical'); button.setAttribute('data-active', this.sortAlphabetical); button.classList.toggle('active', this.sortAlphabetical); const icon = button.querySelector('i'); const text = button.querySelector('span'); if (this.sortAlphabetical) { icon.className = 'fas fa-sort-alpha-up'; text.textContent = 'Default Sort'; } else { icon.className = 'fas fa-sort-alpha-down'; text.textContent = 'A-Z Sort'; } this.applyFiltersAndSort(); this.renderSeries(); } 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', () => { this.isConnected = true; console.log('Connected to server'); // Subscribe to rooms for targeted updates this.socket.join('scan_progress'); this.socket.join('download_progress'); this.socket.join('downloads'); this.showToast(this.localization.getText('connected-server'), 'success'); this.updateConnectionStatus(); }); this.socket.on('disconnect', () => { this.isConnected = false; console.log('Disconnected from server'); this.showToast(this.localization.getText('disconnected-server'), 'warning'); this.updateConnectionStatus(); }); // Scan events this.socket.on('scan_started', () => { this.showStatus('Scanning series...', true); this.updateProcessStatus('rescan', true); }); this.socket.on('scan_progress', (data) => { this.updateStatus(`Scanning: ${data.folder} (${data.counter})`); }); // Handle both 'scan_completed' (legacy) and 'scan_complete' (new backend) const handleScanComplete = () => { this.hideStatus(); this.showToast('Scan completed successfully', 'success'); this.updateProcessStatus('rescan', false); this.loadSeries(); }; this.socket.on('scan_completed', handleScanComplete); this.socket.on('scan_complete', handleScanComplete); // Handle both 'scan_error' (legacy) and 'scan_failed' (new backend) const handleScanError = (data) => { this.hideStatus(); this.showToast(`Scan error: ${data.message || data.error}`, 'error'); this.updateProcessStatus('rescan', false, true); }; this.socket.on('scan_error', handleScanError); this.socket.on('scan_failed', handleScanError); // Scheduled scan events this.socket.on('scheduled_rescan_started', () => { this.showToast('Scheduled rescan started', 'info'); this.updateProcessStatus('rescan', true); }); this.socket.on('scheduled_rescan_completed', (data) => { this.showToast('Scheduled rescan completed successfully', 'success'); this.updateProcessStatus('rescan', false); this.loadSeries(); }); this.socket.on('scheduled_rescan_error', (data) => { this.showToast(`Scheduled rescan error: ${data.error}`, 'error'); this.updateProcessStatus('rescan', false, true); }); this.socket.on('scheduled_rescan_skipped', (data) => { this.showToast(`Scheduled rescan skipped: ${data.reason}`, 'warning'); }); this.socket.on('auto_download_started', (data) => { this.showToast('Auto-download started after scheduled rescan', 'info'); this.updateProcessStatus('download', true); }); this.socket.on('auto_download_error', (data) => { this.showToast(`Auto-download error: ${data.error}`, 'error'); this.updateProcessStatus('download', false, true); }); // Download events this.socket.on('download_started', (data) => { this.isDownloading = true; this.isPaused = false; this.updateProcessStatus('download', true); this.showDownloadQueue(data); this.showStatus(`Starting download of ${data.total_series} series...`, true, true); }); this.socket.on('download_progress', (data) => { let status = ''; let percent = 0; if (data.progress !== undefined) { percent = data.progress; status = `Downloading: ${percent.toFixed(1)}%`; // Add speed information if available if (data.speed_mbps && data.speed_mbps > 0) { status += ` (${data.speed_mbps.toFixed(1)} Mbps)`; } // Add ETA information if available if (data.eta_seconds && data.eta_seconds > 0) { const eta = this.formatETA(data.eta_seconds); status += ` - ETA: ${eta}`; } } else if (data.total_bytes) { percent = ((data.downloaded_bytes || 0) / data.total_bytes * 100); status = `Downloading: ${percent.toFixed(1)}%`; } else if (data.downloaded_mb !== undefined) { status = `Downloaded: ${data.downloaded_mb.toFixed(1)} MB`; } else { status = `Downloading: ${data.percent || '0%'}`; } if (percent > 0) { this.updateProgress(percent, status); } else { this.updateStatus(status); } }); this.socket.on('download_completed', (data) => { this.isDownloading = false; this.isPaused = false; this.hideDownloadQueue(); this.hideStatus(); this.showToast(this.localization.getText('download-completed'), 'success'); this.loadSeries(); this.clearSelection(); }); this.socket.on('download_error', (data) => { this.isDownloading = false; this.isPaused = false; this.hideDownloadQueue(); this.hideStatus(); this.showToast(`${this.localization.getText('download-failed')}: ${data.message}`, 'error'); }); // Download queue status events this.socket.on('download_queue_completed', () => { this.updateProcessStatus('download', false); this.showToast('All downloads completed!', 'success'); }); this.socket.on('download_stop_requested', () => { this.showToast('Stopping downloads...', 'info'); }); this.socket.on('download_stopped', () => { this.updateProcessStatus('download', false); this.showToast('Downloads stopped', 'success'); }); // Download queue events this.socket.on('download_queue_update', (data) => { this.updateDownloadQueue(data); }); this.socket.on('download_episode_update', (data) => { this.updateCurrentEpisode(data); }); this.socket.on('download_series_completed', (data) => { this.updateDownloadProgress(data); }); // Download control events this.socket.on('download_paused', () => { this.isPaused = true; this.updateStatus(this.localization.getText('paused')); }); this.socket.on('download_resumed', () => { this.isPaused = false; this.updateStatus(this.localization.getText('downloading')); }); this.socket.on('download_cancelled', () => { this.isDownloading = false; this.isPaused = false; this.hideDownloadQueue(); this.hideStatus(); this.showToast('Download cancelled', 'warning'); }); } bindEvents() { // Theme toggle document.getElementById('theme-toggle').addEventListener('click', () => { this.toggleTheme(); }); // Search functionality const searchInput = document.getElementById('search-input'); const searchBtn = document.getElementById('search-btn'); const clearSearch = document.getElementById('clear-search'); searchBtn.addEventListener('click', () => { this.performSearch(); }); searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.performSearch(); } }); clearSearch.addEventListener('click', () => { searchInput.value = ''; this.hideSearchResults(); }); // Series management document.getElementById('select-all').addEventListener('click', () => { this.toggleSelectAll(); }); document.getElementById('download-selected').addEventListener('click', () => { this.downloadSelected(); }); // Rescan document.getElementById('rescan-btn').addEventListener('click', () => { this.rescanSeries(); }); // Configuration modal document.getElementById('config-btn').addEventListener('click', () => { this.showConfigModal(); }); document.getElementById('close-config').addEventListener('click', () => { this.hideConfigModal(); }); document.querySelector('#config-modal .modal-overlay').addEventListener('click', () => { this.hideConfigModal(); }); // Scheduler configuration document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => { this.toggleSchedulerTimeInput(); }); document.getElementById('save-scheduler-config').addEventListener('click', () => { this.saveSchedulerConfig(); }); document.getElementById('test-scheduled-rescan').addEventListener('click', () => { this.testScheduledRescan(); }); // Logging configuration document.getElementById('save-logging-config').addEventListener('click', () => { this.saveLoggingConfig(); }); document.getElementById('test-logging').addEventListener('click', () => { this.testLogging(); }); document.getElementById('refresh-log-files').addEventListener('click', () => { this.loadLogFiles(); }); document.getElementById('cleanup-logs').addEventListener('click', () => { this.cleanupLogs(); }); // Configuration management document.getElementById('create-config-backup').addEventListener('click', () => { this.createConfigBackup(); }); document.getElementById('view-config-backups').addEventListener('click', () => { this.viewConfigBackups(); }); document.getElementById('export-config').addEventListener('click', () => { this.exportConfig(); }); document.getElementById('validate-config').addEventListener('click', () => { this.validateConfig(); }); document.getElementById('reset-config').addEventListener('click', () => { this.resetConfig(); }); document.getElementById('save-advanced-config').addEventListener('click', () => { this.saveAdvancedConfig(); }); // Main configuration document.getElementById('save-main-config').addEventListener('click', () => { this.saveMainConfig(); }); document.getElementById('reset-main-config').addEventListener('click', () => { this.resetMainConfig(); }); document.getElementById('test-connection').addEventListener('click', () => { this.testConnection(); }); document.getElementById('browse-directory').addEventListener('click', () => { this.browseDirectory(); }); // Status panel document.getElementById('close-status').addEventListener('click', () => { this.hideStatus(); }); // Logout functionality document.getElementById('logout-btn').addEventListener('click', () => { this.logout(); }); // Series filtering and sorting document.getElementById('show-missing-only').addEventListener('click', () => { this.toggleMissingOnlyFilter(); }); document.getElementById('sort-alphabetical').addEventListener('click', () => { this.toggleAlphabeticalSort(); }); } 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); } async loadSeries() { try { this.showLoading(); const response = await this.makeAuthenticatedRequest('/api/anime'); if (!response) { // makeAuthenticatedRequest returns null and handles redirect on auth failure return; } const data = await response.json(); // Check if response has the expected format if (Array.isArray(data)) { // API returns array of AnimeSummary objects with full serie data this.seriesData = data.map(anime => { // Count total missing episodes from the episode dictionary const episodeDict = anime.missing_episodes || {}; const totalMissing = Object.values(episodeDict).reduce( (sum, episodes) => sum + (Array.isArray(episodes) ? episodes.length : 0), 0 ); return { key: anime.key, name: anime.name, site: anime.site, folder: anime.folder, episodeDict: episodeDict, missing_episodes: totalMissing }; }); } else if (data.status === 'success') { // Legacy format support this.seriesData = data.series; } else { this.showToast(`Error loading series: ${data.message || 'Unknown error'}`, 'error'); return; } this.applyFiltersAndSort(); this.renderSeries(); } catch (error) { console.error('Error loading series:', error); this.showToast('Failed to load series', 'error'); } finally { this.hideLoading(); } } 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; } applyFiltersAndSort() { let filtered = [...this.seriesData]; // Sort based on the current sorting mode filtered.sort((a, b) => { if (this.sortAlphabetical) { // Pure alphabetical sorting when A-Z is enabled return this.getDisplayName(a).localeCompare(this.getDisplayName(b)); } else { // Default sorting: missing episodes first (descending), then by name // Always show series with missing episodes first if (a.missing_episodes > 0 && b.missing_episodes === 0) return -1; if (a.missing_episodes === 0 && b.missing_episodes > 0) return 1; // If both have missing episodes, sort by count (descending) if (a.missing_episodes > 0 && b.missing_episodes > 0) { if (a.missing_episodes !== b.missing_episodes) { return b.missing_episodes - a.missing_episodes; } } // For series with same missing episode status, maintain stable order return 0; } }); // Apply missing episodes filter if (this.showMissingOnly) { filtered = filtered.filter(serie => serie.missing_episodes > 0); } this.filteredSeriesData = filtered; this.renderSeries(); } renderSeries() { const grid = document.getElementById('series-grid'); const dataToRender = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : (this.seriesData.length > 0 ? this.seriesData : []); if (dataToRender.length === 0) { const message = this.showMissingOnly ? 'No series with missing episodes found.' : 'No series found. Try searching for anime or rescanning your directory.'; grid.innerHTML = `
${message}
Showing last ${data.showing_lines} of ${data.total_lines} lines
`; const content = document.createElement('pre'); content.style.maxHeight = '60vh'; content.style.overflow = 'auto'; content.style.backgroundColor = '#f5f5f5'; content.style.padding = '10px'; content.style.fontSize = '12px'; content.textContent = data.lines.join('\n'); const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close'; closeBtn.className = 'btn btn-secondary'; closeBtn.onclick = () => document.body.removeChild(modal); modalContent.appendChild(header); modalContent.appendChild(content); modalContent.appendChild(closeBtn); modal.appendChild(modalContent); document.body.appendChild(modal); // Close on background click modal.onclick = (e) => { if (e.target === modal) { document.body.removeChild(modal); } }; } else { this.showToast(`Failed to view log file: ${data.error}`, 'error'); } } catch (error) { console.error('Error viewing log file:', error); this.showToast('Failed to view log file', 'error'); } } // Configuration Management Methods async loadAdvancedConfig() { try { const response = await this.makeAuthenticatedRequest('/api/config/section/advanced'); if (!response) return; const data = await response.json(); if (data.success) { const config = data.config; document.getElementById('max-concurrent-downloads').value = config.max_concurrent_downloads || 3; document.getElementById('provider-timeout').value = config.provider_timeout || 30; document.getElementById('enable-debug-mode').checked = config.enable_debug_mode === true; } } catch (error) { console.error('Error loading advanced config:', error); } } async saveAdvancedConfig() { try { const config = { max_concurrent_downloads: parseInt(document.getElementById('max-concurrent-downloads').value), provider_timeout: parseInt(document.getElementById('provider-timeout').value), enable_debug_mode: document.getElementById('enable-debug-mode').checked }; const response = await this.makeAuthenticatedRequest('/api/config/section/advanced', { method: 'POST', body: JSON.stringify(config) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Advanced configuration saved successfully', 'success'); } else { this.showToast(`Failed to save config: ${data.error}`, 'error'); } } catch (error) { console.error('Error saving advanced config:', error); this.showToast('Failed to save advanced configuration', 'error'); } } // Main Configuration Methods async saveMainConfig() { try { const animeDirectory = document.getElementById('anime-directory-input').value.trim(); if (!animeDirectory) { this.showToast('Please enter an anime directory path', 'error'); return; } const response = await this.makeAuthenticatedRequest('/api/config/directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ directory: animeDirectory }) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Main configuration saved successfully', 'success'); // Refresh the status to get updated series count await this.refreshStatus(); } else { this.showToast(`Failed to save configuration: ${data.error}`, 'error'); } } catch (error) { console.error('Error saving main config:', error); this.showToast('Failed to save main configuration', 'error'); } } async resetMainConfig() { if (confirm('Are you sure you want to reset the main configuration? This will clear the anime directory.')) { document.getElementById('anime-directory-input').value = ''; document.getElementById('series-count-input').value = '0'; this.showToast('Main configuration reset', 'info'); } } async testConnection() { try { this.showToast('Testing connection...', 'info'); const response = await this.makeAuthenticatedRequest('/api/diagnostics/network'); if (!response) return; const data = await response.json(); if (data.status === 'success') { const networkStatus = data.data; const connectionDiv = document.getElementById('connection-status-display'); const statusIndicator = connectionDiv.querySelector('.status-indicator'); const statusText = connectionDiv.querySelector('.status-text'); if (networkStatus.aniworld_reachable) { statusIndicator.className = 'status-indicator connected'; statusText.textContent = 'Connected'; this.showToast('Connection test successful', 'success'); } else { statusIndicator.className = 'status-indicator disconnected'; statusText.textContent = 'Disconnected'; this.showToast('Connection test failed', 'error'); } } else { this.showToast('Connection test failed', 'error'); } } catch (error) { console.error('Error testing connection:', error); this.showToast('Connection test failed', 'error'); } } async browseDirectory() { // This would typically open a native directory browser // For web applications, we'll show a prompt for manual entry const currentPath = document.getElementById('anime-directory-input').value; const newPath = prompt('Enter the anime directory path:', currentPath); if (newPath !== null && newPath.trim() !== '') { document.getElementById('anime-directory-input').value = newPath.trim(); } } async refreshStatus() { try { const response = await this.makeAuthenticatedRequest('/api/anime/status'); if (!response) return; const data = await response.json(); document.getElementById('anime-directory-input').value = data.directory || ''; document.getElementById('series-count-input').value = data.series_count || '0'; } catch (error) { console.error('Error refreshing status:', error); } } async createConfigBackup() { const backupName = prompt('Enter backup name (optional):'); try { const response = await this.makeAuthenticatedRequest('/api/config/backup', { method: 'POST', body: JSON.stringify({ name: backupName || '' }) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast(`Backup created: ${data.filename}`, 'success'); } else { this.showToast(`Failed to create backup: ${data.error}`, 'error'); } } catch (error) { console.error('Error creating backup:', error); this.showToast('Failed to create backup', 'error'); } } async viewConfigBackups() { try { const response = await this.makeAuthenticatedRequest('/api/config/backups'); if (!response) return; const data = await response.json(); if (data.success) { this.showBackupsModal(data.backups); } else { this.showToast(`Failed to load backups: ${data.error}`, 'error'); } } catch (error) { console.error('Error loading backups:', error); this.showToast('Failed to load backups', 'error'); } } async validateConfig() { try { const response = await this.makeAuthenticatedRequest('/api/config/validate', { method: 'POST', body: JSON.stringify({}) // Validate current config }); if (!response) return; const data = await response.json(); if (data.success) { this.showValidationResults(data.validation); } else { this.showToast(`Validation failed: ${data.error}`, 'error'); } } catch (error) { console.error('Error validating config:', error); this.showToast('Failed to validate configuration', 'error'); } } showValidationResults(validation) { const container = document.getElementById('validation-results'); container.innerHTML = ''; container.classList.remove('hidden'); if (validation.valid) { const success = document.createElement('div'); success.className = 'validation-success'; success.innerHTML = ' Configuration is valid!'; container.appendChild(success); } else { const header = document.createElement('div'); header.innerHTML = 'Validation Issues Found:'; container.appendChild(header); } // Show errors validation.errors.forEach(error => { const errorDiv = document.createElement('div'); errorDiv.className = 'validation-error'; errorDiv.innerHTML = ` Error: ${error}`; container.appendChild(errorDiv); }); // Show warnings validation.warnings.forEach(warning => { const warningDiv = document.createElement('div'); warningDiv.className = 'validation-warning'; warningDiv.innerHTML = ` Warning: ${warning}`; container.appendChild(warningDiv); }); } async resetConfig() { if (!confirm('Are you sure you want to reset all configuration to defaults? This cannot be undone (except by restoring a backup).')) { return; } try { const response = await this.makeAuthenticatedRequest('/api/config/reset', { method: 'POST', body: JSON.stringify({ preserve_security: true }) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Configuration reset to defaults', 'success'); // Reload the config modal setTimeout(() => { this.hideConfigModal(); this.showConfigModal(); }, 1000); } else { this.showToast(`Failed to reset config: ${data.error}`, 'error'); } } catch (error) { console.error('Error resetting config:', error); this.showToast('Failed to reset configuration', 'error'); } } showDownloadQueue(data) { const queueSection = document.getElementById('download-queue-section'); const queueProgress = document.getElementById('queue-progress'); queueProgress.textContent = `0/${data.total_series} series`; this.updateDownloadQueue({ queue: data.queue || [], current_downloading: null, stats: { completed_series: 0, total_series: data.total_series } }); queueSection.classList.remove('hidden'); } hideDownloadQueue() { const queueSection = document.getElementById('download-queue-section'); const currentDownload = document.getElementById('current-download'); queueSection.classList.add('hidden'); currentDownload.classList.add('hidden'); } updateDownloadQueue(data) { const queueList = document.getElementById('queue-list'); const currentDownload = document.getElementById('current-download'); const queueProgress = document.getElementById('queue-progress'); // Update overall progress if (data.stats) { queueProgress.textContent = `${data.stats.completed_series}/${data.stats.total_series} series`; } // Update current downloading if (data.current_downloading) { currentDownload.classList.remove('hidden'); document.getElementById('current-serie-name').textContent = this.getDisplayName(data.current_downloading); document.getElementById('current-episode').textContent = `${data.current_downloading.missing_episodes} episodes remaining`; } else { currentDownload.classList.add('hidden'); } // Update queue list if (data.queue && data.queue.length > 0) { queueList.innerHTML = data.queue.map((serie, index) => `