2025-11-02 15:25:07 +01:00

1957 lines
75 KiB
JavaScript

/**
* 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 = `
<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">
<i class="fas fa-tv" style="font-size: 48px; color: var(--color-text-tertiary); margin-bottom: 1rem;"></i>
<p style="color: var(--color-text-secondary);">${message}</p>
</div>
`;
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 `
<div class="series-card ${isSelected ? 'selected' : ''} ${hasMissingEpisodes ? 'has-missing' : 'complete'}"
data-folder="${serie.folder}">
<div class="series-card-header">
<input type="checkbox"
class="series-checkbox"
data-folder="${serie.folder}"
${isSelected ? 'checked' : ''}
${!canBeSelected ? 'disabled' : ''}>
<div class="series-info">
<h3>${this.escapeHtml(this.getDisplayName(serie))}</h3>
<div class="series-folder">${this.escapeHtml(serie.folder)}</div>
</div>
<div class="series-status">
${hasMissingEpisodes ?
'' :
'<i class="fas fa-check-circle status-complete" title="Complete"></i>'
}
</div>
</div>
<div class="series-stats">
<div class="missing-episodes ${hasMissingEpisodes ? 'has-missing' : 'complete'}">
<i class="fas ${hasMissingEpisodes ? 'fa-exclamation-triangle' : 'fa-check'}"></i>
<span>${hasMissingEpisodes ? `${serie.missing_episodes} missing episodes` : 'Complete'}</span>
</div>
<span class="series-site">${serie.site}</span>
</div>
</div>
`;
}
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 = '<i class="fas fa-check-double"></i><span>Select All</span>';
} else if (allSelectableSelected && selectableFolders.length > 0) {
selectAllBtn.innerHTML = '<i class="fas fa-times"></i><span>Deselect All</span>';
} else {
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
}
// 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 => `
<div class="search-result-item">
<span class="search-result-name">${this.escapeHtml(this.getDisplayName(result))}</span>
<button class="btn btn-small btn-primary" onclick="app.addSeries('${this.escapeHtml(result.link)}', '${this.escapeHtml(this.getDisplayName(result))}')">
<i class="fas fa-plus"></i>
Add
</button>
</div>
`).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 = `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>${this.escapeHtml(message)}</span>
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0; margin-left: 1rem;">
<i class="fas fa-times"></i>
</button>
</div>
`;
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 = '<div class="log-file-item"><span>No log files found</span></div>';
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 = '<i class="fas fa-download"></i>';
downloadBtn.title = 'Download';
downloadBtn.onclick = () => this.downloadLogFile(file.name);
const viewBtn = document.createElement('button');
viewBtn.className = 'btn btn-xs btn-secondary';
viewBtn.innerHTML = '<i class="fas fa-eye"></i>';
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 = `<h3>Log File: ${filename}</h3><p>Showing last ${data.showing_lines} of ${data.total_lines} lines</p>`;
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 = '<i class="fas fa-check-circle"></i> Configuration is valid!';
container.appendChild(success);
} else {
const header = document.createElement('div');
header.innerHTML = '<strong>Validation Issues Found:</strong>';
container.appendChild(header);
}
// Show errors
validation.errors.forEach(error => {
const errorDiv = document.createElement('div');
errorDiv.className = 'validation-error';
errorDiv.innerHTML = `<i class="fas fa-times-circle"></i> Error: ${error}`;
container.appendChild(errorDiv);
});
// Show warnings
validation.warnings.forEach(warning => {
const warningDiv = document.createElement('div');
warningDiv.className = 'validation-warning';
warningDiv.innerHTML = `<i class="fas fa-exclamation-triangle"></i> 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) => `
<div class="queue-item">
<div class="queue-item-index">${index + 1}</div>
<div class="queue-item-name">${this.escapeHtml(this.getDisplayName(serie))}</div>
<div class="queue-item-status">Waiting</div>
</div>
`).join('');
} else {
queueList.innerHTML = '<div class="queue-empty">No series in queue</div>';
}
}
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;