This commit is contained in:
Lukas Pupka-Lipinski 2025-09-29 11:51:58 +02:00
parent f9102d7bcd
commit b2d77a099b
7 changed files with 539 additions and 66 deletions

View File

@ -267,6 +267,7 @@ series_app = None
is_scanning = False is_scanning = False
is_downloading = False is_downloading = False
is_paused = False is_paused = False
should_stop_downloads = False
download_thread = None download_thread = None
download_progress = {} download_progress = {}
download_queue = [] download_queue = []
@ -873,11 +874,120 @@ def rescan_series():
'message': 'Rescan started' 'message': 'Rescan started'
}) })
# Basic download endpoint - simplified for now # Download endpoint - adds items to queue
@app.route('/api/download', methods=['POST']) @app.route('/api/download', methods=['POST'])
@optional_auth @optional_auth
def download_series(): 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 global is_downloading
# Check if download is already running using process lock # Check if download is already running using process lock
@ -888,9 +998,140 @@ def download_series():
'is_running': True 'is_running': True
}), 409 }), 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({ return jsonify({
'status': 'success', '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 # WebSocket events for real-time updates

View File

@ -414,7 +414,7 @@ class BulkOperationsManager {
const confirmed = await this.confirmOperation( const confirmed = await this.confirmOperation(
'Bulk Delete', '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' 'danger'
); );

View File

@ -1437,17 +1437,20 @@ body {
} }
.status-indicator i { .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); transition: all var(--animation-duration-normal) var(--animation-easing-standard);
} }
/* Rescan icon specific styling */ /* Rescan icon specific styling */
#rescan-status i { #rescan-status i {
color: var(--color-text-disabled); /* Gray when idle */ color: var(--color-text-disabled);
/* Gray when idle */
} }
#rescan-status.running i { #rescan-status.running i {
color: #22c55e; /* Green when running */ color: #22c55e;
/* Green when running */
animation: iconPulse 2s infinite; animation: iconPulse 2s infinite;
} }
@ -1474,6 +1477,7 @@ body {
} }
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
opacity: 1; opacity: 1;
@ -1487,6 +1491,7 @@ body {
} }
@keyframes iconPulse { @keyframes iconPulse {
0%, 0%,
100% { 100% {
opacity: 1; opacity: 1;
@ -1514,7 +1519,8 @@ body {
} }
.status-indicator i { .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 */
} }
} }

View File

@ -206,6 +206,7 @@ class AniWorldApp {
this.socket.on('download_started', (data) => { this.socket.on('download_started', (data) => {
this.isDownloading = true; this.isDownloading = true;
this.isPaused = false; this.isPaused = false;
this.updateProcessStatus('download', true);
this.showDownloadQueue(data); this.showDownloadQueue(data);
this.showStatus(`Starting download of ${data.total_series} series...`, true, true); 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'); 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 // Download queue events
this.socket.on('download_queue_update', (data) => { this.socket.on('download_queue_update', (data) => {
this.updateDownloadQueue(data); this.updateDownloadQueue(data);
@ -476,7 +492,13 @@ class AniWorldApp {
} }
async makeAuthenticatedRequest(url, options = {}) { 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) { if (response.status === 401) {
window.location.href = '/login'; window.location.href = '/login';

View File

@ -40,6 +40,41 @@ class QueueManager {
this.socket.on('download_progress_update', (data) => { this.socket.on('download_progress_update', (data) => {
this.updateDownloadProgress(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() { bindEvents() {
@ -70,6 +105,14 @@ class QueueManager {
}); });
// Download controls // 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', () => { document.getElementById('pause-all-btn').addEventListener('click', () => {
this.pauseAllDownloads(); this.pauseAllDownloads();
}); });
@ -359,6 +402,19 @@ class QueueManager {
const hasPending = (data.pending_queue || []).length > 0; const hasPending = (data.pending_queue || []).length > 0;
const hasFailed = (data.failed_downloads || []).length > 0; const hasFailed = (data.failed_downloads || []).length > 0;
// Enable start button only if there are pending items and no active downloads
document.getElementById('start-queue-btn').disabled = !hasPending || hasActive;
// Show/hide start/stop buttons based on whether downloads are active
if (hasActive) {
document.getElementById('start-queue-btn').style.display = 'none';
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
document.getElementById('stop-queue-btn').disabled = false;
} else {
document.getElementById('stop-queue-btn').style.display = 'none';
document.getElementById('start-queue-btn').style.display = 'inline-flex';
}
document.getElementById('pause-all-btn').disabled = !hasActive; document.getElementById('pause-all-btn').disabled = !hasActive;
document.getElementById('clear-queue-btn').disabled = !hasPending; document.getElementById('clear-queue-btn').disabled = !hasPending;
document.getElementById('reorder-queue-btn').disabled = !hasPending || (data.pending_queue || []).length < 2; document.getElementById('reorder-queue-btn').disabled = !hasPending || (data.pending_queue || []).length < 2;
@ -478,8 +534,79 @@ class QueueManager {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
} }
async startDownloadQueue() {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/start', {
method: 'POST'
});
if (!response) return;
const data = await response.json();
if (data.status === 'success') {
this.showToast('Download queue started', 'success');
// Update UI
document.getElementById('start-queue-btn').style.display = 'none';
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
document.getElementById('stop-queue-btn').disabled = false;
} else {
this.showToast(`Failed to start queue: ${data.message}`, 'error');
}
} catch (error) {
console.error('Error starting download queue:', error);
this.showToast('Failed to start download queue', 'error');
}
}
async stopDownloadQueue() {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/stop', {
method: 'POST'
});
if (!response) return;
const data = await response.json();
if (data.status === 'success') {
this.showToast('Download queue stopped', 'success');
// Update UI
document.getElementById('stop-queue-btn').style.display = 'none';
document.getElementById('start-queue-btn').style.display = 'inline-flex';
document.getElementById('start-queue-btn').disabled = false;
} else {
this.showToast(`Failed to stop queue: ${data.message}`, 'error');
}
} catch (error) {
console.error('Error stopping download queue:', error);
this.showToast('Failed to stop download queue', 'error');
}
}
pauseAllDownloads() {
// TODO: Implement pause functionality
this.showToast('Pause functionality not yet implemented', 'info');
}
resumeAllDownloads() {
// TODO: Implement resume functionality
this.showToast('Resume functionality not yet implemented', 'info');
}
toggleReorderMode() {
// TODO: Implement reorder functionality
this.showToast('Reorder functionality not yet implemented', 'info');
}
async makeAuthenticatedRequest(url, options = {}) { 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) { if (response.status === 401) {
window.location.href = '/login'; window.location.href = '/login';

View File

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="light"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -7,6 +8,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<!-- Header --> <!-- Header -->
@ -132,6 +134,14 @@
Download Queue Download Queue
</h2> </h2>
<div class="section-actions"> <div class="section-actions">
<button id="start-queue-btn" class="btn btn-primary" disabled>
<i class="fas fa-play"></i>
Start Downloads
</button>
<button id="stop-queue-btn" class="btn btn-secondary" disabled style="display: none;">
<i class="fas fa-stop"></i>
Stop Downloads
</button>
<button id="clear-queue-btn" class="btn btn-secondary" disabled> <button id="clear-queue-btn" class="btn btn-secondary" disabled>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
Clear Queue Clear Queue
@ -238,4 +248,5 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<script src="{{ url_for('static', filename='js/queue.js') }}"></script> <script src="{{ url_for('static', filename='js/queue.js') }}"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
Simple test script to verify the stop download functionality works properly.
This simulates the browser behavior without authentication requirements.
"""
import requests
import time
import json
def test_stop_download_functionality():
"""Test the stop download functionality."""
base_url = "http://127.0.0.1:5000"
print("Testing Stop Download Functionality")
print("=" * 50)
# Test 1: Try to stop when no downloads are running
print("\n1. Testing stop when no downloads are running...")
try:
response = requests.post(f"{base_url}/api/queue/stop", timeout=5)
print(f"Status Code: {response.status_code}")
print(f"Response: {response.json()}")
if response.status_code == 400:
print("✓ Correctly returns error when no downloads are running")
elif response.status_code == 401:
print("⚠ Authentication required - this is expected")
else:
print(f"✗ Unexpected response: {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"✗ Connection error: {e}")
return False
# Test 2: Check queue status endpoint
print("\n2. Testing queue status endpoint...")
try:
response = requests.get(f"{base_url}/api/queue/status", timeout=5)
print(f"Status Code: {response.status_code}")
if response.status_code == 200:
print(f"Response: {response.json()}")
print("✓ Queue status endpoint working")
elif response.status_code == 401:
print("⚠ Authentication required for queue status")
else:
print(f"✗ Unexpected status code: {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"✗ Connection error: {e}")
# Test 3: Check if the server is responding
print("\n3. Testing server health...")
try:
response = requests.get(f"{base_url}/", timeout=5)
print(f"Status Code: {response.status_code}")
if response.status_code == 200:
print("✓ Server is responding")
else:
print(f"⚠ Server returned: {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"✗ Server connection error: {e}")
return True
if __name__ == "__main__":
test_stop_download_functionality()