/** * 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}

`; return; } grid.innerHTML = dataToRender.map(serie => this.createSerieCard(serie)).join(''); // Bind checkbox events grid.querySelectorAll('.series-checkbox').forEach(checkbox => { checkbox.addEventListener('change', (e) => { this.toggleSerieSelection(e.target.dataset.folder, e.target.checked); }); }); } createSerieCard(serie) { const isSelected = this.selectedSeries.has(serie.folder); const hasMissingEpisodes = serie.missing_episodes > 0; const canBeSelected = hasMissingEpisodes; // Only allow selection if has missing episodes return `

${this.escapeHtml(this.getDisplayName(serie))}

${this.escapeHtml(serie.folder)}
${hasMissingEpisodes ? '' : '' }
${hasMissingEpisodes ? `${serie.missing_episodes} missing episodes` : 'Complete'}
${serie.site}
`; } toggleSerieSelection(folder, selected) { // Only allow selection of series with missing episodes const serie = this.seriesData.find(s => s.folder === folder); if (!serie || serie.missing_episodes === 0) { // Uncheck the checkbox if it was checked for a complete series const checkbox = document.querySelector(`input[data-folder="${folder}"]`); if (checkbox) checkbox.checked = false; return; } if (selected) { this.selectedSeries.add(folder); } else { this.selectedSeries.delete(folder); } this.updateSelectionUI(); } updateSelectionUI() { const downloadBtn = document.getElementById('download-selected'); const selectAllBtn = document.getElementById('select-all'); // Get series that can be selected (have missing episodes) const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData; const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0); const selectableFolders = selectableSeries.map(serie => serie.folder); downloadBtn.disabled = this.selectedSeries.size === 0; const allSelectableSelected = selectableFolders.every(folder => this.selectedSeries.has(folder)); if (this.selectedSeries.size === 0) { selectAllBtn.innerHTML = 'Select All'; } else if (allSelectableSelected && selectableFolders.length > 0) { selectAllBtn.innerHTML = 'Deselect All'; } else { selectAllBtn.innerHTML = 'Select All'; } // Update card appearances document.querySelectorAll('.series-card').forEach(card => { const folder = card.dataset.folder; const isSelected = this.selectedSeries.has(folder); card.classList.toggle('selected', isSelected); }); } toggleSelectAll() { // Get series that can be selected (have missing episodes) const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData; const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0); const selectableFolders = selectableSeries.map(serie => serie.folder); const allSelectableSelected = selectableFolders.every(folder => this.selectedSeries.has(folder)); if (allSelectableSelected && this.selectedSeries.size > 0) { // Deselect all selectable series selectableFolders.forEach(folder => this.selectedSeries.delete(folder)); document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = false); } else { // Select all selectable series selectableFolders.forEach(folder => this.selectedSeries.add(folder)); document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = true); } this.updateSelectionUI(); } clearSelection() { this.selectedSeries.clear(); document.querySelectorAll('.series-checkbox').forEach(cb => cb.checked = false); this.updateSelectionUI(); } async performSearch() { const searchInput = document.getElementById('search-input'); const query = searchInput.value.trim(); if (!query) { this.showToast('Please enter a search term', 'warning'); return; } try { this.showLoading(); const response = await this.makeAuthenticatedRequest('/api/anime/search', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query }) }); if (!response) return; const data = await response.json(); // Check if response is a direct array (new format) or wrapped object (legacy) if (Array.isArray(data)) { this.displaySearchResults(data); } else if (data.status === 'success') { this.displaySearchResults(data.results); } else { this.showToast(`Search error: ${data.message || 'Unknown error'}`, 'error'); } } catch (error) { console.error('Search error:', error); this.showToast('Search failed', 'error'); } finally { this.hideLoading(); } } displaySearchResults(results) { const resultsContainer = document.getElementById('search-results'); const resultsList = document.getElementById('search-results-list'); if (results.length === 0) { resultsContainer.classList.add('hidden'); this.showToast('No search results found', 'warning'); return; } resultsList.innerHTML = results.map(result => `
${this.escapeHtml(this.getDisplayName(result))}
`).join(''); resultsContainer.classList.remove('hidden'); } hideSearchResults() { document.getElementById('search-results').classList.add('hidden'); } async addSeries(link, name) { try { const response = await this.makeAuthenticatedRequest('/api/anime/add', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ link, name }) }); if (!response) return; const data = await response.json(); if (data.status === 'success') { this.showToast(data.message, 'success'); this.loadSeries(); this.hideSearchResults(); document.getElementById('search-input').value = ''; } else { this.showToast(`Error adding series: ${data.message}`, 'error'); } } catch (error) { console.error('Error adding series:', error); this.showToast('Failed to add series', 'error'); } } async downloadSelected() { console.log('=== downloadSelected v1.1 - DEBUG VERSION ==='); if (this.selectedSeries.size === 0) { this.showToast('No series selected', 'warning'); return; } try { const folders = Array.from(this.selectedSeries); console.log('=== Starting download for selected series ==='); console.log('Selected folders:', folders); console.log('seriesData:', this.seriesData); let totalEpisodesAdded = 0; let failedSeries = []; // For each selected series, get its missing episodes and add to queue for (const folder of folders) { const serie = this.seriesData.find(s => s.folder === folder); if (!serie || !serie.episodeDict) { console.error('Serie not found or has no episodeDict:', folder, serie); failedSeries.push(folder); continue; } // Validate required fields if (!serie.key) { console.error('Serie missing key:', serie); failedSeries.push(folder); continue; } // Convert episodeDict format {season: [episodes]} to episode identifiers const episodes = []; for (const [season, episodeNumbers] of Object.entries(serie.episodeDict)) { if (Array.isArray(episodeNumbers)) { for (const episode of episodeNumbers) { episodes.push({ season: parseInt(season), episode: episode }); } } } if (episodes.length === 0) { console.log('No episodes to add for serie:', serie.name); continue; } // Use folder name as fallback if serie name is empty const serieName = serie.name && serie.name.trim() ? serie.name : serie.folder; // Add episodes to download queue const requestBody = { serie_id: serie.key, serie_folder: serie.folder, serie_name: serieName, episodes: episodes, priority: 'NORMAL' }; console.log('Sending queue add request:', requestBody); const response = await this.makeAuthenticatedRequest('/api/queue/add', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }); if (!response) { failedSeries.push(folder); continue; } const data = await response.json(); console.log('Queue add response:', response.status, data); // Log validation errors in detail if (data.detail && Array.isArray(data.detail)) { console.error('Validation errors:', JSON.stringify(data.detail, null, 2)); } if (response.ok && data.status === 'success') { totalEpisodesAdded += episodes.length; } else { console.error('Failed to add to queue:', data); failedSeries.push(folder); } } // Show result message console.log('=== Download request complete ==='); console.log('Total episodes added:', totalEpisodesAdded); console.log('Failed series:', failedSeries); if (totalEpisodesAdded > 0) { const message = failedSeries.length > 0 ? `Added ${totalEpisodesAdded} episode(s) to queue (${failedSeries.length} series failed)` : `Added ${totalEpisodesAdded} episode(s) to download queue`; this.showToast(message, 'success'); } else { const errorDetails = failedSeries.length > 0 ? `Failed series: ${failedSeries.join(', ')}` : 'No episodes were added. Check browser console for details.'; console.error('Failed to add episodes. Details:', errorDetails); this.showToast('Failed to add episodes to queue. Check console for details.', 'error'); } } catch (error) { console.error('Download error:', error); this.showToast('Failed to start download', 'error'); } } async rescanSeries() { try { this.showToast('Scanning directory...', 'info'); const response = await this.makeAuthenticatedRequest('/api/anime/rescan', { method: 'POST' }); if (!response) return; const data = await response.json(); // Debug logging console.log('Rescan response:', data); console.log('Success value:', data.success, 'Type:', typeof data.success); if (data.success === true) { const seriesCount = data.series_count || 0; this.showToast( `Rescan complete! Found ${seriesCount} series with missing episodes.`, 'success' ); // Reload the series list to show the updated data await this.loadSeries(); } else { this.showToast(`Rescan error: ${data.message}`, 'error'); } } catch (error) { console.error('Rescan error:', error); this.showToast('Failed to start rescan', 'error'); } } showStatus(message, showProgress = false, showControls = false) { const panel = document.getElementById('status-panel'); const messageEl = document.getElementById('status-message'); const progressContainer = document.getElementById('progress-container'); const controlsContainer = document.getElementById('download-controls'); messageEl.textContent = message; progressContainer.classList.toggle('hidden', !showProgress); controlsContainer.classList.toggle('hidden', !showControls); if (showProgress) { this.updateProgress(0); } panel.classList.remove('hidden'); } updateStatus(message) { document.getElementById('status-message').textContent = message; } updateProgress(percent, message = null) { const fill = document.getElementById('progress-fill'); const text = document.getElementById('progress-text'); fill.style.width = `${percent}%`; text.textContent = message || `${percent}%`; } hideStatus() { document.getElementById('status-panel').classList.add('hidden'); } showLoading() { document.getElementById('loading-overlay').classList.remove('hidden'); } hideLoading() { document.getElementById('loading-overlay').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); // Auto-remove after 5 seconds setTimeout(() => { if (toast.parentElement) { toast.remove(); } }, 5000); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Get display name for anime/series object. * Returns name if available and not empty, otherwise returns key. * @param {Object} anime - Anime/series object with name and key properties * @returns {string} Display name */ getDisplayName(anime) { if (!anime) return ''; // Use name if it exists and is not empty (after trimming whitespace) const name = anime.name || ''; const trimmedName = name.trim(); if (trimmedName) { return trimmedName; } // Fallback to key return anime.key || anime.folder || ''; } updateConnectionStatus() { const indicator = document.getElementById('connection-status-display'); if (indicator) { const statusIndicator = indicator.querySelector('.status-indicator'); const statusText = indicator.querySelector('.status-text'); if (this.isConnected) { statusIndicator.classList.add('connected'); statusText.textContent = this.localization.getText('connected'); } else { statusIndicator.classList.remove('connected'); statusText.textContent = this.localization.getText('disconnected'); } } } updateProcessStatus(processName, isRunning, hasError = false) { const statusElement = document.getElementById(`${processName}-status`); if (!statusElement) return; const statusDot = statusElement.querySelector('.status-dot'); if (!statusDot) return; // Remove all status classes from both dot and element statusDot.classList.remove('idle', 'running', 'error'); statusElement.classList.remove('running', 'error', 'idle'); // Capitalize process name for display const displayName = processName.charAt(0).toUpperCase() + processName.slice(1); if (hasError) { statusDot.classList.add('error'); statusElement.classList.add('error'); statusElement.title = `${displayName} error - click for details`; } else if (isRunning) { statusDot.classList.add('running'); statusElement.classList.add('running'); statusElement.title = `${displayName} is running...`; } else { statusDot.classList.add('idle'); statusElement.classList.add('idle'); statusElement.title = `${displayName} is idle`; } } async showConfigModal() { const modal = document.getElementById('config-modal'); try { // Load current status 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'; // Load scheduler configuration await this.loadSchedulerConfig(); // Load logging configuration await this.loadLoggingConfig(); // Load advanced configuration await this.loadAdvancedConfig(); modal.classList.remove('hidden'); } catch (error) { console.error('Error loading configuration:', error); this.showToast('Failed to load configuration', 'error'); } } hideConfigModal() { document.getElementById('config-modal').classList.add('hidden'); } async loadSchedulerConfig() { try { const response = await this.makeAuthenticatedRequest('/api/scheduler/config'); if (!response) return; const data = await response.json(); if (data.success) { const config = data.config; // Update UI elements document.getElementById('scheduled-rescan-enabled').checked = config.enabled; document.getElementById('scheduled-rescan-time').value = config.time || '03:00'; document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan; // Update status display document.getElementById('next-rescan-time').textContent = config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled'; document.getElementById('last-rescan-time').textContent = config.last_run ? new Date(config.last_run).toLocaleString() : 'Never'; const statusBadge = document.getElementById('scheduler-running-status'); statusBadge.textContent = config.is_running ? 'Running' : 'Stopped'; statusBadge.className = `info-value status-badge ${config.is_running ? 'running' : 'stopped'}`; // Enable/disable time input based on checkbox this.toggleSchedulerTimeInput(); } } catch (error) { console.error('Error loading scheduler config:', error); this.showToast('Failed to load scheduler configuration', 'error'); } } async saveSchedulerConfig() { try { const enabled = document.getElementById('scheduled-rescan-enabled').checked; const time = document.getElementById('scheduled-rescan-time').value; const autoDownload = document.getElementById('auto-download-after-rescan').checked; const response = await this.makeAuthenticatedRequest('/api/scheduler/config', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: enabled, time: time, auto_download_after_rescan: autoDownload }) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Scheduler configuration saved successfully', 'success'); // Reload config to update display await this.loadSchedulerConfig(); } else { this.showToast(`Failed to save config: ${data.error}`, 'error'); } } catch (error) { console.error('Error saving scheduler config:', error); this.showToast('Failed to save scheduler configuration', 'error'); } } async testScheduledRescan() { try { const response = await this.makeAuthenticatedRequest('/api/scheduler/trigger-rescan', { method: 'POST' }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Test rescan triggered successfully', 'success'); } else { this.showToast(`Failed to trigger test rescan: ${data.error}`, 'error'); } } catch (error) { console.error('Error triggering test rescan:', error); this.showToast('Failed to trigger test rescan', 'error'); } } toggleSchedulerTimeInput() { const enabled = document.getElementById('scheduled-rescan-enabled').checked; const timeConfig = document.getElementById('rescan-time-config'); if (enabled) { timeConfig.classList.add('enabled'); } else { timeConfig.classList.remove('enabled'); } } async loadLoggingConfig() { try { const response = await this.makeAuthenticatedRequest('/api/logging/config'); if (!response) return; const data = await response.json(); if (data.success) { const config = data.config; // Set form values document.getElementById('log-level').value = config.log_level || 'INFO'; document.getElementById('enable-console-logging').checked = config.enable_console_logging !== false; document.getElementById('enable-console-progress').checked = config.enable_console_progress === true; document.getElementById('enable-fail2ban-logging').checked = config.enable_fail2ban_logging !== false; // Load log files await this.loadLogFiles(); } } catch (error) { console.error('Error loading logging config:', error); this.showToast('Failed to load logging configuration', 'error'); } } async loadLogFiles() { try { const response = await this.makeAuthenticatedRequest('/api/logging/files'); if (!response) return; const data = await response.json(); if (data.success) { const container = document.getElementById('log-files-list'); container.innerHTML = ''; if (data.files.length === 0) { container.innerHTML = '
No log files found
'; return; } data.files.forEach(file => { const item = document.createElement('div'); item.className = 'log-file-item'; const info = document.createElement('div'); info.className = 'log-file-info'; const name = document.createElement('div'); name.className = 'log-file-name'; name.textContent = file.name; const details = document.createElement('div'); details.className = 'log-file-details'; details.textContent = `Size: ${file.size_mb} MB • Modified: ${new Date(file.modified).toLocaleDateString()}`; info.appendChild(name); info.appendChild(details); const actions = document.createElement('div'); actions.className = 'log-file-actions'; const downloadBtn = document.createElement('button'); downloadBtn.className = 'btn btn-xs btn-secondary'; downloadBtn.innerHTML = ''; downloadBtn.title = 'Download'; downloadBtn.onclick = () => this.downloadLogFile(file.name); const viewBtn = document.createElement('button'); viewBtn.className = 'btn btn-xs btn-secondary'; viewBtn.innerHTML = ''; viewBtn.title = 'View Last 100 Lines'; viewBtn.onclick = () => this.viewLogFile(file.name); actions.appendChild(downloadBtn); actions.appendChild(viewBtn); item.appendChild(info); item.appendChild(actions); container.appendChild(item); }); } } catch (error) { console.error('Error loading log files:', error); this.showToast('Failed to load log files', 'error'); } } async saveLoggingConfig() { try { const config = { log_level: document.getElementById('log-level').value, enable_console_logging: document.getElementById('enable-console-logging').checked, enable_console_progress: document.getElementById('enable-console-progress').checked, enable_fail2ban_logging: document.getElementById('enable-fail2ban-logging').checked }; const response = await this.makeAuthenticatedRequest('/api/logging/config', { method: 'POST', body: JSON.stringify(config) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Logging configuration saved successfully', 'success'); await this.loadLoggingConfig(); } else { this.showToast(`Failed to save logging config: ${data.error}`, 'error'); } } catch (error) { console.error('Error saving logging config:', error); this.showToast('Failed to save logging configuration', 'error'); } } async testLogging() { try { const response = await this.makeAuthenticatedRequest('/api/logging/test', { method: 'POST' }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast('Test messages logged successfully', 'success'); setTimeout(() => this.loadLogFiles(), 1000); // Refresh log files after a second } else { this.showToast(`Failed to test logging: ${data.error}`, 'error'); } } catch (error) { console.error('Error testing logging:', error); this.showToast('Failed to test logging', 'error'); } } async loadAdvancedConfig() { // Placeholder for advanced configuration loading // This method is called by showConfigModal but doesn't need to do anything special yet console.log('Advanced configuration loaded (placeholder)'); } async cleanupLogs() { const days = prompt('Delete log files older than how many days?', '30'); if (!days || isNaN(days) || days < 1) { this.showToast('Invalid number of days', 'error'); return; } try { const response = await this.makeAuthenticatedRequest('/api/logging/cleanup', { method: 'POST', body: JSON.stringify({ days: parseInt(days) }) }); if (!response) return; const data = await response.json(); if (data.success) { this.showToast(data.message, 'success'); await this.loadLogFiles(); } else { this.showToast(`Failed to cleanup logs: ${data.error}`, 'error'); } } catch (error) { console.error('Error cleaning up logs:', error); this.showToast('Failed to cleanup logs', 'error'); } } downloadLogFile(filename) { // Create download link const link = document.createElement('a'); link.href = `/api/logging/files/${encodeURIComponent(filename)}/download`; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); } async viewLogFile(filename) { try { const response = await this.makeAuthenticatedRequest(`/api/logging/files/${encodeURIComponent(filename)}/tail?lines=100`); if (!response) return; const data = await response.json(); if (data.success) { // Create modal to show log content const modal = document.createElement('div'); modal.className = 'modal'; modal.style.display = 'block'; const modalContent = document.createElement('div'); modalContent.className = 'modal-content'; modalContent.style.maxWidth = '80%'; modalContent.style.maxHeight = '80%'; const header = document.createElement('div'); header.innerHTML = `

Log File: ${filename}

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) => `
${index + 1}
${this.escapeHtml(this.getDisplayName(serie))}
Waiting
`).join(''); } else { queueList.innerHTML = '
No series in queue
'; } } updateCurrentEpisode(data) { const currentEpisode = document.getElementById('current-episode'); const progressFill = document.getElementById('current-progress-fill'); const progressText = document.getElementById('current-progress-text'); if (currentEpisode && data.episode) { currentEpisode.textContent = `${data.episode} (${data.episode_progress})`; } // Update mini progress bar based on overall progress if (data.overall_progress && progressFill && progressText) { const [current, total] = data.overall_progress.split('/').map(n => parseInt(n)); const percent = total > 0 ? (current / total * 100).toFixed(1) : 0; progressFill.style.width = `${percent}%`; progressText.textContent = `${percent}%`; } } updateDownloadProgress(data) { const queueProgress = document.getElementById('queue-progress'); if (queueProgress && data.completed_series && data.total_series) { queueProgress.textContent = `${data.completed_series}/${data.total_series} series`; } this.showToast(`Completed: ${data.serie}`, 'success'); } initMobileAndAccessibility() { // Initialize Mobile Responsive Manager if (typeof MobileResponsiveManager !== 'undefined') { this.mobileResponsive = new MobileResponsiveManager(); } // Initialize Touch Gesture Manager if (typeof TouchGestureManager !== 'undefined') { this.touchGestures = new TouchGestureManager(); } // Initialize Accessibility Manager if (typeof AccessibilityManager !== 'undefined') { this.accessibility = new AccessibilityManager(); } // Initialize Screen Reader Manager if (typeof ScreenReaderManager !== 'undefined') { this.screenReader = new ScreenReaderManager(); } // Initialize Color Contrast Manager if (typeof ColorContrastManager !== 'undefined') { this.colorContrast = new ColorContrastManager(); } // Initialize Multi-Screen Manager if (typeof MultiScreenManager !== 'undefined') { this.multiScreen = new MultiScreenManager(); } console.log('Mobile & Accessibility features initialized'); } formatETA(seconds) { if (!seconds || seconds <= 0) return '---'; if (seconds < 60) { return `${Math.round(seconds)}s`; } else if (seconds < 3600) { const minutes = Math.round(seconds / 60); return `${minutes}m`; } else if (seconds < 86400) { const hours = Math.floor(seconds / 3600); const minutes = Math.round((seconds % 3600) / 60); return `${hours}h ${minutes}m`; } else { const days = Math.floor(seconds / 86400); const hours = Math.round((seconds % 86400) / 3600); return `${days}d ${hours}h`; } } } // Initialize the application when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.app = new AniWorldApp(); }); // Global functions for inline event handlers window.app = null;