new folder structure
This commit is contained in:
0
src/server/web/static/__init__.py
Normal file
0
src/server/web/static/__init__.py
Normal file
0
src/server/web/static/css/__init__.py
Normal file
0
src/server/web/static/css/__init__.py
Normal file
0
src/server/web/static/css/components/__init__.py
Normal file
0
src/server/web/static/css/components/__init__.py
Normal file
0
src/server/web/static/css/pages/__init__.py
Normal file
0
src/server/web/static/css/pages/__init__.py
Normal file
1743
src/server/web/static/css/styles.css
Normal file
1743
src/server/web/static/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
0
src/server/web/static/css/themes/__init__.py
Normal file
0
src/server/web/static/css/themes/__init__.py
Normal file
0
src/server/web/static/css/vendor/__init__.py
vendored
Normal file
0
src/server/web/static/css/vendor/__init__.py
vendored
Normal file
0
src/server/web/static/fonts/__init__.py
Normal file
0
src/server/web/static/fonts/__init__.py
Normal file
0
src/server/web/static/images/__init__.py
Normal file
0
src/server/web/static/images/__init__.py
Normal file
0
src/server/web/static/images/covers/__init__.py
Normal file
0
src/server/web/static/images/covers/__init__.py
Normal file
0
src/server/web/static/images/icons/__init__.py
Normal file
0
src/server/web/static/images/icons/__init__.py
Normal file
0
src/server/web/static/js/__init__.py
Normal file
0
src/server/web/static/js/__init__.py
Normal file
1936
src/server/web/static/js/app.js
Normal file
1936
src/server/web/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
0
src/server/web/static/js/components/__init__.py
Normal file
0
src/server/web/static/js/components/__init__.py
Normal file
236
src/server/web/static/js/localization.js
Normal file
236
src/server/web/static/js/localization.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Localization support for AniWorld Manager
|
||||
* Implements resource-based text management for easy translation
|
||||
*/
|
||||
|
||||
class Localization {
|
||||
constructor() {
|
||||
this.currentLanguage = 'en';
|
||||
this.fallbackLanguage = 'en';
|
||||
this.translations = {};
|
||||
this.loadTranslations();
|
||||
}
|
||||
|
||||
loadTranslations() {
|
||||
// English (default)
|
||||
this.translations.en = {
|
||||
// Header
|
||||
'config-title': 'Configuration',
|
||||
'toggle-theme': 'Toggle theme',
|
||||
'rescan': 'Rescan',
|
||||
|
||||
// Search
|
||||
'search-placeholder': 'Search for anime...',
|
||||
'search-results': 'Search Results',
|
||||
'no-results': 'No results found',
|
||||
'add': 'Add',
|
||||
|
||||
// Series
|
||||
'series-collection': 'Series Collection',
|
||||
'select-all': 'Select All',
|
||||
'deselect-all': 'Deselect All',
|
||||
'download-selected': 'Download Selected',
|
||||
'missing-episodes': 'missing episodes',
|
||||
|
||||
// Configuration
|
||||
'anime-directory': 'Anime Directory',
|
||||
'series-count': 'Series Count',
|
||||
'connection-status': 'Connection Status',
|
||||
'connected': 'Connected',
|
||||
'disconnected': 'Disconnected',
|
||||
|
||||
// Download controls
|
||||
'pause': 'Pause',
|
||||
'resume': 'Resume',
|
||||
'cancel': 'Cancel',
|
||||
'downloading': 'Downloading',
|
||||
'paused': 'Paused',
|
||||
|
||||
// Download queue
|
||||
'download-queue': 'Download Queue',
|
||||
'currently-downloading': 'Currently Downloading',
|
||||
'queued-series': 'Queued Series',
|
||||
|
||||
// Status messages
|
||||
'connected-server': 'Connected to server',
|
||||
'disconnected-server': 'Disconnected from server',
|
||||
'scan-started': 'Scan started',
|
||||
'scan-completed': 'Scan completed successfully',
|
||||
'download-started': 'Download started',
|
||||
'download-completed': 'Download completed successfully',
|
||||
'series-added': 'Series added successfully',
|
||||
|
||||
// Error messages
|
||||
'search-failed': 'Search failed',
|
||||
'download-failed': 'Download failed',
|
||||
'scan-failed': 'Scan failed',
|
||||
'connection-failed': 'Connection failed',
|
||||
|
||||
// General
|
||||
'loading': 'Loading...',
|
||||
'close': 'Close',
|
||||
'ok': 'OK',
|
||||
'cancel-action': 'Cancel'
|
||||
};
|
||||
|
||||
// German
|
||||
this.translations.de = {
|
||||
// Header
|
||||
'config-title': 'Konfiguration',
|
||||
'toggle-theme': 'Design wechseln',
|
||||
'rescan': 'Neu scannen',
|
||||
|
||||
// Search
|
||||
'search-placeholder': 'Nach Anime suchen...',
|
||||
'search-results': 'Suchergebnisse',
|
||||
'no-results': 'Keine Ergebnisse gefunden',
|
||||
'add': 'Hinzufügen',
|
||||
|
||||
// Series
|
||||
'series-collection': 'Serien-Sammlung',
|
||||
'select-all': 'Alle auswählen',
|
||||
'deselect-all': 'Alle abwählen',
|
||||
'download-selected': 'Ausgewählte herunterladen',
|
||||
'missing-episodes': 'fehlende Episoden',
|
||||
|
||||
// Configuration
|
||||
'anime-directory': 'Anime-Verzeichnis',
|
||||
'series-count': 'Anzahl Serien',
|
||||
'connection-status': 'Verbindungsstatus',
|
||||
'connected': 'Verbunden',
|
||||
'disconnected': 'Getrennt',
|
||||
|
||||
// Download controls
|
||||
'pause': 'Pausieren',
|
||||
'resume': 'Fortsetzen',
|
||||
'cancel': 'Abbrechen',
|
||||
'downloading': 'Herunterladen',
|
||||
'paused': 'Pausiert',
|
||||
|
||||
// Download queue
|
||||
'download-queue': 'Download-Warteschlange',
|
||||
'currently-downloading': 'Wird heruntergeladen',
|
||||
'queued-series': 'Warteschlange',
|
||||
|
||||
// Status messages
|
||||
'connected-server': 'Mit Server verbunden',
|
||||
'disconnected-server': 'Verbindung zum Server getrennt',
|
||||
'scan-started': 'Scan gestartet',
|
||||
'scan-completed': 'Scan erfolgreich abgeschlossen',
|
||||
'download-started': 'Download gestartet',
|
||||
'download-completed': 'Download erfolgreich abgeschlossen',
|
||||
'series-added': 'Serie erfolgreich hinzugefügt',
|
||||
|
||||
// Error messages
|
||||
'search-failed': 'Suche fehlgeschlagen',
|
||||
'download-failed': 'Download fehlgeschlagen',
|
||||
'scan-failed': 'Scan fehlgeschlagen',
|
||||
'connection-failed': 'Verbindung fehlgeschlagen',
|
||||
|
||||
// General
|
||||
'loading': 'Wird geladen...',
|
||||
'close': 'Schließen',
|
||||
'ok': 'OK',
|
||||
'cancel-action': 'Abbrechen'
|
||||
};
|
||||
|
||||
// Load saved language preference
|
||||
const savedLanguage = localStorage.getItem('language') || this.detectLanguage();
|
||||
this.setLanguage(savedLanguage);
|
||||
}
|
||||
|
||||
detectLanguage() {
|
||||
const browserLang = navigator.language || navigator.userLanguage;
|
||||
const langCode = browserLang.split('-')[0];
|
||||
return this.translations[langCode] ? langCode : this.fallbackLanguage;
|
||||
}
|
||||
|
||||
setLanguage(langCode) {
|
||||
if (this.translations[langCode]) {
|
||||
this.currentLanguage = langCode;
|
||||
localStorage.setItem('language', langCode);
|
||||
this.updatePageTexts();
|
||||
}
|
||||
}
|
||||
|
||||
getText(key, fallback = key) {
|
||||
const translation = this.translations[this.currentLanguage];
|
||||
if (translation && translation[key]) {
|
||||
return translation[key];
|
||||
}
|
||||
|
||||
// Try fallback language
|
||||
const fallbackTranslation = this.translations[this.fallbackLanguage];
|
||||
if (fallbackTranslation && fallbackTranslation[key]) {
|
||||
return fallbackTranslation[key];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
updatePageTexts() {
|
||||
// Update all elements with data-text attributes
|
||||
document.querySelectorAll('[data-text]').forEach(element => {
|
||||
const key = element.getAttribute('data-text');
|
||||
const text = this.getText(key);
|
||||
|
||||
if (element.tagName === 'INPUT' && element.type === 'text') {
|
||||
element.placeholder = text;
|
||||
} else {
|
||||
element.textContent = text;
|
||||
}
|
||||
});
|
||||
|
||||
// Update specific elements that need special handling
|
||||
this.updateSearchPlaceholder();
|
||||
this.updateDynamicTexts();
|
||||
}
|
||||
|
||||
updateSearchPlaceholder() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
searchInput.placeholder = this.getText('search-placeholder');
|
||||
}
|
||||
}
|
||||
|
||||
updateDynamicTexts() {
|
||||
// Update any dynamically generated content
|
||||
const selectAllBtn = document.getElementById('select-all');
|
||||
if (selectAllBtn && window.app) {
|
||||
const selectedCount = window.app.selectedSeries ? window.app.selectedSeries.size : 0;
|
||||
const totalCount = window.app.seriesData ? window.app.seriesData.length : 0;
|
||||
|
||||
if (selectedCount === totalCount && totalCount > 0) {
|
||||
selectAllBtn.innerHTML = `<i class="fas fa-times"></i><span>${this.getText('deselect-all')}</span>`;
|
||||
} else {
|
||||
selectAllBtn.innerHTML = `<i class="fas fa-check-double"></i><span>${this.getText('select-all')}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableLanguages() {
|
||||
return Object.keys(this.translations).map(code => ({
|
||||
code: code,
|
||||
name: this.getLanguageName(code)
|
||||
}));
|
||||
}
|
||||
|
||||
getLanguageName(code) {
|
||||
const names = {
|
||||
'en': 'English',
|
||||
'de': 'Deutsch'
|
||||
};
|
||||
return names[code] || code.toUpperCase();
|
||||
}
|
||||
|
||||
formatMessage(key, ...args) {
|
||||
let message = this.getText(key);
|
||||
args.forEach((arg, index) => {
|
||||
message = message.replace(`{${index}}`, arg);
|
||||
});
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.Localization = Localization;
|
||||
0
src/server/web/static/js/pages/__init__.py
Normal file
0
src/server/web/static/js/pages/__init__.py
Normal file
578
src/server/web/static/js/queue.js
Normal file
578
src/server/web/static/js/queue.js
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Download Queue Management - JavaScript Application
|
||||
*/
|
||||
|
||||
class QueueManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.refreshInterval = null;
|
||||
this.isReordering = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initSocket();
|
||||
this.bindEvents();
|
||||
this.initTheme();
|
||||
this.startRefreshTimer();
|
||||
this.loadQueueData();
|
||||
}
|
||||
|
||||
initSocket() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.showToast('Connected to server', 'success');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
this.showToast('Disconnected from server', 'warning');
|
||||
});
|
||||
|
||||
// Queue update events
|
||||
this.socket.on('queue_updated', (data) => {
|
||||
this.updateQueueDisplay(data);
|
||||
});
|
||||
|
||||
this.socket.on('download_progress_update', (data) => {
|
||||
this.updateDownloadProgress(data);
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Theme toggle
|
||||
document.getElementById('theme-toggle').addEventListener('click', () => {
|
||||
this.toggleTheme();
|
||||
});
|
||||
|
||||
// Queue management actions
|
||||
document.getElementById('clear-queue-btn').addEventListener('click', () => {
|
||||
this.clearQueue('pending');
|
||||
});
|
||||
|
||||
document.getElementById('clear-completed-btn').addEventListener('click', () => {
|
||||
this.clearQueue('completed');
|
||||
});
|
||||
|
||||
document.getElementById('clear-failed-btn').addEventListener('click', () => {
|
||||
this.clearQueue('failed');
|
||||
});
|
||||
|
||||
document.getElementById('retry-all-btn').addEventListener('click', () => {
|
||||
this.retryAllFailed();
|
||||
});
|
||||
|
||||
document.getElementById('reorder-queue-btn').addEventListener('click', () => {
|
||||
this.toggleReorderMode();
|
||||
});
|
||||
|
||||
// Download controls
|
||||
document.getElementById('pause-all-btn').addEventListener('click', () => {
|
||||
this.pauseAllDownloads();
|
||||
});
|
||||
|
||||
document.getElementById('resume-all-btn').addEventListener('click', () => {
|
||||
this.resumeAllDownloads();
|
||||
});
|
||||
|
||||
// Modal events
|
||||
document.getElementById('close-confirm').addEventListener('click', () => {
|
||||
this.hideConfirmModal();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-cancel').addEventListener('click', () => {
|
||||
this.hideConfirmModal();
|
||||
});
|
||||
|
||||
document.querySelector('#confirm-modal .modal-overlay').addEventListener('click', () => {
|
||||
this.hideConfirmModal();
|
||||
});
|
||||
|
||||
// Logout functionality
|
||||
document.getElementById('logout-btn').addEventListener('click', () => {
|
||||
this.logout();
|
||||
});
|
||||
}
|
||||
|
||||
initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
this.setTheme(savedTheme);
|
||||
}
|
||||
|
||||
setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const themeIcon = document.querySelector('#theme-toggle i');
|
||||
themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
this.setTheme(newTheme);
|
||||
}
|
||||
|
||||
startRefreshTimer() {
|
||||
// Refresh every 2 seconds
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadQueueData();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async loadQueueData() {
|
||||
try {
|
||||
const response = await this.makeAuthenticatedRequest('/api/queue/status');
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
this.updateQueueDisplay(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading queue data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateQueueDisplay(data) {
|
||||
// Update statistics
|
||||
this.updateStatistics(data.statistics, data);
|
||||
|
||||
// Update active downloads
|
||||
this.renderActiveDownloads(data.active_downloads || []);
|
||||
|
||||
// Update pending queue
|
||||
this.renderPendingQueue(data.pending_queue || []);
|
||||
|
||||
// Update completed downloads
|
||||
this.renderCompletedDownloads(data.completed_downloads || []);
|
||||
|
||||
// Update failed downloads
|
||||
this.renderFailedDownloads(data.failed_downloads || []);
|
||||
|
||||
// Update button states
|
||||
this.updateButtonStates(data);
|
||||
}
|
||||
|
||||
updateStatistics(stats, data) {
|
||||
document.getElementById('total-items').textContent = stats.total_items || 0;
|
||||
document.getElementById('pending-items').textContent = (data.pending_queue || []).length;
|
||||
document.getElementById('completed-items').textContent = stats.completed_items || 0;
|
||||
document.getElementById('failed-items').textContent = stats.failed_items || 0;
|
||||
|
||||
document.getElementById('current-speed').textContent = stats.current_speed || '0 MB/s';
|
||||
document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s';
|
||||
|
||||
// Format ETA
|
||||
const etaElement = document.getElementById('eta-time');
|
||||
if (stats.eta) {
|
||||
const eta = new Date(stats.eta);
|
||||
const now = new Date();
|
||||
const diffMs = eta - now;
|
||||
|
||||
if (diffMs > 0) {
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
etaElement.textContent = `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
etaElement.textContent = 'Calculating...';
|
||||
}
|
||||
} else {
|
||||
etaElement.textContent = '--:--';
|
||||
}
|
||||
}
|
||||
|
||||
renderActiveDownloads(downloads) {
|
||||
const container = document.getElementById('active-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-pause-circle"></i>
|
||||
<p>No active downloads</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = downloads.map(download => this.createActiveDownloadCard(download)).join('');
|
||||
}
|
||||
|
||||
createActiveDownloadCard(download) {
|
||||
const progress = download.progress || {};
|
||||
const progressPercent = progress.percent || 0;
|
||||
const speed = progress.speed_mbps ? `${progress.speed_mbps.toFixed(1)} MB/s` : '0 MB/s';
|
||||
const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0 MB';
|
||||
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown';
|
||||
|
||||
return `
|
||||
<div class="download-card active">
|
||||
<div class="download-header">
|
||||
<div class="download-info">
|
||||
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
||||
<p>${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}</p>
|
||||
</div>
|
||||
<div class="download-actions">
|
||||
<button class="btn btn-small btn-secondary" onclick="queueManager.pauseDownload('${download.id}')">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
<button class="btn btn-small btn-error" onclick="queueManager.cancelDownload('${download.id}')">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progressPercent}%"></div>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span>${progressPercent.toFixed(1)}% (${downloaded} / ${total})</span>
|
||||
<span class="download-speed">${speed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderPendingQueue(queue) {
|
||||
const container = document.getElementById('pending-queue');
|
||||
|
||||
if (queue.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-list"></i>
|
||||
<p>No items in queue</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join('');
|
||||
}
|
||||
|
||||
createPendingQueueCard(download, index) {
|
||||
const addedAt = new Date(download.added_at).toLocaleString();
|
||||
const priorityClass = download.priority === 'high' ? 'high-priority' : '';
|
||||
|
||||
return `
|
||||
<div class="download-card pending ${priorityClass}" data-id="${download.id}">
|
||||
<div class="queue-position">${index + 1}</div>
|
||||
<div class="download-header">
|
||||
<div class="download-info">
|
||||
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
||||
<p>${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}</p>
|
||||
<small>Added: ${addedAt}</small>
|
||||
</div>
|
||||
<div class="download-actions">
|
||||
${download.priority === 'high' ? '<i class="fas fa-arrow-up priority-indicator" title="High Priority"></i>' : ''}
|
||||
<button class="btn btn-small btn-secondary" onclick="queueManager.removeFromQueue('${download.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderCompletedDownloads(downloads) {
|
||||
const container = document.getElementById('completed-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<p>No completed downloads</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = downloads.map(download => this.createCompletedDownloadCard(download)).join('');
|
||||
}
|
||||
|
||||
createCompletedDownloadCard(download) {
|
||||
const completedAt = new Date(download.completed_at).toLocaleString();
|
||||
const duration = this.calculateDuration(download.started_at, download.completed_at);
|
||||
|
||||
return `
|
||||
<div class="download-card completed">
|
||||
<div class="download-header">
|
||||
<div class="download-info">
|
||||
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
||||
<p>${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}</p>
|
||||
<small>Completed: ${completedAt} (${duration})</small>
|
||||
</div>
|
||||
<div class="download-status">
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFailedDownloads(downloads) {
|
||||
const container = document.getElementById('failed-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
<p>No failed downloads</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = downloads.map(download => this.createFailedDownloadCard(download)).join('');
|
||||
}
|
||||
|
||||
createFailedDownloadCard(download) {
|
||||
const failedAt = new Date(download.completed_at).toLocaleString();
|
||||
const retryCount = download.retry_count || 0;
|
||||
|
||||
return `
|
||||
<div class="download-card failed">
|
||||
<div class="download-header">
|
||||
<div class="download-info">
|
||||
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
||||
<p>${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}</p>
|
||||
<small>Failed: ${failedAt} ${retryCount > 0 ? `(Retry ${retryCount})` : ''}</small>
|
||||
${download.error ? `<small class="error-message">${this.escapeHtml(download.error)}</small>` : ''}
|
||||
</div>
|
||||
<div class="download-actions">
|
||||
<button class="btn btn-small btn-warning" onclick="queueManager.retryDownload('${download.id}')">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="queueManager.removeFailedDownload('${download.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateButtonStates(data) {
|
||||
const hasActive = (data.active_downloads || []).length > 0;
|
||||
const hasPending = (data.pending_queue || []).length > 0;
|
||||
const hasFailed = (data.failed_downloads || []).length > 0;
|
||||
|
||||
document.getElementById('pause-all-btn').disabled = !hasActive;
|
||||
document.getElementById('clear-queue-btn').disabled = !hasPending;
|
||||
document.getElementById('reorder-queue-btn').disabled = !hasPending || (data.pending_queue || []).length < 2;
|
||||
document.getElementById('retry-all-btn').disabled = !hasFailed;
|
||||
}
|
||||
|
||||
async clearQueue(type) {
|
||||
const titles = {
|
||||
pending: 'Clear Queue',
|
||||
completed: 'Clear Completed Downloads',
|
||||
failed: 'Clear Failed Downloads'
|
||||
};
|
||||
|
||||
const messages = {
|
||||
pending: 'Are you sure you want to clear all pending downloads from the queue?',
|
||||
completed: 'Are you sure you want to clear all completed downloads?',
|
||||
failed: 'Are you sure you want to clear all failed downloads?'
|
||||
};
|
||||
|
||||
const confirmed = await this.showConfirmModal(titles[type], messages[type]);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await this.makeAuthenticatedRequest('/api/queue/clear', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type })
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.showToast(data.message, 'success');
|
||||
this.loadQueueData();
|
||||
} else {
|
||||
this.showToast(data.message, 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error clearing queue:', error);
|
||||
this.showToast('Failed to clear queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async retryDownload(downloadId) {
|
||||
try {
|
||||
const response = await this.makeAuthenticatedRequest('/api/queue/retry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: downloadId })
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.showToast('Download added back to queue', 'success');
|
||||
this.loadQueueData();
|
||||
} else {
|
||||
this.showToast(data.message, 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error retrying download:', error);
|
||||
this.showToast('Failed to retry download', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async retryAllFailed() {
|
||||
const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?');
|
||||
if (!confirmed) return;
|
||||
|
||||
// Get all failed downloads and retry them individually
|
||||
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed');
|
||||
|
||||
for (const card of failedCards) {
|
||||
const downloadId = card.dataset.id;
|
||||
if (downloadId) {
|
||||
await this.retryDownload(downloadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromQueue(downloadId) {
|
||||
try {
|
||||
const response = await this.makeAuthenticatedRequest('/api/queue/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: downloadId })
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.showToast('Download removed from queue', 'success');
|
||||
this.loadQueueData();
|
||||
} else {
|
||||
this.showToast(data.message, 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error removing from queue:', error);
|
||||
this.showToast('Failed to remove from queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
calculateDuration(startTime, endTime) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
const diffMs = end - start;
|
||||
|
||||
const minutes = Math.floor(diffMs / (1000 * 60));
|
||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
async makeAuthenticatedRequest(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
showConfirmModal(title, message) {
|
||||
return new Promise((resolve) => {
|
||||
document.getElementById('confirm-title').textContent = title;
|
||||
document.getElementById('confirm-message').textContent = message;
|
||||
document.getElementById('confirm-modal').classList.remove('hidden');
|
||||
|
||||
const handleConfirm = () => {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
document.getElementById('confirm-ok').removeEventListener('click', handleConfirm);
|
||||
document.getElementById('confirm-cancel').removeEventListener('click', handleCancel);
|
||||
this.hideConfirmModal();
|
||||
};
|
||||
|
||||
document.getElementById('confirm-ok').addEventListener('click', handleConfirm);
|
||||
document.getElementById('confirm-cancel').addEventListener('click', handleCancel);
|
||||
});
|
||||
}
|
||||
|
||||
hideConfirmModal() {
|
||||
document.getElementById('confirm-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<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);
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.showToast('Logged out successfully', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 1000);
|
||||
} else {
|
||||
this.showToast('Logout failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
this.showToast('Logout failed', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.queueManager = new QueueManager();
|
||||
});
|
||||
|
||||
// Global reference for inline event handlers
|
||||
window.queueManager = null;
|
||||
0
src/server/web/static/js/utils/__init__.py
Normal file
0
src/server/web/static/js/utils/__init__.py
Normal file
0
src/server/web/static/js/vendor/__init__.py
vendored
Normal file
0
src/server/web/static/js/vendor/__init__.py
vendored
Normal file
Reference in New Issue
Block a user