diff --git a/src/server/app.py b/src/server/app.py index b694c66..9c6380e 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -267,6 +267,7 @@ series_app = None is_scanning = False is_downloading = False is_paused = False +should_stop_downloads = False download_thread = None download_progress = {} download_queue = [] @@ -873,11 +874,120 @@ def rescan_series(): 'message': 'Rescan started' }) -# Basic download endpoint - simplified for now +# Download endpoint - adds items to queue @app.route('/api/download', methods=['POST']) @optional_auth def download_series(): - """Download selected series.""" + """Add selected series to download queue.""" + try: + data = request.get_json() + if not data or 'folders' not in data: + return jsonify({ + 'status': 'error', + 'message': 'Folders list is required' + }), 400 + + folders = data['folders'] + if not folders: + return jsonify({ + 'status': 'error', + 'message': 'No series selected' + }), 400 + + # Import the queue functions + from application.services.queue_service import add_to_download_queue + + added_count = 0 + for folder in folders: + try: + # Find the serie in our list + serie = None + if series_app and series_app.List: + for s in series_app.List.GetList(): + if s.folder == folder: + serie = s + break + + if serie: + # Check if this serie has missing episodes (non-empty episodeDict) + if serie.episodeDict: + # Create download entries for each season/episode combination + for season, episodes in serie.episodeDict.items(): + for episode in episodes: + episode_info = { + 'folder': folder, + 'season': season, + 'episode_number': episode, + 'title': f'S{season:02d}E{episode:02d}', + 'url': '', # Will be populated during actual download + 'serie_name': serie.name or folder + } + + add_to_download_queue( + serie_name=serie.name or folder, + episode_info=episode_info, + priority='normal' + ) + added_count += 1 + else: + # No missing episodes, add a placeholder entry indicating series is complete + episode_info = { + 'folder': folder, + 'season': None, + 'episode_number': 'Complete', + 'title': 'No missing episodes', + 'url': '', + 'serie_name': serie.name or folder + } + + add_to_download_queue( + serie_name=serie.name or folder, + episode_info=episode_info, + priority='normal' + ) + added_count += 1 + else: + # Serie not found, add with folder name only + episode_info = { + 'folder': folder, + 'episode_number': 'Unknown', + 'title': 'Serie Check Required', + 'url': '', + 'serie_name': folder + } + + add_to_download_queue( + serie_name=folder, + episode_info=episode_info, + priority='normal' + ) + added_count += 1 + + except Exception as e: + print(f"Error processing folder {folder}: {e}") + continue + + if added_count > 0: + return jsonify({ + 'status': 'success', + 'message': f'Added {added_count} items to download queue' + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'No items could be added to the queue' + }), 400 + + except Exception as e: + return jsonify({ + 'status': 'error', + 'message': f'Failed to add to queue: {str(e)}' + }), 500 + +@app.route('/api/queue/start', methods=['POST']) +@optional_auth +def start_download_queue(): + """Start processing the download queue.""" global is_downloading # Check if download is already running using process lock @@ -888,9 +998,140 @@ def download_series(): 'is_running': True }), 409 + def download_thread(): + global is_downloading, should_stop_downloads + should_stop_downloads = False # Reset stop flag when starting + + try: + # Use process lock to prevent duplicate downloads + @with_process_lock(DOWNLOAD_LOCK, timeout_minutes=720) # 12 hours max + def perform_downloads(): + global is_downloading + is_downloading = True + + try: + from application.services.queue_service import start_next_download, move_download_to_completed, update_download_progress + + # Emit download started + socketio.emit('download_started') + + # Process queue items + while True: + # Check for stop signal + global should_stop_downloads + if should_stop_downloads: + should_stop_downloads = False # Reset the flag + break + + # Start next download + current_download = start_next_download() + if not current_download: + break # No more items in queue + + try: + socketio.emit('download_progress', { + 'id': current_download['id'], + 'serie': current_download['serie_name'], + 'episode': current_download['episode']['episode_number'], + 'status': 'downloading' + }) + + # Simulate download process (replace with actual download logic) + import time + for i in range(0, 101, 10): + # Check for stop signal during download + if should_stop_downloads: + move_download_to_completed(current_download['id'], success=False, error='Download stopped by user') + socketio.emit('download_stopped', { + 'message': 'Download queue stopped by user' + }) + should_stop_downloads = False + raise Exception('Download stopped by user') + + update_download_progress(current_download['id'], { + 'percent': i, + 'speed_mbps': 2.5, + 'eta_seconds': (100 - i) * 2 + }) + + socketio.emit('download_progress', { + 'id': current_download['id'], + 'serie': current_download['serie_name'], + 'episode': current_download['episode']['episode_number'], + 'progress': i + }) + + time.sleep(0.5) # Simulate download time + + # Mark as completed + move_download_to_completed(current_download['id'], success=True) + + socketio.emit('download_completed', { + 'id': current_download['id'], + 'serie': current_download['serie_name'], + 'episode': current_download['episode']['episode_number'] + }) + + except Exception as e: + # Mark as failed + move_download_to_completed(current_download['id'], success=False, error=str(e)) + + socketio.emit('download_error', { + 'id': current_download['id'], + 'serie': current_download['serie_name'], + 'episode': current_download['episode']['episode_number'], + 'error': str(e) + }) + + # Emit download queue completed + socketio.emit('download_queue_completed') + + except Exception as e: + socketio.emit('download_error', {'message': str(e)}) + raise + finally: + is_downloading = False + + perform_downloads(_locked_by='web_interface') + + except ProcessLockError: + socketio.emit('download_error', {'message': 'Download is already running'}) + except Exception as e: + socketio.emit('download_error', {'message': str(e)}) + + # Start download in background thread + threading.Thread(target=download_thread, daemon=True).start() + return jsonify({ 'status': 'success', - 'message': 'Download functionality will be implemented with queue system' + 'message': 'Download queue processing started' + }) + +@app.route('/api/queue/stop', methods=['POST']) +@optional_auth +def stop_download_queue(): + """Stop processing the download queue.""" + global is_downloading, should_stop_downloads + + # Check if any download is currently running + if not is_downloading and not is_process_running(DOWNLOAD_LOCK): + return jsonify({ + 'status': 'error', + 'message': 'No download is currently running' + }), 400 + + # Set stop signal for graceful shutdown + should_stop_downloads = True + + # Don't forcefully set is_downloading to False here, let the download thread handle it + # This prevents race conditions where the thread might still be running + + # Emit stop signal to clients immediately + socketio.emit('download_stop_requested') + + return jsonify({ + 'status': 'success', + 'message': 'Download stop requested. Downloads will stop gracefully.' }) # WebSocket events for real-time updates diff --git a/src/server/application/services/bulk_service.py b/src/server/application/services/bulk_service.py index b0e4274..422bf32 100644 --- a/src/server/application/services/bulk_service.py +++ b/src/server/application/services/bulk_service.py @@ -414,7 +414,7 @@ class BulkOperationsManager { const confirmed = await this.confirmOperation( 'Bulk Delete', - `Permanently delete ${this.selectedItems.size} selected series?\\n\\nThis action cannot be undone`, + `Permanently delete ${this.selectedItems.size} selected series?\\n\\nThis action cannot be undone!`, 'danger' ); diff --git a/src/server/web/static/css/styles.css b/src/server/web/static/css/styles.css index 700dd1c..a0efead 100644 --- a/src/server/web/static/css/styles.css +++ b/src/server/web/static/css/styles.css @@ -1437,17 +1437,20 @@ body { } .status-indicator i { - font-size: 24px; /* 2x bigger: 12px -> 24px */ + font-size: 24px; + /* 2x bigger: 12px -> 24px */ transition: all var(--animation-duration-normal) var(--animation-easing-standard); } /* Rescan icon specific styling */ #rescan-status i { - color: var(--color-text-disabled); /* Gray when idle */ + color: var(--color-text-disabled); + /* Gray when idle */ } #rescan-status.running i { - color: #22c55e; /* Green when running */ + color: #22c55e; + /* Green when running */ animation: iconPulse 2s infinite; } @@ -1474,6 +1477,7 @@ body { } @keyframes pulse { + 0%, 100% { opacity: 1; @@ -1487,6 +1491,7 @@ body { } @keyframes iconPulse { + 0%, 100% { opacity: 1; @@ -1514,7 +1519,8 @@ body { } .status-indicator i { - font-size: 20px; /* Maintain 2x scale for mobile: was 14px -> 20px */ + font-size: 20px; + /* Maintain 2x scale for mobile: was 14px -> 20px */ } } diff --git a/src/server/web/static/js/app.js b/src/server/web/static/js/app.js index e90f2ae..2f5d86b 100644 --- a/src/server/web/static/js/app.js +++ b/src/server/web/static/js/app.js @@ -206,6 +206,7 @@ class AniWorldApp { 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); }); @@ -237,6 +238,21 @@ class AniWorldApp { 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); @@ -476,7 +492,13 @@ class AniWorldApp { } async makeAuthenticatedRequest(url, options = {}) { - const response = await fetch(url, options); + // Ensure credentials are included for session-based authentication + const requestOptions = { + credentials: 'same-origin', + ...options + }; + + const response = await fetch(url, requestOptions); if (response.status === 401) { window.location.href = '/login'; diff --git a/src/server/web/static/js/queue.js b/src/server/web/static/js/queue.js index d76f38e..4d84476 100644 --- a/src/server/web/static/js/queue.js +++ b/src/server/web/static/js/queue.js @@ -7,7 +7,7 @@ class QueueManager { this.socket = null; this.refreshInterval = null; this.isReordering = false; - + this.init(); } @@ -21,7 +21,7 @@ class QueueManager { initSocket() { this.socket = io(); - + this.socket.on('connect', () => { console.log('Connected to server'); this.showToast('Connected to server', 'success'); @@ -40,6 +40,41 @@ class QueueManager { this.socket.on('download_progress_update', (data) => { this.updateDownloadProgress(data); }); + + // Download queue events + this.socket.on('download_started', () => { + this.showToast('Download queue started', 'success'); + this.loadQueueData(); // Refresh data + }); + + this.socket.on('download_progress', (data) => { + this.updateDownloadProgress(data); + }); + + this.socket.on('download_completed', (data) => { + this.showToast(`Completed: ${data.serie} - Episode ${data.episode}`, 'success'); + this.loadQueueData(); // Refresh data + }); + + this.socket.on('download_error', (data) => { + const message = data.error || data.message || 'Unknown error'; + this.showToast(`Download failed: ${message}`, 'error'); + this.loadQueueData(); // Refresh data + }); + + this.socket.on('download_queue_completed', () => { + this.showToast('All downloads completed!', 'success'); + this.loadQueueData(); // Refresh data + }); + + this.socket.on('download_stop_requested', () => { + this.showToast('Stopping downloads...', 'info'); + }); + + this.socket.on('download_stopped', () => { + this.showToast('Download queue stopped', 'success'); + this.loadQueueData(); // Refresh data + }); } bindEvents() { @@ -70,6 +105,14 @@ class QueueManager { }); // Download controls + document.getElementById('start-queue-btn').addEventListener('click', () => { + this.startDownloadQueue(); + }); + + document.getElementById('stop-queue-btn').addEventListener('click', () => { + this.stopDownloadQueue(); + }); + document.getElementById('pause-all-btn').addEventListener('click', () => { this.pauseAllDownloads(); }); @@ -105,7 +148,7 @@ class QueueManager { 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'; } @@ -127,10 +170,10 @@ class QueueManager { 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); } @@ -139,19 +182,19 @@ class QueueManager { 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); } @@ -161,17 +204,17 @@ class QueueManager { 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)); @@ -186,7 +229,7 @@ class QueueManager { renderActiveDownloads(downloads) { const container = document.getElementById('active-downloads'); - + if (downloads.length === 0) { container.innerHTML = `