1957 lines
75 KiB
JavaScript
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; |