new folder structure
This commit is contained in:
3
src/server/web/__init__.py
Normal file
3
src/server/web/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Web presentation layer with controllers, middleware, and templates.
|
||||
"""
|
||||
1
src/server/web/controllers/__init__.py
Normal file
1
src/server/web/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Web controllers - Flask blueprints
|
||||
1
src/server/web/controllers/admin/__init__.py
Normal file
1
src/server/web/controllers/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Admin controllers
|
||||
1
src/server/web/controllers/api/__init__.py
Normal file
1
src/server/web/controllers/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API endpoints version 1
|
||||
1
src/server/web/controllers/api/middleware/__init__.py
Normal file
1
src/server/web/controllers/api/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API middleware
|
||||
1
src/server/web/controllers/api/v1/__init__.py
Normal file
1
src/server/web/controllers/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API version 1 endpoints
|
||||
341
src/server/web/controllers/api/v1/bulk.py
Normal file
341
src/server/web/controllers/api/v1/bulk.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Bulk Operations API endpoints
|
||||
Provides REST API for bulk series management operations.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Dict, Any
|
||||
import uuid
|
||||
import io
|
||||
from bulk_operations import bulk_operations_manager
|
||||
|
||||
bulk_api_bp = Blueprint('bulk_api', __name__, url_prefix='/api/bulk')
|
||||
|
||||
# Store active operations
|
||||
active_operations = {}
|
||||
|
||||
@bulk_api_bp.route('/download', methods=['POST'])
|
||||
def bulk_download():
|
||||
"""Start bulk download operation."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
operation_id = data.get('operation_id')
|
||||
series_ids = data.get('series_ids', [])
|
||||
|
||||
if not series_ids:
|
||||
return jsonify({'success': False, 'error': 'No series IDs provided'}), 400
|
||||
|
||||
# Create task ID
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# Store operation info
|
||||
active_operations[task_id] = {
|
||||
'id': operation_id,
|
||||
'type': 'download',
|
||||
'status': 'running',
|
||||
'progress': {
|
||||
'completed': 0,
|
||||
'total': len(series_ids),
|
||||
'message': 'Starting download...'
|
||||
}
|
||||
}
|
||||
|
||||
# Start async operation
|
||||
def run_bulk_download():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
bulk_operations_manager.bulk_download(series_ids, operation_id)
|
||||
)
|
||||
active_operations[task_id]['status'] = 'completed'
|
||||
active_operations[task_id]['result'] = result
|
||||
except Exception as e:
|
||||
active_operations[task_id]['status'] = 'failed'
|
||||
active_operations[task_id]['error'] = str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
thread = threading.Thread(target=run_bulk_download)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/update', methods=['POST'])
|
||||
def bulk_update():
|
||||
"""Start bulk update operation."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
operation_id = data.get('operation_id')
|
||||
series_ids = data.get('series_ids', [])
|
||||
|
||||
if not series_ids:
|
||||
return jsonify({'success': False, 'error': 'No series IDs provided'}), 400
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
active_operations[task_id] = {
|
||||
'id': operation_id,
|
||||
'type': 'update',
|
||||
'status': 'running',
|
||||
'progress': {
|
||||
'completed': 0,
|
||||
'total': len(series_ids),
|
||||
'message': 'Starting update...'
|
||||
}
|
||||
}
|
||||
|
||||
def run_bulk_update():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
bulk_operations_manager.bulk_update(series_ids, operation_id)
|
||||
)
|
||||
active_operations[task_id]['status'] = 'completed'
|
||||
active_operations[task_id]['result'] = result
|
||||
except Exception as e:
|
||||
active_operations[task_id]['status'] = 'failed'
|
||||
active_operations[task_id]['error'] = str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
thread = threading.Thread(target=run_bulk_update)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/organize', methods=['POST'])
|
||||
def bulk_organize():
|
||||
"""Start bulk organize operation."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
operation_id = data.get('operation_id')
|
||||
series_ids = data.get('series_ids', [])
|
||||
options = data.get('options', {})
|
||||
|
||||
if not series_ids:
|
||||
return jsonify({'success': False, 'error': 'No series IDs provided'}), 400
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
active_operations[task_id] = {
|
||||
'id': operation_id,
|
||||
'type': 'organize',
|
||||
'status': 'running',
|
||||
'progress': {
|
||||
'completed': 0,
|
||||
'total': len(series_ids),
|
||||
'message': 'Starting organization...'
|
||||
}
|
||||
}
|
||||
|
||||
def run_bulk_organize():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
bulk_operations_manager.bulk_organize(series_ids, options, operation_id)
|
||||
)
|
||||
active_operations[task_id]['status'] = 'completed'
|
||||
active_operations[task_id]['result'] = result
|
||||
except Exception as e:
|
||||
active_operations[task_id]['status'] = 'failed'
|
||||
active_operations[task_id]['error'] = str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
thread = threading.Thread(target=run_bulk_organize)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/delete', methods=['DELETE'])
|
||||
def bulk_delete():
|
||||
"""Start bulk delete operation."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
operation_id = data.get('operation_id')
|
||||
series_ids = data.get('series_ids', [])
|
||||
|
||||
if not series_ids:
|
||||
return jsonify({'success': False, 'error': 'No series IDs provided'}), 400
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
active_operations[task_id] = {
|
||||
'id': operation_id,
|
||||
'type': 'delete',
|
||||
'status': 'running',
|
||||
'progress': {
|
||||
'completed': 0,
|
||||
'total': len(series_ids),
|
||||
'message': 'Starting deletion...'
|
||||
}
|
||||
}
|
||||
|
||||
def run_bulk_delete():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
bulk_operations_manager.bulk_delete(series_ids, operation_id)
|
||||
)
|
||||
active_operations[task_id]['status'] = 'completed'
|
||||
active_operations[task_id]['result'] = result
|
||||
except Exception as e:
|
||||
active_operations[task_id]['status'] = 'failed'
|
||||
active_operations[task_id]['error'] = str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
thread = threading.Thread(target=run_bulk_delete)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/export', methods=['POST'])
|
||||
def bulk_export():
|
||||
"""Export series data."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
series_ids = data.get('series_ids', [])
|
||||
format_type = data.get('format', 'json')
|
||||
|
||||
if not series_ids:
|
||||
return jsonify({'success': False, 'error': 'No series IDs provided'}), 400
|
||||
|
||||
# Generate export data
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
export_data = loop.run_until_complete(
|
||||
bulk_operations_manager.export_series_data(series_ids, format_type)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Determine content type and filename
|
||||
content_types = {
|
||||
'json': 'application/json',
|
||||
'csv': 'text/csv',
|
||||
'xml': 'application/xml'
|
||||
}
|
||||
|
||||
content_type = content_types.get(format_type, 'application/octet-stream')
|
||||
filename = f'series_export_{len(series_ids)}_items.{format_type}'
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(export_data),
|
||||
mimetype=content_type,
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/status/<task_id>', methods=['GET'])
|
||||
def get_operation_status(task_id):
|
||||
"""Get operation status and progress."""
|
||||
try:
|
||||
if task_id not in active_operations:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
operation = active_operations[task_id]
|
||||
|
||||
response = {
|
||||
'complete': operation['status'] in ['completed', 'failed'],
|
||||
'success': operation['status'] == 'completed',
|
||||
'status': operation['status']
|
||||
}
|
||||
|
||||
if 'progress' in operation:
|
||||
response.update(operation['progress'])
|
||||
|
||||
if 'error' in operation:
|
||||
response['error'] = operation['error']
|
||||
|
||||
if 'result' in operation:
|
||||
response['result'] = operation['result']
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/cancel/<task_id>', methods=['POST'])
|
||||
def cancel_operation(task_id):
|
||||
"""Cancel a running operation."""
|
||||
try:
|
||||
if task_id not in active_operations:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
# Mark operation as cancelled
|
||||
active_operations[task_id]['status'] = 'cancelled'
|
||||
|
||||
return jsonify({'success': True, 'message': 'Operation cancelled'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/history', methods=['GET'])
|
||||
def get_operation_history():
|
||||
"""Get history of bulk operations."""
|
||||
try:
|
||||
# Return completed/failed operations
|
||||
history = []
|
||||
for task_id, operation in active_operations.items():
|
||||
if operation['status'] in ['completed', 'failed', 'cancelled']:
|
||||
history.append({
|
||||
'task_id': task_id,
|
||||
'operation_id': operation['id'],
|
||||
'type': operation['type'],
|
||||
'status': operation['status'],
|
||||
'progress': operation.get('progress', {}),
|
||||
'error': operation.get('error'),
|
||||
'result': operation.get('result')
|
||||
})
|
||||
|
||||
# Sort by most recent first
|
||||
history.sort(key=lambda x: x.get('progress', {}).get('completed', 0), reverse=True)
|
||||
|
||||
return jsonify({'history': history})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bulk_api_bp.route('/cleanup', methods=['POST'])
|
||||
def cleanup_completed_operations():
|
||||
"""Clean up completed/failed operations."""
|
||||
try:
|
||||
to_remove = []
|
||||
for task_id, operation in active_operations.items():
|
||||
if operation['status'] in ['completed', 'failed', 'cancelled']:
|
||||
to_remove.append(task_id)
|
||||
|
||||
for task_id in to_remove:
|
||||
del active_operations[task_id]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'cleaned_up': len(to_remove),
|
||||
'message': f'Cleaned up {len(to_remove)} completed operations'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
417
src/server/web/controllers/api/v1/config.py
Normal file
417
src/server/web/controllers/api/v1/config.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
API endpoints for configuration management.
|
||||
Provides comprehensive configuration management with validation, backup, and restore functionality.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from auth import require_auth
|
||||
from config import config
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config_bp = Blueprint('config', __name__, url_prefix='/api/config')
|
||||
|
||||
@config_bp.route('/', methods=['GET'])
|
||||
@require_auth
|
||||
def get_full_config():
|
||||
"""Get complete configuration (without sensitive data)."""
|
||||
try:
|
||||
config_data = config.export_config(include_sensitive=False)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config_data,
|
||||
'schema': config.get_config_schema()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting configuration: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/', methods=['POST'])
|
||||
@require_auth
|
||||
def update_config():
|
||||
"""Update configuration with validation."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Import the configuration with validation
|
||||
result = config.import_config(data, validate=True)
|
||||
|
||||
if result['success']:
|
||||
logger.info("Configuration updated successfully")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Configuration updated successfully',
|
||||
'warnings': result.get('warnings', [])
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Configuration validation failed',
|
||||
'errors': result['errors'],
|
||||
'warnings': result.get('warnings', [])
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating configuration: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/validate', methods=['POST'])
|
||||
@require_auth
|
||||
def validate_config():
|
||||
"""Validate configuration without saving."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
validation_result = config.validate_config(data)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'validation': validation_result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating configuration: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/section/<section_name>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_config_section(section_name):
|
||||
"""Get specific configuration section."""
|
||||
try:
|
||||
section_data = config.get(section_name, {})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'section': section_name,
|
||||
'config': section_data
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config section {section_name}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/section/<section_name>', methods=['POST'])
|
||||
@require_auth
|
||||
def update_config_section(section_name):
|
||||
"""Update specific configuration section."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Get current config
|
||||
current_config = config.export_config(include_sensitive=True)
|
||||
|
||||
# Update the specific section
|
||||
current_config[section_name] = data
|
||||
|
||||
# Validate and save
|
||||
result = config.import_config(current_config, validate=True)
|
||||
|
||||
if result['success']:
|
||||
logger.info(f"Configuration section '{section_name}' updated successfully")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Configuration section "{section_name}" updated successfully',
|
||||
'warnings': result.get('warnings', [])
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Configuration validation failed',
|
||||
'errors': result['errors'],
|
||||
'warnings': result.get('warnings', [])
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating config section {section_name}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/backup', methods=['POST'])
|
||||
@require_auth
|
||||
def create_backup():
|
||||
"""Create configuration backup."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
backup_name = data.get('name', '')
|
||||
|
||||
# Generate backup filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if backup_name:
|
||||
# Sanitize backup name
|
||||
backup_name = secure_filename(backup_name)
|
||||
filename = f"config_backup_{backup_name}_{timestamp}.json"
|
||||
else:
|
||||
filename = f"config_backup_{timestamp}.json"
|
||||
|
||||
backup_path = config.backup_config(filename)
|
||||
|
||||
logger.info(f"Configuration backup created: {backup_path}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Backup created successfully',
|
||||
'backup_path': backup_path,
|
||||
'filename': filename
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating backup: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/backups', methods=['GET'])
|
||||
@require_auth
|
||||
def list_backups():
|
||||
"""List available configuration backups."""
|
||||
try:
|
||||
backups = []
|
||||
|
||||
# Scan current directory for backup files
|
||||
for filename in os.listdir('.'):
|
||||
if filename.startswith('config_backup_') and filename.endswith('.json'):
|
||||
file_path = os.path.abspath(filename)
|
||||
file_size = os.path.getsize(filename)
|
||||
file_modified = datetime.fromtimestamp(os.path.getmtime(filename))
|
||||
|
||||
backups.append({
|
||||
'filename': filename,
|
||||
'path': file_path,
|
||||
'size': file_size,
|
||||
'size_kb': round(file_size / 1024, 2),
|
||||
'modified': file_modified.isoformat(),
|
||||
'modified_display': file_modified.strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
# Sort by modification date (newest first)
|
||||
backups.sort(key=lambda x: x['modified'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'backups': backups
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/backup/<filename>/restore', methods=['POST'])
|
||||
@require_auth
|
||||
def restore_backup(filename):
|
||||
"""Restore configuration from backup."""
|
||||
try:
|
||||
# Security: Only allow config backup files
|
||||
if not filename.startswith('config_backup_') or not filename.endswith('.json'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid backup file'
|
||||
}), 400
|
||||
|
||||
# Security: Check if file exists
|
||||
if not os.path.exists(filename):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Backup file not found'
|
||||
}), 404
|
||||
|
||||
success = config.restore_config(filename)
|
||||
|
||||
if success:
|
||||
logger.info(f"Configuration restored from backup: {filename}")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Configuration restored successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to restore configuration'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring backup {filename}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/backup/<filename>/download', methods=['GET'])
|
||||
@require_auth
|
||||
def download_backup(filename):
|
||||
"""Download configuration backup file."""
|
||||
try:
|
||||
# Security: Only allow config backup files
|
||||
if not filename.startswith('config_backup_') or not filename.endswith('.json'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid backup file'
|
||||
}), 400
|
||||
|
||||
# Security: Check if file exists
|
||||
if not os.path.exists(filename):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Backup file not found'
|
||||
}), 404
|
||||
|
||||
return send_file(
|
||||
filename,
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading backup {filename}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/export', methods=['POST'])
|
||||
@require_auth
|
||||
def export_config():
|
||||
"""Export current configuration to JSON."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
include_sensitive = data.get('include_sensitive', False)
|
||||
|
||||
config_data = config.export_config(include_sensitive=include_sensitive)
|
||||
|
||||
# Create filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"aniworld_config_export_{timestamp}.json"
|
||||
|
||||
# Write to temporary file
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(config_data, f, indent=4)
|
||||
|
||||
return send_file(
|
||||
filename,
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting configuration: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/import', methods=['POST'])
|
||||
@require_auth
|
||||
def import_config():
|
||||
"""Import configuration from uploaded JSON file."""
|
||||
try:
|
||||
if 'config_file' not in request.files:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No file uploaded'
|
||||
}), 400
|
||||
|
||||
file = request.files['config_file']
|
||||
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No file selected'
|
||||
}), 400
|
||||
|
||||
if not file.filename.endswith('.json'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid file type. Only JSON files are allowed.'
|
||||
}), 400
|
||||
|
||||
# Read and parse JSON
|
||||
try:
|
||||
config_data = json.load(file)
|
||||
except json.JSONDecodeError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Invalid JSON format: {e}'
|
||||
}), 400
|
||||
|
||||
# Import configuration with validation
|
||||
result = config.import_config(config_data, validate=True)
|
||||
|
||||
if result['success']:
|
||||
logger.info(f"Configuration imported from file: {file.filename}")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Configuration imported successfully',
|
||||
'warnings': result.get('warnings', [])
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Configuration validation failed',
|
||||
'errors': result['errors'],
|
||||
'warnings': result.get('warnings', [])
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing configuration: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@config_bp.route('/reset', methods=['POST'])
|
||||
@require_auth
|
||||
def reset_config():
|
||||
"""Reset configuration to defaults (preserves security settings)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
preserve_security = data.get('preserve_security', True)
|
||||
|
||||
# Get current security settings
|
||||
current_security = config.get('security', {}) if preserve_security else {}
|
||||
|
||||
# Reset to defaults
|
||||
config._config = config.default_config.copy()
|
||||
|
||||
# Restore security settings if requested
|
||||
if preserve_security and current_security:
|
||||
config._config['security'] = current_security
|
||||
|
||||
success = config.save_config()
|
||||
|
||||
if success:
|
||||
logger.info("Configuration reset to defaults")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Configuration reset to defaults'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to save configuration'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting configuration: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
649
src/server/web/controllers/api/v1/database.py
Normal file
649
src/server/web/controllers/api/v1/database.py
Normal file
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
Database & Storage Management API Endpoints
|
||||
|
||||
This module provides REST API endpoints for database operations,
|
||||
backup management, and storage monitoring.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from auth import require_auth, optional_auth
|
||||
from error_handler import handle_api_errors, RetryableError, NonRetryableError
|
||||
from database_manager import (
|
||||
database_manager, anime_repository, backup_manager, storage_manager,
|
||||
AnimeMetadata
|
||||
)
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
|
||||
# Blueprint for database management endpoints
|
||||
database_bp = Blueprint('database', __name__)
|
||||
|
||||
|
||||
# Database Information Endpoints
|
||||
@database_bp.route('/api/database/info')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_database_info():
|
||||
"""Get database information and statistics."""
|
||||
try:
|
||||
# Get schema version
|
||||
schema_version = database_manager.get_current_version()
|
||||
|
||||
# Get table statistics
|
||||
stats_query = """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM anime_metadata) as anime_count,
|
||||
(SELECT COUNT(*) FROM episode_metadata) as episode_count,
|
||||
(SELECT COUNT(*) FROM episode_metadata WHERE is_downloaded = 1) as downloaded_count,
|
||||
(SELECT COUNT(*) FROM download_history) as download_history_count
|
||||
"""
|
||||
|
||||
results = database_manager.execute_query(stats_query)
|
||||
stats = dict(results[0]) if results else {}
|
||||
|
||||
# Get database file size
|
||||
db_size = os.path.getsize(database_manager.db_path) if os.path.exists(database_manager.db_path) else 0
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'schema_version': schema_version,
|
||||
'database_path': database_manager.db_path,
|
||||
'database_size_mb': round(db_size / (1024 * 1024), 2),
|
||||
'statistics': {
|
||||
'anime_count': stats.get('anime_count', 0),
|
||||
'episode_count': stats.get('episode_count', 0),
|
||||
'downloaded_count': stats.get('downloaded_count', 0),
|
||||
'download_history_count': stats.get('download_history_count', 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get database info: {e}")
|
||||
|
||||
|
||||
# Anime Metadata Endpoints
|
||||
@database_bp.route('/api/database/anime')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_all_anime():
|
||||
"""Get all anime from database."""
|
||||
try:
|
||||
status_filter = request.args.get('status')
|
||||
anime_list = anime_repository.get_all_anime(status_filter)
|
||||
|
||||
# Convert to serializable format
|
||||
anime_data = []
|
||||
for anime in anime_list:
|
||||
anime_data.append({
|
||||
'anime_id': anime.anime_id,
|
||||
'name': anime.name,
|
||||
'folder': anime.folder,
|
||||
'key': anime.key,
|
||||
'description': anime.description,
|
||||
'genres': anime.genres,
|
||||
'release_year': anime.release_year,
|
||||
'status': anime.status,
|
||||
'total_episodes': anime.total_episodes,
|
||||
'poster_url': anime.poster_url,
|
||||
'last_updated': anime.last_updated.isoformat(),
|
||||
'created_at': anime.created_at.isoformat(),
|
||||
'custom_metadata': anime.custom_metadata
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'anime': anime_data,
|
||||
'count': len(anime_data)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get anime list: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/anime/<anime_id>')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_anime_by_id(anime_id):
|
||||
"""Get specific anime by ID."""
|
||||
try:
|
||||
query = "SELECT * FROM anime_metadata WHERE anime_id = ?"
|
||||
results = database_manager.execute_query(query, (anime_id,))
|
||||
|
||||
if not results:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Anime not found'
|
||||
}), 404
|
||||
|
||||
row = results[0]
|
||||
anime_data = {
|
||||
'anime_id': row['anime_id'],
|
||||
'name': row['name'],
|
||||
'folder': row['folder'],
|
||||
'key': row['key'],
|
||||
'description': row['description'],
|
||||
'genres': row['genres'],
|
||||
'release_year': row['release_year'],
|
||||
'status': row['status'],
|
||||
'total_episodes': row['total_episodes'],
|
||||
'poster_url': row['poster_url'],
|
||||
'last_updated': row['last_updated'],
|
||||
'created_at': row['created_at'],
|
||||
'custom_metadata': row['custom_metadata']
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': anime_data
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get anime: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/anime', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def create_anime():
|
||||
"""Create new anime record."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['name', 'folder']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Missing required field: {field}'
|
||||
}), 400
|
||||
|
||||
# Create anime metadata
|
||||
anime = AnimeMetadata(
|
||||
anime_id=str(uuid.uuid4()),
|
||||
name=data['name'],
|
||||
folder=data['folder'],
|
||||
key=data.get('key'),
|
||||
description=data.get('description'),
|
||||
genres=data.get('genres', []),
|
||||
release_year=data.get('release_year'),
|
||||
status=data.get('status', 'ongoing'),
|
||||
total_episodes=data.get('total_episodes'),
|
||||
poster_url=data.get('poster_url'),
|
||||
custom_metadata=data.get('custom_metadata', {})
|
||||
)
|
||||
|
||||
success = anime_repository.create_anime(anime)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Anime created successfully',
|
||||
'data': {
|
||||
'anime_id': anime.anime_id
|
||||
}
|
||||
}), 201
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to create anime'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to create anime: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/anime/<anime_id>', methods=['PUT'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def update_anime(anime_id):
|
||||
"""Update anime metadata."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Get existing anime
|
||||
existing = anime_repository.get_anime_by_folder(data.get('folder', ''))
|
||||
if not existing or existing.anime_id != anime_id:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Anime not found'
|
||||
}), 404
|
||||
|
||||
# Update fields
|
||||
if 'name' in data:
|
||||
existing.name = data['name']
|
||||
if 'key' in data:
|
||||
existing.key = data['key']
|
||||
if 'description' in data:
|
||||
existing.description = data['description']
|
||||
if 'genres' in data:
|
||||
existing.genres = data['genres']
|
||||
if 'release_year' in data:
|
||||
existing.release_year = data['release_year']
|
||||
if 'status' in data:
|
||||
existing.status = data['status']
|
||||
if 'total_episodes' in data:
|
||||
existing.total_episodes = data['total_episodes']
|
||||
if 'poster_url' in data:
|
||||
existing.poster_url = data['poster_url']
|
||||
if 'custom_metadata' in data:
|
||||
existing.custom_metadata.update(data['custom_metadata'])
|
||||
|
||||
success = anime_repository.update_anime(existing)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Anime updated successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to update anime'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to update anime: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/anime/<anime_id>', methods=['DELETE'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def delete_anime(anime_id):
|
||||
"""Delete anime and related data."""
|
||||
try:
|
||||
success = anime_repository.delete_anime(anime_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Anime deleted successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Anime not found'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to delete anime: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/anime/search')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def search_anime():
|
||||
"""Search anime by name or description."""
|
||||
try:
|
||||
search_term = request.args.get('q', '').strip()
|
||||
|
||||
if not search_term:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Search term is required'
|
||||
}), 400
|
||||
|
||||
results = anime_repository.search_anime(search_term)
|
||||
|
||||
# Convert to serializable format
|
||||
anime_data = []
|
||||
for anime in results:
|
||||
anime_data.append({
|
||||
'anime_id': anime.anime_id,
|
||||
'name': anime.name,
|
||||
'folder': anime.folder,
|
||||
'key': anime.key,
|
||||
'description': anime.description,
|
||||
'genres': anime.genres,
|
||||
'release_year': anime.release_year,
|
||||
'status': anime.status
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'results': anime_data,
|
||||
'count': len(anime_data),
|
||||
'search_term': search_term
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to search anime: {e}")
|
||||
|
||||
|
||||
# Backup Management Endpoints
|
||||
@database_bp.route('/api/database/backups')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def list_backups():
|
||||
"""List all available backups."""
|
||||
try:
|
||||
backups = backup_manager.list_backups()
|
||||
|
||||
backup_data = []
|
||||
for backup in backups:
|
||||
backup_data.append({
|
||||
'backup_id': backup.backup_id,
|
||||
'backup_type': backup.backup_type,
|
||||
'created_at': backup.created_at.isoformat(),
|
||||
'size_mb': round(backup.size_bytes / (1024 * 1024), 2),
|
||||
'description': backup.description,
|
||||
'tables_included': backup.tables_included
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'backups': backup_data,
|
||||
'count': len(backup_data)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to list backups: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/backups/create', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def create_backup():
|
||||
"""Create a new database backup."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
backup_type = data.get('backup_type', 'full')
|
||||
description = data.get('description')
|
||||
|
||||
if backup_type not in ['full', 'metadata_only']:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Backup type must be "full" or "metadata_only"'
|
||||
}), 400
|
||||
|
||||
if backup_type == 'full':
|
||||
backup_info = backup_manager.create_full_backup(description)
|
||||
else:
|
||||
backup_info = backup_manager.create_metadata_backup(description)
|
||||
|
||||
if backup_info:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'{backup_type.title()} backup created successfully',
|
||||
'data': {
|
||||
'backup_id': backup_info.backup_id,
|
||||
'backup_type': backup_info.backup_type,
|
||||
'size_mb': round(backup_info.size_bytes / (1024 * 1024), 2),
|
||||
'created_at': backup_info.created_at.isoformat()
|
||||
}
|
||||
}), 201
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to create backup'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to create backup: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/backups/<backup_id>/restore', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def restore_backup(backup_id):
|
||||
"""Restore from a backup."""
|
||||
try:
|
||||
success = backup_manager.restore_backup(backup_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Backup restored successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to restore backup'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to restore backup: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/backups/<backup_id>/download')
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def download_backup(backup_id):
|
||||
"""Download a backup file."""
|
||||
try:
|
||||
backups = backup_manager.list_backups()
|
||||
target_backup = None
|
||||
|
||||
for backup in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
target_backup = backup
|
||||
break
|
||||
|
||||
if not target_backup:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Backup not found'
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(target_backup.backup_path):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Backup file not found'
|
||||
}), 404
|
||||
|
||||
filename = os.path.basename(target_backup.backup_path)
|
||||
return send_file(target_backup.backup_path, as_attachment=True, download_name=filename)
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to download backup: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/backups/cleanup', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def cleanup_backups():
|
||||
"""Clean up old backup files."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
keep_days = data.get('keep_days', 30)
|
||||
keep_count = data.get('keep_count', 10)
|
||||
|
||||
if keep_days < 1 or keep_count < 1:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'keep_days and keep_count must be positive integers'
|
||||
}), 400
|
||||
|
||||
backup_manager.cleanup_old_backups(keep_days, keep_count)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Backup cleanup completed (keeping {keep_count} backups, max {keep_days} days old)'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to cleanup backups: {e}")
|
||||
|
||||
|
||||
# Storage Management Endpoints
|
||||
@database_bp.route('/api/database/storage/summary')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_storage_summary():
|
||||
"""Get storage usage summary."""
|
||||
try:
|
||||
summary = storage_manager.get_storage_summary()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': summary
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get storage summary: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/storage/locations')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_storage_locations():
|
||||
"""Get all storage locations."""
|
||||
try:
|
||||
query = """
|
||||
SELECT sl.*, am.name as anime_name
|
||||
FROM storage_locations sl
|
||||
LEFT JOIN anime_metadata am ON sl.anime_id = am.anime_id
|
||||
WHERE sl.is_active = 1
|
||||
ORDER BY sl.location_type, sl.path
|
||||
"""
|
||||
|
||||
results = database_manager.execute_query(query)
|
||||
|
||||
locations = []
|
||||
for row in results:
|
||||
locations.append({
|
||||
'location_id': row['location_id'],
|
||||
'anime_id': row['anime_id'],
|
||||
'anime_name': row['anime_name'],
|
||||
'path': row['path'],
|
||||
'location_type': row['location_type'],
|
||||
'free_space_gb': (row['free_space_bytes'] / (1024**3)) if row['free_space_bytes'] else None,
|
||||
'total_space_gb': (row['total_space_bytes'] / (1024**3)) if row['total_space_bytes'] else None,
|
||||
'usage_percent': ((row['total_space_bytes'] - row['free_space_bytes']) / row['total_space_bytes'] * 100) if row['total_space_bytes'] and row['free_space_bytes'] else None,
|
||||
'last_checked': row['last_checked']
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'locations': locations,
|
||||
'count': len(locations)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get storage locations: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/storage/locations', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def add_storage_location():
|
||||
"""Add a new storage location."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
path = data.get('path')
|
||||
location_type = data.get('location_type', 'primary')
|
||||
anime_id = data.get('anime_id')
|
||||
|
||||
if not path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Path is required'
|
||||
}), 400
|
||||
|
||||
if location_type not in ['primary', 'backup', 'cache']:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Location type must be primary, backup, or cache'
|
||||
}), 400
|
||||
|
||||
location_id = storage_manager.add_storage_location(path, location_type, anime_id)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Storage location added successfully',
|
||||
'data': {
|
||||
'location_id': location_id
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to add storage location: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/storage/locations/<location_id>/update', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def update_storage_location(location_id):
|
||||
"""Update storage location statistics."""
|
||||
try:
|
||||
storage_manager.update_storage_stats(location_id)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Storage statistics updated successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to update storage location: {e}")
|
||||
|
||||
|
||||
# Database Maintenance Endpoints
|
||||
@database_bp.route('/api/database/maintenance/vacuum', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def vacuum_database():
|
||||
"""Perform database VACUUM operation to reclaim space."""
|
||||
try:
|
||||
with database_manager.get_connection() as conn:
|
||||
conn.execute("VACUUM")
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Database vacuum completed successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to vacuum database: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/maintenance/analyze', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def analyze_database():
|
||||
"""Perform database ANALYZE operation to update statistics."""
|
||||
try:
|
||||
with database_manager.get_connection() as conn:
|
||||
conn.execute("ANALYZE")
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Database analysis completed successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to analyze database: {e}")
|
||||
|
||||
|
||||
@database_bp.route('/api/database/maintenance/integrity-check', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def integrity_check():
|
||||
"""Perform database integrity check."""
|
||||
try:
|
||||
with database_manager.get_connection() as conn:
|
||||
cursor = conn.execute("PRAGMA integrity_check")
|
||||
results = cursor.fetchall()
|
||||
|
||||
# Check if database is OK
|
||||
is_ok = len(results) == 1 and results[0][0] == 'ok'
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'integrity_ok': is_ok,
|
||||
'results': [row[0] for row in results]
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to check database integrity: {e}")
|
||||
|
||||
|
||||
# Export the blueprint
|
||||
__all__ = ['database_bp']
|
||||
433
src/server/web/controllers/api/v1/health.py
Normal file
433
src/server/web/controllers/api/v1/health.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Health Check Endpoints
|
||||
|
||||
This module provides comprehensive health check endpoints for monitoring
|
||||
the AniWorld application's status, dependencies, and performance metrics.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
import time
|
||||
import os
|
||||
import sqlite3
|
||||
import psutil
|
||||
from datetime import datetime
|
||||
import threading
|
||||
from health_monitor import health_monitor
|
||||
from database_manager import database_manager
|
||||
from performance_optimizer import memory_monitor
|
||||
from config import config
|
||||
|
||||
|
||||
# Blueprint for health check endpoints
|
||||
health_bp = Blueprint('health_check', __name__)
|
||||
|
||||
# Health check cache to avoid expensive operations on every request
|
||||
_health_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_cache_ttl = 30 # Cache for 30 seconds
|
||||
|
||||
|
||||
def get_cached_health_data(cache_key, check_function, ttl=None):
|
||||
"""Get health data from cache or execute check function."""
|
||||
current_time = time.time()
|
||||
ttl = ttl or _cache_ttl
|
||||
|
||||
with _cache_lock:
|
||||
if cache_key in _health_cache:
|
||||
cached_data, timestamp = _health_cache[cache_key]
|
||||
if current_time - timestamp < ttl:
|
||||
return cached_data
|
||||
|
||||
# Execute check and cache result
|
||||
try:
|
||||
result = check_function()
|
||||
_health_cache[cache_key] = (result, current_time)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
|
||||
@health_bp.route('/health')
|
||||
@health_bp.route('/api/health')
|
||||
def basic_health():
|
||||
"""Basic health check endpoint for load balancers."""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'service': 'aniworld-web'
|
||||
})
|
||||
|
||||
|
||||
@health_bp.route('/api/health/system')
|
||||
def system_health():
|
||||
"""Comprehensive system health check."""
|
||||
def check_system_health():
|
||||
try:
|
||||
# System metrics
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
# Process metrics
|
||||
process = psutil.Process()
|
||||
process_memory = process.memory_info()
|
||||
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'system': {
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory': {
|
||||
'total_mb': memory.total / 1024 / 1024,
|
||||
'available_mb': memory.available / 1024 / 1024,
|
||||
'percent': memory.percent
|
||||
},
|
||||
'disk': {
|
||||
'total_gb': disk.total / 1024 / 1024 / 1024,
|
||||
'free_gb': disk.free / 1024 / 1024 / 1024,
|
||||
'percent': (disk.used / disk.total) * 100
|
||||
}
|
||||
},
|
||||
'process': {
|
||||
'memory_mb': process_memory.rss / 1024 / 1024,
|
||||
'threads': process.num_threads(),
|
||||
'cpu_percent': process.cpu_percent()
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
return jsonify(get_cached_health_data('system', check_system_health))
|
||||
|
||||
|
||||
@health_bp.route('/api/health/database')
|
||||
def database_health():
|
||||
"""Database connectivity and health check."""
|
||||
def check_database_health():
|
||||
try:
|
||||
# Test database connection
|
||||
start_time = time.time()
|
||||
with database_manager.get_connection() as conn:
|
||||
cursor = conn.execute("SELECT 1")
|
||||
result = cursor.fetchone()
|
||||
connection_time = (time.time() - start_time) * 1000 # ms
|
||||
|
||||
# Get database size and basic stats
|
||||
db_size = os.path.getsize(database_manager.db_path) if os.path.exists(database_manager.db_path) else 0
|
||||
|
||||
# Check schema version
|
||||
schema_version = database_manager.get_current_version()
|
||||
|
||||
# Get table counts
|
||||
with database_manager.get_connection() as conn:
|
||||
anime_count = conn.execute("SELECT COUNT(*) FROM anime_metadata").fetchone()[0]
|
||||
episode_count = conn.execute("SELECT COUNT(*) FROM episode_metadata").fetchone()[0]
|
||||
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'database': {
|
||||
'connected': True,
|
||||
'connection_time_ms': connection_time,
|
||||
'size_mb': db_size / 1024 / 1024,
|
||||
'schema_version': schema_version,
|
||||
'tables': {
|
||||
'anime_count': anime_count,
|
||||
'episode_count': episode_count
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'database': {
|
||||
'connected': False,
|
||||
'error': str(e)
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify(get_cached_health_data('database', check_database_health, ttl=60))
|
||||
|
||||
|
||||
@health_bp.route('/api/health/dependencies')
|
||||
def dependencies_health():
|
||||
"""Check health of external dependencies."""
|
||||
def check_dependencies():
|
||||
dependencies = {
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'dependencies': {}
|
||||
}
|
||||
|
||||
# Check filesystem access
|
||||
try:
|
||||
anime_directory = getattr(config, 'anime_directory', '/app/data')
|
||||
if os.path.exists(anime_directory):
|
||||
# Test read/write access
|
||||
test_file = os.path.join(anime_directory, '.health_check')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
dependencies['dependencies']['filesystem'] = {
|
||||
'status': 'healthy',
|
||||
'path': anime_directory,
|
||||
'accessible': True
|
||||
}
|
||||
else:
|
||||
dependencies['dependencies']['filesystem'] = {
|
||||
'status': 'unhealthy',
|
||||
'path': anime_directory,
|
||||
'accessible': False,
|
||||
'error': 'Directory does not exist'
|
||||
}
|
||||
dependencies['status'] = 'degraded'
|
||||
except Exception as e:
|
||||
dependencies['dependencies']['filesystem'] = {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
dependencies['status'] = 'degraded'
|
||||
|
||||
# Check network connectivity (basic)
|
||||
try:
|
||||
import socket
|
||||
socket.create_connection(("8.8.8.8", 53), timeout=3)
|
||||
dependencies['dependencies']['network'] = {
|
||||
'status': 'healthy',
|
||||
'connectivity': True
|
||||
}
|
||||
except Exception as e:
|
||||
dependencies['dependencies']['network'] = {
|
||||
'status': 'unhealthy',
|
||||
'connectivity': False,
|
||||
'error': str(e)
|
||||
}
|
||||
dependencies['status'] = 'degraded'
|
||||
|
||||
return dependencies
|
||||
|
||||
return jsonify(get_cached_health_data('dependencies', check_dependencies, ttl=120))
|
||||
|
||||
|
||||
@health_bp.route('/api/health/performance')
|
||||
def performance_health():
|
||||
"""Performance metrics and health indicators."""
|
||||
def check_performance():
|
||||
try:
|
||||
# Memory usage
|
||||
memory_usage = memory_monitor.get_current_memory_usage() if memory_monitor else 0
|
||||
is_memory_high = memory_monitor.is_memory_usage_high() if memory_monitor else False
|
||||
|
||||
# Thread count
|
||||
process = psutil.Process()
|
||||
thread_count = process.num_threads()
|
||||
|
||||
# Load average (if available)
|
||||
load_avg = None
|
||||
try:
|
||||
load_avg = os.getloadavg()
|
||||
except (AttributeError, OSError):
|
||||
# Not available on all platforms
|
||||
pass
|
||||
|
||||
# Check if performance is within acceptable limits
|
||||
performance_status = 'healthy'
|
||||
warnings = []
|
||||
|
||||
if is_memory_high:
|
||||
performance_status = 'degraded'
|
||||
warnings.append('High memory usage detected')
|
||||
|
||||
if thread_count > 100: # Arbitrary threshold
|
||||
performance_status = 'degraded'
|
||||
warnings.append(f'High thread count: {thread_count}')
|
||||
|
||||
if load_avg and load_avg[0] > 4: # Load average > 4
|
||||
performance_status = 'degraded'
|
||||
warnings.append(f'High system load: {load_avg[0]:.2f}')
|
||||
|
||||
return {
|
||||
'status': performance_status,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'performance': {
|
||||
'memory_usage_mb': memory_usage,
|
||||
'memory_high': is_memory_high,
|
||||
'thread_count': thread_count,
|
||||
'load_average': load_avg,
|
||||
'warnings': warnings
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'error',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
return jsonify(get_cached_health_data('performance', check_performance, ttl=10))
|
||||
|
||||
|
||||
@health_bp.route('/api/health/detailed')
|
||||
def detailed_health():
|
||||
"""Comprehensive health check combining all metrics."""
|
||||
def check_detailed_health():
|
||||
try:
|
||||
# Get all health checks
|
||||
system = get_cached_health_data('system', lambda: system_health().json)
|
||||
database = get_cached_health_data('database', lambda: database_health().json)
|
||||
dependencies = get_cached_health_data('dependencies', lambda: dependencies_health().json)
|
||||
performance = get_cached_health_data('performance', lambda: performance_health().json)
|
||||
|
||||
# Determine overall status
|
||||
statuses = [
|
||||
system.get('status', 'unknown'),
|
||||
database.get('status', 'unknown'),
|
||||
dependencies.get('status', 'unknown'),
|
||||
performance.get('status', 'unknown')
|
||||
]
|
||||
|
||||
if 'unhealthy' in statuses or 'error' in statuses:
|
||||
overall_status = 'unhealthy'
|
||||
elif 'degraded' in statuses:
|
||||
overall_status = 'degraded'
|
||||
else:
|
||||
overall_status = 'healthy'
|
||||
|
||||
return {
|
||||
'status': overall_status,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'components': {
|
||||
'system': system,
|
||||
'database': database,
|
||||
'dependencies': dependencies,
|
||||
'performance': performance
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'error',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
# Don't cache detailed health - always get fresh data
|
||||
return jsonify(check_detailed_health())
|
||||
|
||||
|
||||
@health_bp.route('/api/health/ready')
|
||||
def readiness_probe():
|
||||
"""Kubernetes readiness probe endpoint."""
|
||||
try:
|
||||
# Check critical dependencies
|
||||
with database_manager.get_connection() as conn:
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
# Check if anime directory is accessible
|
||||
anime_directory = getattr(config, 'anime_directory', '/app/data')
|
||||
if not os.path.exists(anime_directory):
|
||||
raise Exception(f"Anime directory not accessible: {anime_directory}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'ready',
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'not_ready',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': str(e)
|
||||
}), 503
|
||||
|
||||
|
||||
@health_bp.route('/api/health/live')
|
||||
def liveness_probe():
|
||||
"""Kubernetes liveness probe endpoint."""
|
||||
try:
|
||||
# Basic liveness check - just verify the application is responding
|
||||
return jsonify({
|
||||
'status': 'alive',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'uptime_seconds': time.time() - psutil.Process().create_time()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'dead',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': str(e)
|
||||
}), 503
|
||||
|
||||
|
||||
@health_bp.route('/api/health/metrics')
|
||||
def prometheus_metrics():
|
||||
"""Prometheus-compatible metrics endpoint."""
|
||||
try:
|
||||
# Generate Prometheus-format metrics
|
||||
metrics = []
|
||||
|
||||
# System metrics
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
metrics.extend([
|
||||
f"# HELP aniworld_cpu_usage_percent CPU usage percentage",
|
||||
f"# TYPE aniworld_cpu_usage_percent gauge",
|
||||
f"aniworld_cpu_usage_percent {cpu_percent}",
|
||||
f"",
|
||||
f"# HELP aniworld_memory_usage_percent Memory usage percentage",
|
||||
f"# TYPE aniworld_memory_usage_percent gauge",
|
||||
f"aniworld_memory_usage_percent {memory.percent}",
|
||||
f"",
|
||||
f"# HELP aniworld_disk_usage_percent Disk usage percentage",
|
||||
f"# TYPE aniworld_disk_usage_percent gauge",
|
||||
f"aniworld_disk_usage_percent {(disk.used / disk.total) * 100}",
|
||||
f"",
|
||||
])
|
||||
|
||||
# Database metrics
|
||||
try:
|
||||
with database_manager.get_connection() as conn:
|
||||
anime_count = conn.execute("SELECT COUNT(*) FROM anime_metadata").fetchone()[0]
|
||||
episode_count = conn.execute("SELECT COUNT(*) FROM episode_metadata").fetchone()[0]
|
||||
|
||||
metrics.extend([
|
||||
f"# HELP aniworld_anime_total Total number of anime in database",
|
||||
f"# TYPE aniworld_anime_total counter",
|
||||
f"aniworld_anime_total {anime_count}",
|
||||
f"",
|
||||
f"# HELP aniworld_episodes_total Total number of episodes in database",
|
||||
f"# TYPE aniworld_episodes_total counter",
|
||||
f"aniworld_episodes_total {episode_count}",
|
||||
f"",
|
||||
])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process metrics
|
||||
process = psutil.Process()
|
||||
metrics.extend([
|
||||
f"# HELP aniworld_process_threads Number of threads in process",
|
||||
f"# TYPE aniworld_process_threads gauge",
|
||||
f"aniworld_process_threads {process.num_threads()}",
|
||||
f"",
|
||||
f"# HELP aniworld_process_memory_bytes Memory usage in bytes",
|
||||
f"# TYPE aniworld_process_memory_bytes gauge",
|
||||
f"aniworld_process_memory_bytes {process.memory_info().rss}",
|
||||
f"",
|
||||
])
|
||||
|
||||
return "\n".join(metrics), 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||||
|
||||
except Exception as e:
|
||||
return f"# Error generating metrics: {e}", 500, {'Content-Type': 'text/plain'}
|
||||
|
||||
|
||||
# Export the blueprint
|
||||
__all__ = ['health_bp']
|
||||
256
src/server/web/controllers/api/v1/logging.py
Normal file
256
src/server/web/controllers/api/v1/logging.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
API endpoints for logging configuration and management.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from auth import require_auth
|
||||
from config import config
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logging_bp = Blueprint('logging', __name__, url_prefix='/api/logging')
|
||||
|
||||
@logging_bp.route('/config', methods=['GET'])
|
||||
@require_auth
|
||||
def get_logging_config():
|
||||
"""Get current logging configuration."""
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from logging_config import logging_config as log_config
|
||||
|
||||
config_data = {
|
||||
'log_level': config.log_level,
|
||||
'enable_console_logging': config.enable_console_logging,
|
||||
'enable_console_progress': config.enable_console_progress,
|
||||
'enable_fail2ban_logging': config.enable_fail2ban_logging,
|
||||
'log_files': log_config.get_log_files() if hasattr(log_config, 'get_log_files') else []
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config_data
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting logging config: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@logging_bp.route('/config', methods=['POST'])
|
||||
@require_auth
|
||||
def update_logging_config():
|
||||
"""Update logging configuration."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Update log level
|
||||
log_level = data.get('log_level', config.log_level)
|
||||
if log_level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
config.log_level = log_level
|
||||
|
||||
# Update console logging settings
|
||||
if 'enable_console_logging' in data:
|
||||
config.enable_console_logging = bool(data['enable_console_logging'])
|
||||
|
||||
if 'enable_console_progress' in data:
|
||||
config.enable_console_progress = bool(data['enable_console_progress'])
|
||||
|
||||
if 'enable_fail2ban_logging' in data:
|
||||
config.enable_fail2ban_logging = bool(data['enable_fail2ban_logging'])
|
||||
|
||||
# Save configuration
|
||||
config.save_config()
|
||||
|
||||
# Update runtime logging level
|
||||
try:
|
||||
from logging_config import logging_config as log_config
|
||||
log_config.update_log_level(config.log_level)
|
||||
except ImportError:
|
||||
# Fallback for basic logging
|
||||
numeric_level = getattr(logging, config.log_level.upper(), logging.INFO)
|
||||
logging.getLogger().setLevel(numeric_level)
|
||||
|
||||
logger.info(f"Logging configuration updated: level={config.log_level}, console={config.enable_console_logging}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Logging configuration updated successfully',
|
||||
'config': {
|
||||
'log_level': config.log_level,
|
||||
'enable_console_logging': config.enable_console_logging,
|
||||
'enable_console_progress': config.enable_console_progress,
|
||||
'enable_fail2ban_logging': config.enable_fail2ban_logging
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating logging config: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@logging_bp.route('/files', methods=['GET'])
|
||||
@require_auth
|
||||
def list_log_files():
|
||||
"""Get list of available log files."""
|
||||
try:
|
||||
from logging_config import logging_config as log_config
|
||||
|
||||
log_files = log_config.get_log_files()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'files': log_files
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing log files: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@logging_bp.route('/files/<filename>/download', methods=['GET'])
|
||||
@require_auth
|
||||
def download_log_file(filename):
|
||||
"""Download a specific log file."""
|
||||
try:
|
||||
# Security: Only allow log files
|
||||
if not filename.endswith('.log'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid file type'
|
||||
}), 400
|
||||
|
||||
log_directory = "logs"
|
||||
file_path = os.path.join(log_directory, filename)
|
||||
|
||||
# Security: Check if file exists and is within log directory
|
||||
if not os.path.exists(file_path) or not os.path.abspath(file_path).startswith(os.path.abspath(log_directory)):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'File not found'
|
||||
}), 404
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
as_attachment=True,
|
||||
download_name=f"{filename}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading log file {filename}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@logging_bp.route('/files/<filename>/tail', methods=['GET'])
|
||||
@require_auth
|
||||
def tail_log_file(filename):
|
||||
"""Get the last N lines from a log file."""
|
||||
try:
|
||||
# Security: Only allow log files
|
||||
if not filename.endswith('.log'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid file type'
|
||||
}), 400
|
||||
|
||||
lines = int(request.args.get('lines', 100))
|
||||
lines = min(lines, 1000) # Limit to 1000 lines max
|
||||
|
||||
log_directory = "logs"
|
||||
file_path = os.path.join(log_directory, filename)
|
||||
|
||||
# Security: Check if file exists and is within log directory
|
||||
if not os.path.exists(file_path) or not os.path.abspath(file_path).startswith(os.path.abspath(log_directory)):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'File not found'
|
||||
}), 404
|
||||
|
||||
# Read last N lines
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
all_lines = f.readlines()
|
||||
tail_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'lines': [line.rstrip('\n\r') for line in tail_lines],
|
||||
'total_lines': len(all_lines),
|
||||
'showing_lines': len(tail_lines)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error tailing log file {filename}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@logging_bp.route('/cleanup', methods=['POST'])
|
||||
@require_auth
|
||||
def cleanup_logs():
|
||||
"""Clean up old log files."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
days = int(data.get('days', 30))
|
||||
days = max(1, min(days, 365)) # Limit between 1-365 days
|
||||
|
||||
from logging_config import logging_config as log_config
|
||||
cleaned_files = log_config.cleanup_old_logs(days)
|
||||
|
||||
logger.info(f"Cleaned up {len(cleaned_files)} old log files (older than {days} days)")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Cleaned up {len(cleaned_files)} log files',
|
||||
'cleaned_files': cleaned_files
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up logs: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@logging_bp.route('/test', methods=['POST'])
|
||||
@require_auth
|
||||
def test_logging():
|
||||
"""Test logging at different levels."""
|
||||
try:
|
||||
test_message = "Test log message from web interface"
|
||||
|
||||
# Test different log levels
|
||||
logger.debug(f"DEBUG: {test_message}")
|
||||
logger.info(f"INFO: {test_message}")
|
||||
logger.warning(f"WARNING: {test_message}")
|
||||
logger.error(f"ERROR: {test_message}")
|
||||
|
||||
# Test fail2ban logging
|
||||
try:
|
||||
from logging_config import log_auth_failure
|
||||
log_auth_failure("127.0.0.1", "test_user")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Test download progress logging
|
||||
try:
|
||||
from logging_config import log_download_progress
|
||||
log_download_progress("Test Series", "S01E01", 50.0, "1.2 MB/s", "5m 30s")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Test messages logged successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing logging: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
406
src/server/web/controllers/api/v1/performance.py
Normal file
406
src/server/web/controllers/api/v1/performance.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Performance Optimization API Endpoints
|
||||
|
||||
This module provides REST API endpoints for performance monitoring
|
||||
and optimization features.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from auth import require_auth, optional_auth
|
||||
from error_handler import handle_api_errors, RetryableError
|
||||
from performance_optimizer import (
|
||||
speed_limiter, download_cache, memory_monitor,
|
||||
download_manager, resume_manager, DownloadTask
|
||||
)
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Blueprint for performance optimization endpoints
|
||||
performance_bp = Blueprint('performance', __name__)
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/speed-limit', methods=['GET'])
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_speed_limit():
|
||||
"""Get current download speed limit."""
|
||||
try:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'speed_limit_mbps': speed_limiter.max_speed_mbps,
|
||||
'current_speed_mbps': speed_limiter.get_current_speed()
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get speed limit: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/speed-limit', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def set_speed_limit():
|
||||
"""Set download speed limit."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
speed_mbps = data.get('speed_mbps', 0)
|
||||
|
||||
if speed_mbps < 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Speed limit must be non-negative (0 = unlimited)'
|
||||
}), 400
|
||||
|
||||
speed_limiter.set_speed_limit(speed_mbps)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Speed limit set to {speed_mbps} MB/s' if speed_mbps > 0 else 'Speed limit removed',
|
||||
'data': {
|
||||
'speed_limit_mbps': speed_mbps
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to set speed limit: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/cache/stats')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_cache_stats():
|
||||
"""Get cache statistics."""
|
||||
try:
|
||||
stats = download_cache.get_stats()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': stats
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get cache stats: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/cache/clear', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def clear_cache():
|
||||
"""Clear download cache."""
|
||||
try:
|
||||
download_cache.clear()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Cache cleared successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to clear cache: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/memory/stats')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_memory_stats():
|
||||
"""Get memory usage statistics."""
|
||||
try:
|
||||
stats = memory_monitor.get_memory_stats()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': stats
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get memory stats: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/memory/gc', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def force_garbage_collection():
|
||||
"""Force garbage collection to free memory."""
|
||||
try:
|
||||
memory_monitor.force_garbage_collection()
|
||||
stats = memory_monitor.get_memory_stats()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Garbage collection completed',
|
||||
'data': stats
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to force garbage collection: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/downloads/workers', methods=['GET'])
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_worker_count():
|
||||
"""Get current number of download workers."""
|
||||
try:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'max_workers': download_manager.max_workers,
|
||||
'active_tasks': len(download_manager.active_tasks)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get worker count: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/downloads/workers', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def set_worker_count():
|
||||
"""Set number of download workers."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
max_workers = data.get('max_workers', 3)
|
||||
|
||||
if not isinstance(max_workers, int) or max_workers < 1 or max_workers > 10:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Worker count must be between 1 and 10'
|
||||
}), 400
|
||||
|
||||
download_manager.set_max_workers(max_workers)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Worker count set to {max_workers}',
|
||||
'data': {
|
||||
'max_workers': max_workers
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to set worker count: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/downloads/stats')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_download_stats():
|
||||
"""Get download manager statistics."""
|
||||
try:
|
||||
stats = download_manager.get_statistics()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': stats
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get download stats: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/downloads/tasks')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_all_download_tasks():
|
||||
"""Get all download tasks."""
|
||||
try:
|
||||
tasks = download_manager.get_all_tasks()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': tasks
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get download tasks: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/downloads/tasks/<task_id>')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_download_task(task_id):
|
||||
"""Get specific download task status."""
|
||||
try:
|
||||
task_status = download_manager.get_task_status(task_id)
|
||||
|
||||
if not task_status:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Task not found'
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': task_status
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get task status: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/downloads/add-task', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def add_download_task():
|
||||
"""Add a new download task to the queue."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
required_fields = ['serie_name', 'season', 'episode', 'key', 'output_path', 'temp_path']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Missing required field: {field}'
|
||||
}), 400
|
||||
|
||||
# Create download task
|
||||
task = DownloadTask(
|
||||
task_id=str(uuid.uuid4()),
|
||||
serie_name=data['serie_name'],
|
||||
season=int(data['season']),
|
||||
episode=int(data['episode']),
|
||||
key=data['key'],
|
||||
language=data.get('language', 'German Dub'),
|
||||
output_path=data['output_path'],
|
||||
temp_path=data['temp_path'],
|
||||
priority=data.get('priority', 0)
|
||||
)
|
||||
|
||||
task_id = download_manager.add_task(task)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Download task added successfully',
|
||||
'data': {
|
||||
'task_id': task_id
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to add download task: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/resume/tasks')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_resumable_tasks():
|
||||
"""Get list of tasks that can be resumed."""
|
||||
try:
|
||||
resumable_tasks = resume_manager.get_resumable_tasks()
|
||||
|
||||
# Get detailed info for each resumable task
|
||||
tasks_info = []
|
||||
for task_id in resumable_tasks:
|
||||
resume_info = resume_manager.load_resume_info(task_id)
|
||||
if resume_info:
|
||||
tasks_info.append({
|
||||
'task_id': task_id,
|
||||
'resume_info': resume_info
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'resumable_tasks': tasks_info,
|
||||
'count': len(tasks_info)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get resumable tasks: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/resume/clear/<task_id>', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def clear_resume_info(task_id):
|
||||
"""Clear resume information for a specific task."""
|
||||
try:
|
||||
resume_manager.clear_resume_info(task_id)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Resume information cleared for task: {task_id}'
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to clear resume info: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/system/optimize', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def optimize_system():
|
||||
"""Perform system optimization tasks."""
|
||||
try:
|
||||
optimization_results = {}
|
||||
|
||||
# Force garbage collection
|
||||
memory_monitor.force_garbage_collection()
|
||||
memory_stats = memory_monitor.get_memory_stats()
|
||||
optimization_results['memory_gc'] = {
|
||||
'completed': True,
|
||||
'memory_mb': memory_stats.get('rss_mb', 0)
|
||||
}
|
||||
|
||||
# Clean up cache expired entries
|
||||
download_cache._cleanup_expired()
|
||||
cache_stats = download_cache.get_stats()
|
||||
optimization_results['cache_cleanup'] = {
|
||||
'completed': True,
|
||||
'entries': cache_stats.get('entry_count', 0),
|
||||
'size_mb': cache_stats.get('total_size_mb', 0)
|
||||
}
|
||||
|
||||
# Clean up old resume files (older than 7 days)
|
||||
import os
|
||||
import time
|
||||
resume_dir = resume_manager.resume_dir
|
||||
cleaned_files = 0
|
||||
|
||||
try:
|
||||
for filename in os.listdir(resume_dir):
|
||||
file_path = os.path.join(resume_dir, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_age = time.time() - os.path.getmtime(file_path)
|
||||
if file_age > 7 * 24 * 3600: # 7 days in seconds
|
||||
os.remove(file_path)
|
||||
cleaned_files += 1
|
||||
except Exception as e:
|
||||
pass # Ignore errors in cleanup
|
||||
|
||||
optimization_results['resume_cleanup'] = {
|
||||
'completed': True,
|
||||
'files_removed': cleaned_files
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'System optimization completed',
|
||||
'data': optimization_results
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"System optimization failed: {e}")
|
||||
|
||||
|
||||
@performance_bp.route('/api/performance/config')
|
||||
@handle_api_errors
|
||||
@optional_auth
|
||||
def get_performance_config():
|
||||
"""Get current performance configuration."""
|
||||
try:
|
||||
config = {
|
||||
'speed_limit': {
|
||||
'current_mbps': speed_limiter.max_speed_mbps,
|
||||
'unlimited': speed_limiter.max_speed_mbps == 0
|
||||
},
|
||||
'downloads': {
|
||||
'max_workers': download_manager.max_workers,
|
||||
'active_tasks': len(download_manager.active_tasks)
|
||||
},
|
||||
'cache': {
|
||||
'max_size_mb': download_cache.max_size_bytes / (1024 * 1024),
|
||||
**download_cache.get_stats()
|
||||
},
|
||||
'memory': {
|
||||
'warning_threshold_mb': memory_monitor.warning_threshold / (1024 * 1024),
|
||||
'critical_threshold_mb': memory_monitor.critical_threshold / (1024 * 1024),
|
||||
**memory_monitor.get_memory_stats()
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': config
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get performance config: {e}")
|
||||
|
||||
|
||||
# Export the blueprint
|
||||
__all__ = ['performance_bp']
|
||||
280
src/server/web/controllers/api/v1/process.py
Normal file
280
src/server/web/controllers/api/v1/process.py
Normal file
@@ -0,0 +1,280 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from auth import require_auth
|
||||
from process_locks import (
|
||||
process_lock_manager,
|
||||
RESCAN_LOCK,
|
||||
DOWNLOAD_LOCK,
|
||||
SEARCH_LOCK,
|
||||
check_process_locks,
|
||||
get_process_status,
|
||||
update_process_progress,
|
||||
is_process_running,
|
||||
episode_deduplicator,
|
||||
ProcessLockError
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
process_bp = Blueprint('process', __name__, url_prefix='/api/process')
|
||||
|
||||
@process_bp.route('/locks/status', methods=['GET'])
|
||||
@require_auth
|
||||
def get_all_locks_status():
|
||||
"""Get status of all process locks."""
|
||||
try:
|
||||
# Clean up expired locks first
|
||||
cleaned = check_process_locks()
|
||||
if cleaned > 0:
|
||||
logger.info(f"Cleaned up {cleaned} expired locks")
|
||||
|
||||
status = process_lock_manager.get_all_locks_status()
|
||||
|
||||
# Add queue deduplication info
|
||||
status['queue_info'] = {
|
||||
'active_episodes': episode_deduplicator.get_count(),
|
||||
'episodes': episode_deduplicator.get_active_episodes()
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'locks': status
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting locks status: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/locks/<lock_name>/status', methods=['GET'])
|
||||
@require_auth
|
||||
def get_lock_status(lock_name):
|
||||
"""Get status of a specific process lock."""
|
||||
try:
|
||||
if lock_name not in [RESCAN_LOCK, DOWNLOAD_LOCK, SEARCH_LOCK]:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid lock name'
|
||||
}), 400
|
||||
|
||||
status = get_process_status(lock_name)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'status': status
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lock status for {lock_name}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/locks/<lock_name>/acquire', methods=['POST'])
|
||||
@require_auth
|
||||
def acquire_lock(lock_name):
|
||||
"""Manually acquire a process lock."""
|
||||
try:
|
||||
if lock_name not in [RESCAN_LOCK, DOWNLOAD_LOCK, SEARCH_LOCK]:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid lock name'
|
||||
}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
locked_by = data.get('locked_by', 'manual')
|
||||
timeout_minutes = data.get('timeout_minutes', 60)
|
||||
|
||||
success = process_lock_manager.acquire_lock(lock_name, locked_by, timeout_minutes)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Lock {lock_name} acquired successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Lock {lock_name} is already held'
|
||||
}), 409
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error acquiring lock {lock_name}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/locks/<lock_name>/release', methods=['POST'])
|
||||
@require_auth
|
||||
def release_lock(lock_name):
|
||||
"""Manually release a process lock."""
|
||||
try:
|
||||
if lock_name not in [RESCAN_LOCK, DOWNLOAD_LOCK, SEARCH_LOCK]:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid lock name'
|
||||
}), 400
|
||||
|
||||
success = process_lock_manager.release_lock(lock_name)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Lock {lock_name} released successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Lock {lock_name} was not held'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing lock {lock_name}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/locks/cleanup', methods=['POST'])
|
||||
@require_auth
|
||||
def cleanup_expired_locks():
|
||||
"""Manually clean up expired locks."""
|
||||
try:
|
||||
cleaned = check_process_locks()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'cleaned_count': cleaned,
|
||||
'message': f'Cleaned up {cleaned} expired locks'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up locks: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/locks/force-release-all', methods=['POST'])
|
||||
@require_auth
|
||||
def force_release_all_locks():
|
||||
"""Force release all process locks (emergency use)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
confirm = data.get('confirm', False)
|
||||
|
||||
if not confirm:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Confirmation required for force release'
|
||||
}), 400
|
||||
|
||||
released = process_lock_manager.force_release_all()
|
||||
|
||||
# Also clear queue deduplication
|
||||
episode_deduplicator.clear_all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'released_count': released,
|
||||
'message': f'Force released {released} locks and cleared queue deduplication'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error force releasing locks: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/locks/<lock_name>/progress', methods=['POST'])
|
||||
@require_auth
|
||||
def update_lock_progress(lock_name):
|
||||
"""Update progress for a running process."""
|
||||
try:
|
||||
if lock_name not in [RESCAN_LOCK, DOWNLOAD_LOCK, SEARCH_LOCK]:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid lock name'
|
||||
}), 400
|
||||
|
||||
if not is_process_running(lock_name):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Process {lock_name} is not running'
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
progress_data = data.get('progress', {})
|
||||
|
||||
update_process_progress(lock_name, progress_data)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Progress updated successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating progress for {lock_name}: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/queue/deduplication', methods=['GET'])
|
||||
@require_auth
|
||||
def get_queue_deduplication():
|
||||
"""Get current queue deduplication status."""
|
||||
try:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deduplication': {
|
||||
'active_count': episode_deduplicator.get_count(),
|
||||
'active_episodes': episode_deduplicator.get_active_episodes()
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting queue deduplication: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/queue/deduplication/clear', methods=['POST'])
|
||||
@require_auth
|
||||
def clear_queue_deduplication():
|
||||
"""Clear all queue deduplication entries."""
|
||||
try:
|
||||
episode_deduplicator.clear_all()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Queue deduplication cleared successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing queue deduplication: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@process_bp.route('/is-running/<process_name>', methods=['GET'])
|
||||
@require_auth
|
||||
def check_if_process_running(process_name):
|
||||
"""Quick check if a specific process is running."""
|
||||
try:
|
||||
if process_name not in [RESCAN_LOCK, DOWNLOAD_LOCK, SEARCH_LOCK]:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid process name'
|
||||
}), 400
|
||||
|
||||
is_running = is_process_running(process_name)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'is_running': is_running,
|
||||
'process_name': process_name
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if process {process_name} is running: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
187
src/server/web/controllers/api/v1/scheduler.py
Normal file
187
src/server/web/controllers/api/v1/scheduler.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from auth import require_auth
|
||||
from scheduler import get_scheduler
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler_bp = Blueprint('scheduler', __name__, url_prefix='/api/scheduler')
|
||||
|
||||
@scheduler_bp.route('/config', methods=['GET'])
|
||||
@require_auth
|
||||
def get_scheduler_config():
|
||||
"""Get current scheduler configuration."""
|
||||
try:
|
||||
scheduler = get_scheduler()
|
||||
if not scheduler:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Scheduler not initialized'
|
||||
}), 500
|
||||
|
||||
config = scheduler.get_scheduled_rescan_config()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting scheduler config: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@scheduler_bp.route('/config', methods=['POST'])
|
||||
@require_auth
|
||||
def update_scheduler_config():
|
||||
"""Update scheduler configuration."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
enabled = data.get('enabled', False)
|
||||
time_str = data.get('time', '03:00')
|
||||
auto_download = data.get('auto_download_after_rescan', False)
|
||||
|
||||
# Validate inputs
|
||||
if enabled and not time_str:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Time is required when scheduling is enabled'
|
||||
}), 400
|
||||
|
||||
scheduler = get_scheduler()
|
||||
if not scheduler:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Scheduler not initialized'
|
||||
}), 500
|
||||
|
||||
# Update configuration
|
||||
scheduler.update_scheduled_rescan_config(enabled, time_str, auto_download)
|
||||
|
||||
# Get updated config
|
||||
updated_config = scheduler.get_scheduled_rescan_config()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Scheduler configuration updated successfully',
|
||||
'config': updated_config
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating scheduler config: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@scheduler_bp.route('/status', methods=['GET'])
|
||||
@require_auth
|
||||
def get_scheduler_status():
|
||||
"""Get current scheduler status and next jobs."""
|
||||
try:
|
||||
scheduler = get_scheduler()
|
||||
if not scheduler:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Scheduler not initialized'
|
||||
}), 500
|
||||
|
||||
config = scheduler.get_scheduled_rescan_config()
|
||||
jobs = scheduler.get_next_scheduled_jobs()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'status': {
|
||||
'running': config['is_running'],
|
||||
'config': config,
|
||||
'scheduled_jobs': jobs
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting scheduler status: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@scheduler_bp.route('/start', methods=['POST'])
|
||||
@require_auth
|
||||
def start_scheduler():
|
||||
"""Start the scheduler."""
|
||||
try:
|
||||
scheduler = get_scheduler()
|
||||
if not scheduler:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Scheduler not initialized'
|
||||
}), 500
|
||||
|
||||
scheduler.start_scheduler()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Scheduler started successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting scheduler: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@scheduler_bp.route('/stop', methods=['POST'])
|
||||
@require_auth
|
||||
def stop_scheduler():
|
||||
"""Stop the scheduler."""
|
||||
try:
|
||||
scheduler = get_scheduler()
|
||||
if not scheduler:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Scheduler not initialized'
|
||||
}), 500
|
||||
|
||||
scheduler.stop_scheduler()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Scheduler stopped successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping scheduler: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@scheduler_bp.route('/trigger-rescan', methods=['POST'])
|
||||
@require_auth
|
||||
def trigger_manual_rescan():
|
||||
"""Manually trigger a scheduled rescan for testing."""
|
||||
try:
|
||||
scheduler = get_scheduler()
|
||||
if not scheduler:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Scheduler not initialized'
|
||||
}), 500
|
||||
|
||||
scheduler.trigger_manual_scheduled_rescan()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Manual scheduled rescan triggered'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering manual rescan: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
570
src/server/web/controllers/api_endpoints.py
Normal file
570
src/server/web/controllers/api_endpoints.py
Normal file
@@ -0,0 +1,570 @@
|
||||
"""
|
||||
API Integration Endpoints
|
||||
|
||||
This module provides REST API endpoints for external integrations,
|
||||
webhooks, exports, and notifications.
|
||||
"""
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, make_response, current_app
|
||||
from auth import require_auth, optional_auth
|
||||
from error_handler import handle_api_errors, RetryableError, NonRetryableError
|
||||
from api_integration import (
|
||||
api_key_manager, webhook_manager, export_manager, notification_service,
|
||||
require_api_key
|
||||
)
|
||||
|
||||
|
||||
# Blueprint for API integration endpoints
|
||||
api_integration_bp = Blueprint('api_integration', __name__)
|
||||
|
||||
|
||||
# API Key Management Endpoints
|
||||
@api_integration_bp.route('/api/keys', methods=['GET'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def list_api_keys():
|
||||
"""List all API keys."""
|
||||
try:
|
||||
keys = api_key_manager.list_api_keys()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'api_keys': keys,
|
||||
'count': len(keys)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to list API keys: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/keys', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def create_api_key():
|
||||
"""Create a new API key."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
name = data.get('name')
|
||||
permissions = data.get('permissions', [])
|
||||
rate_limit = data.get('rate_limit', 1000)
|
||||
|
||||
if not name:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Name is required'
|
||||
}), 400
|
||||
|
||||
if not isinstance(permissions, list):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Permissions must be a list'
|
||||
}), 400
|
||||
|
||||
# Validate permissions
|
||||
valid_permissions = ['read', 'write', 'admin', 'download', 'export']
|
||||
invalid_permissions = set(permissions) - set(valid_permissions)
|
||||
if invalid_permissions:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid permissions: {", ".join(invalid_permissions)}'
|
||||
}), 400
|
||||
|
||||
api_key, key_id = api_key_manager.create_api_key(name, permissions, rate_limit)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'API key created successfully',
|
||||
'data': {
|
||||
'api_key': api_key, # Only returned once!
|
||||
'key_id': key_id,
|
||||
'name': name,
|
||||
'permissions': permissions,
|
||||
'rate_limit': rate_limit
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to create API key: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/keys/<key_id>', methods=['DELETE'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def revoke_api_key(key_id):
|
||||
"""Revoke an API key."""
|
||||
try:
|
||||
success = api_key_manager.revoke_api_key(key_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'API key revoked successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'API key not found'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to revoke API key: {e}")
|
||||
|
||||
|
||||
# Webhook Management Endpoints
|
||||
@api_integration_bp.route('/api/webhooks', methods=['GET'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def list_webhooks():
|
||||
"""List all webhook endpoints."""
|
||||
try:
|
||||
webhooks = webhook_manager.list_webhooks()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'webhooks': webhooks,
|
||||
'count': len(webhooks)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to list webhooks: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/webhooks', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def create_webhook():
|
||||
"""Create a new webhook endpoint."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
name = data.get('name')
|
||||
url = data.get('url')
|
||||
events = data.get('events', [])
|
||||
secret = data.get('secret')
|
||||
|
||||
if not name or not url:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Name and URL are required'
|
||||
}), 400
|
||||
|
||||
if not isinstance(events, list) or not events:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'At least one event must be specified'
|
||||
}), 400
|
||||
|
||||
# Validate events
|
||||
valid_events = [
|
||||
'download.started', 'download.completed', 'download.failed',
|
||||
'scan.started', 'scan.completed', 'scan.failed',
|
||||
'series.added', 'series.removed'
|
||||
]
|
||||
invalid_events = set(events) - set(valid_events)
|
||||
if invalid_events:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid events: {", ".join(invalid_events)}'
|
||||
}), 400
|
||||
|
||||
webhook_id = webhook_manager.create_webhook(name, url, events, secret)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Webhook created successfully',
|
||||
'data': {
|
||||
'webhook_id': webhook_id,
|
||||
'name': name,
|
||||
'url': url,
|
||||
'events': events
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to create webhook: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/webhooks/<webhook_id>', methods=['DELETE'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def delete_webhook(webhook_id):
|
||||
"""Delete a webhook endpoint."""
|
||||
try:
|
||||
success = webhook_manager.delete_webhook(webhook_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Webhook deleted successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Webhook not found'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to delete webhook: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/webhooks/test', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def test_webhook():
|
||||
"""Test webhook delivery."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
webhook_id = data.get('webhook_id')
|
||||
|
||||
if not webhook_id:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'webhook_id is required'
|
||||
}), 400
|
||||
|
||||
# Send test event
|
||||
test_data = {
|
||||
'message': 'This is a test webhook delivery',
|
||||
'test': True
|
||||
}
|
||||
|
||||
webhook_manager.trigger_event('test.webhook', test_data)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Test webhook triggered'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to test webhook: {e}")
|
||||
|
||||
|
||||
# Export Endpoints
|
||||
@api_integration_bp.route('/api/export/anime-list')
|
||||
@handle_api_errors
|
||||
@require_api_key(['read', 'export'])
|
||||
def export_anime_list():
|
||||
"""Export anime list in JSON or CSV format."""
|
||||
try:
|
||||
format_type = request.args.get('format', 'json').lower()
|
||||
include_missing_only = request.args.get('missing_only', 'false').lower() == 'true'
|
||||
|
||||
if format_type not in ['json', 'csv']:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Format must be either "json" or "csv"'
|
||||
}), 400
|
||||
|
||||
if format_type == 'json':
|
||||
data = export_manager.export_anime_list_json(include_missing_only)
|
||||
response = make_response(jsonify({
|
||||
'status': 'success',
|
||||
'data': data
|
||||
}))
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
|
||||
else: # CSV
|
||||
csv_data = export_manager.export_anime_list_csv(include_missing_only)
|
||||
response = make_response(csv_data)
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=anime_list.csv'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to export anime list: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/export/statistics')
|
||||
@handle_api_errors
|
||||
@require_api_key(['read', 'export'])
|
||||
def export_statistics():
|
||||
"""Export download statistics."""
|
||||
try:
|
||||
data = export_manager.export_download_statistics()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to export statistics: {e}")
|
||||
|
||||
|
||||
# External API Endpoints (for API key authentication)
|
||||
@api_integration_bp.route('/api/v1/series')
|
||||
@handle_api_errors
|
||||
@require_api_key(['read'])
|
||||
def api_get_series():
|
||||
"""Get series list via API."""
|
||||
try:
|
||||
# This would integrate with the main series app
|
||||
from app import series_app
|
||||
|
||||
if not series_app or not series_app.List:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'series': [],
|
||||
'count': 0
|
||||
}
|
||||
})
|
||||
|
||||
series_list = []
|
||||
for serie in series_app.List.GetList():
|
||||
series_data = {
|
||||
'name': serie.name or serie.folder,
|
||||
'folder': serie.folder,
|
||||
'key': getattr(serie, 'key', None),
|
||||
'missing_episodes_count': sum(len(episodes) for episodes in serie.episodeDict.values()) if hasattr(serie, 'episodeDict') and serie.episodeDict else 0
|
||||
}
|
||||
series_list.append(series_data)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'series': series_list,
|
||||
'count': len(series_list)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get series: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/v1/series/<serie_folder>/episodes')
|
||||
@handle_api_errors
|
||||
@require_api_key(['read'])
|
||||
def api_get_series_episodes(serie_folder):
|
||||
"""Get episodes for a specific series via API."""
|
||||
try:
|
||||
from app import series_app
|
||||
|
||||
if not series_app or not series_app.List:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Series data not available'
|
||||
}), 404
|
||||
|
||||
# Find series by folder
|
||||
target_serie = None
|
||||
for serie in series_app.List.GetList():
|
||||
if serie.folder == serie_folder:
|
||||
target_serie = serie
|
||||
break
|
||||
|
||||
if not target_serie:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Series not found'
|
||||
}), 404
|
||||
|
||||
episodes_data = {}
|
||||
if hasattr(target_serie, 'episodeDict') and target_serie.episodeDict:
|
||||
for season, episodes in target_serie.episodeDict.items():
|
||||
episodes_data[str(season)] = list(episodes)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'series_name': target_serie.name or target_serie.folder,
|
||||
'folder': target_serie.folder,
|
||||
'missing_episodes': episodes_data
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to get series episodes: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/v1/download/start', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_api_key(['download'])
|
||||
def api_start_download():
|
||||
"""Start download for specific episodes via API."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
serie_folder = data.get('serie_folder')
|
||||
season = data.get('season')
|
||||
episode = data.get('episode')
|
||||
|
||||
if not all([serie_folder, season is not None, episode is not None]):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'serie_folder, season, and episode are required'
|
||||
}), 400
|
||||
|
||||
# This would integrate with the download system
|
||||
# For now, trigger webhook event
|
||||
webhook_manager.trigger_event('download.started', {
|
||||
'serie_folder': serie_folder,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'requested_via': 'api'
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Download started',
|
||||
'data': {
|
||||
'serie_folder': serie_folder,
|
||||
'season': season,
|
||||
'episode': episode
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to start download: {e}")
|
||||
|
||||
|
||||
# Notification Service Endpoints
|
||||
@api_integration_bp.route('/api/notifications/discord', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def setup_discord_notifications():
|
||||
"""Setup Discord webhook notifications."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
webhook_url = data.get('webhook_url')
|
||||
name = data.get('name', 'discord')
|
||||
|
||||
if not webhook_url:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'webhook_url is required'
|
||||
}), 400
|
||||
|
||||
notification_service.register_discord_webhook(webhook_url, name)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Discord notifications configured'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to setup Discord notifications: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/notifications/telegram', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def setup_telegram_notifications():
|
||||
"""Setup Telegram bot notifications."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
bot_token = data.get('bot_token')
|
||||
chat_id = data.get('chat_id')
|
||||
name = data.get('name', 'telegram')
|
||||
|
||||
if not bot_token or not chat_id:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'bot_token and chat_id are required'
|
||||
}), 400
|
||||
|
||||
notification_service.register_telegram_bot(bot_token, chat_id, name)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Telegram notifications configured'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to setup Telegram notifications: {e}")
|
||||
|
||||
|
||||
@api_integration_bp.route('/api/notifications/test', methods=['POST'])
|
||||
@handle_api_errors
|
||||
@require_auth
|
||||
def test_notifications():
|
||||
"""Test notification delivery."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
service_name = data.get('service_name')
|
||||
|
||||
notification_service.send_notification(
|
||||
message="This is a test notification from AniWorld API",
|
||||
title="Test Notification",
|
||||
service_name=service_name
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Test notification sent'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
raise RetryableError(f"Failed to send test notification: {e}")
|
||||
|
||||
|
||||
# API Documentation Endpoint
|
||||
@api_integration_bp.route('/api/docs')
|
||||
def api_documentation():
|
||||
"""Get API documentation."""
|
||||
docs = {
|
||||
'title': 'AniWorld API Documentation',
|
||||
'version': '1.0.0',
|
||||
'description': 'REST API for AniWorld anime download management',
|
||||
'authentication': {
|
||||
'type': 'API Key',
|
||||
'header': 'Authorization: Bearer <api_key>',
|
||||
'note': 'API keys can be created through the web interface'
|
||||
},
|
||||
'endpoints': {
|
||||
'GET /api/v1/series': {
|
||||
'description': 'Get list of all series',
|
||||
'permissions': ['read'],
|
||||
'parameters': {},
|
||||
'response': 'List of series with basic information'
|
||||
},
|
||||
'GET /api/v1/series/{folder}/episodes': {
|
||||
'description': 'Get episodes for specific series',
|
||||
'permissions': ['read'],
|
||||
'parameters': {
|
||||
'folder': 'Series folder name'
|
||||
},
|
||||
'response': 'Missing episodes for the series'
|
||||
},
|
||||
'POST /api/v1/download/start': {
|
||||
'description': 'Start download for specific episode',
|
||||
'permissions': ['download'],
|
||||
'parameters': {
|
||||
'serie_folder': 'Series folder name',
|
||||
'season': 'Season number',
|
||||
'episode': 'Episode number'
|
||||
},
|
||||
'response': 'Download status'
|
||||
},
|
||||
'GET /api/export/anime-list': {
|
||||
'description': 'Export anime list',
|
||||
'permissions': ['read', 'export'],
|
||||
'parameters': {
|
||||
'format': 'json or csv',
|
||||
'missing_only': 'true or false'
|
||||
},
|
||||
'response': 'Anime list in requested format'
|
||||
}
|
||||
},
|
||||
'webhook_events': [
|
||||
'download.started',
|
||||
'download.completed',
|
||||
'download.failed',
|
||||
'scan.started',
|
||||
'scan.completed',
|
||||
'scan.failed',
|
||||
'series.added',
|
||||
'series.removed'
|
||||
],
|
||||
'rate_limits': {
|
||||
'default': '1000 requests per hour per API key',
|
||||
'note': 'Rate limits are configurable per API key'
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify(docs)
|
||||
|
||||
|
||||
# Export the blueprint
|
||||
__all__ = ['api_integration_bp']
|
||||
273
src/server/web/controllers/auth_controller.py
Normal file
273
src/server/web/controllers/auth_controller.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Tuple
|
||||
from functools import wraps
|
||||
from flask import session, request, jsonify, redirect, url_for
|
||||
from config import config
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manage user sessions and authentication."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_sessions: Dict[str, Dict] = {}
|
||||
self.failed_attempts: Dict[str, Dict] = {}
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Get client IP address with proxy support."""
|
||||
# Check for forwarded IP (in case of reverse proxy)
|
||||
forwarded_ip = request.headers.get('X-Forwarded-For')
|
||||
if forwarded_ip:
|
||||
return forwarded_ip.split(',')[0].strip()
|
||||
|
||||
real_ip = request.headers.get('X-Real-IP')
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
return request.remote_addr or 'unknown'
|
||||
|
||||
def _is_locked_out(self, ip_address: str) -> bool:
|
||||
"""Check if IP is currently locked out."""
|
||||
if ip_address not in self.failed_attempts:
|
||||
return False
|
||||
|
||||
attempt_data = self.failed_attempts[ip_address]
|
||||
failed_count = attempt_data.get('count', 0)
|
||||
last_attempt = attempt_data.get('last_attempt')
|
||||
|
||||
if failed_count < config.max_failed_attempts:
|
||||
return False
|
||||
|
||||
if not last_attempt:
|
||||
return False
|
||||
|
||||
# Check if lockout period has expired
|
||||
lockout_until = last_attempt + timedelta(minutes=config.lockout_duration_minutes)
|
||||
if datetime.now() >= lockout_until:
|
||||
# Reset failed attempts after lockout period
|
||||
self.failed_attempts[ip_address] = {'count': 0, 'last_attempt': None}
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _record_failed_attempt(self, ip_address: str, username: str = 'admin') -> None:
|
||||
"""Record failed login attempt for fail2ban logging."""
|
||||
# Update failed attempts counter
|
||||
if ip_address not in self.failed_attempts:
|
||||
self.failed_attempts[ip_address] = {'count': 0, 'last_attempt': None}
|
||||
|
||||
self.failed_attempts[ip_address]['count'] += 1
|
||||
self.failed_attempts[ip_address]['last_attempt'] = datetime.now()
|
||||
|
||||
# Log in fail2ban compatible format using the new logging system
|
||||
if config.enable_fail2ban_logging:
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from logging_config import log_auth_failure
|
||||
log_auth_failure(ip_address, username)
|
||||
except ImportError:
|
||||
# Fallback to simple logging if new system not available
|
||||
logger = logging.getLogger('auth_failures')
|
||||
logger.warning(f"authentication failure for [{ip_address}] user [{username}]")
|
||||
|
||||
def authenticate(self, password: str) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Authenticate user with password.
|
||||
Returns: (success, message, session_token)
|
||||
"""
|
||||
ip_address = self._get_client_ip()
|
||||
|
||||
# Check if IP is locked out
|
||||
if self._is_locked_out(ip_address):
|
||||
remaining_time = self._get_remaining_lockout_time(ip_address)
|
||||
return False, f"Too many failed attempts. Try again in {remaining_time} minutes.", None
|
||||
|
||||
# Verify password
|
||||
if not config.verify_password(password):
|
||||
self._record_failed_attempt(ip_address)
|
||||
attempts_left = config.max_failed_attempts - self.failed_attempts[ip_address]['count']
|
||||
|
||||
if attempts_left <= 0:
|
||||
return False, f"Invalid password. Account locked for {config.lockout_duration_minutes} minutes.", None
|
||||
else:
|
||||
return False, f"Invalid password. {attempts_left} attempts remaining.", None
|
||||
|
||||
# Reset failed attempts on successful login
|
||||
if ip_address in self.failed_attempts:
|
||||
self.failed_attempts[ip_address] = {'count': 0, 'last_attempt': None}
|
||||
|
||||
# Create session
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
session_data = {
|
||||
'token': session_token,
|
||||
'ip_address': ip_address,
|
||||
'login_time': datetime.now(),
|
||||
'last_activity': datetime.now(),
|
||||
'user': 'admin'
|
||||
}
|
||||
|
||||
self.active_sessions[session_token] = session_data
|
||||
|
||||
# Set Flask session
|
||||
session['token'] = session_token
|
||||
session['user'] = 'admin'
|
||||
session['login_time'] = datetime.now().isoformat()
|
||||
|
||||
return True, "Login successful", session_token
|
||||
|
||||
def login(self, password: str, ip_address: str = None) -> Dict:
|
||||
"""
|
||||
Login method that returns a dictionary response (for API compatibility).
|
||||
"""
|
||||
success, message, token = self.authenticate(password)
|
||||
|
||||
if success:
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': message,
|
||||
'token': token
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}
|
||||
|
||||
def _get_remaining_lockout_time(self, ip_address: str) -> int:
|
||||
"""Get remaining lockout time in minutes."""
|
||||
if ip_address not in self.failed_attempts:
|
||||
return 0
|
||||
|
||||
last_attempt = self.failed_attempts[ip_address].get('last_attempt')
|
||||
if not last_attempt:
|
||||
return 0
|
||||
|
||||
lockout_until = last_attempt + timedelta(minutes=config.lockout_duration_minutes)
|
||||
remaining = lockout_until - datetime.now()
|
||||
|
||||
return max(0, int(remaining.total_seconds() / 60))
|
||||
|
||||
def is_authenticated(self, session_token: Optional[str] = None) -> bool:
|
||||
"""Check if user is authenticated with valid session."""
|
||||
if not session_token:
|
||||
session_token = session.get('token')
|
||||
|
||||
if not session_token or session_token not in self.active_sessions:
|
||||
return False
|
||||
|
||||
session_data = self.active_sessions[session_token]
|
||||
|
||||
# Check session timeout
|
||||
last_activity = session_data['last_activity']
|
||||
timeout_duration = timedelta(hours=config.session_timeout_hours)
|
||||
|
||||
if datetime.now() - last_activity > timeout_duration:
|
||||
self.logout(session_token)
|
||||
return False
|
||||
|
||||
# Update last activity
|
||||
session_data['last_activity'] = datetime.now()
|
||||
|
||||
return True
|
||||
|
||||
def logout(self, session_token: Optional[str] = None) -> bool:
|
||||
"""Logout user and cleanup session."""
|
||||
if not session_token:
|
||||
session_token = session.get('token')
|
||||
|
||||
if session_token and session_token in self.active_sessions:
|
||||
del self.active_sessions[session_token]
|
||||
|
||||
# Clear Flask session
|
||||
session.clear()
|
||||
|
||||
return True
|
||||
|
||||
def get_session_info(self, session_token: Optional[str] = None) -> Optional[Dict]:
|
||||
"""Get session information."""
|
||||
if not session_token:
|
||||
session_token = session.get('token')
|
||||
|
||||
if not session_token or session_token not in self.active_sessions:
|
||||
return None
|
||||
|
||||
session_data = self.active_sessions[session_token].copy()
|
||||
# Convert datetime objects to strings for JSON serialization
|
||||
session_data['login_time'] = session_data['login_time'].isoformat()
|
||||
session_data['last_activity'] = session_data['last_activity'].isoformat()
|
||||
|
||||
return session_data
|
||||
|
||||
def cleanup_expired_sessions(self) -> int:
|
||||
"""Clean up expired sessions. Returns number of sessions removed."""
|
||||
timeout_duration = timedelta(hours=config.session_timeout_hours)
|
||||
current_time = datetime.now()
|
||||
expired_tokens = []
|
||||
|
||||
for token, session_data in self.active_sessions.items():
|
||||
last_activity = session_data['last_activity']
|
||||
if current_time - last_activity > timeout_duration:
|
||||
expired_tokens.append(token)
|
||||
|
||||
for token in expired_tokens:
|
||||
del self.active_sessions[token]
|
||||
|
||||
return len(expired_tokens)
|
||||
|
||||
|
||||
# Global session manager instance
|
||||
session_manager = SessionManager()
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""Decorator to require authentication for Flask routes."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session_manager.is_authenticated():
|
||||
# Check if this is an AJAX request (JSON, XMLHttpRequest, or fetch API request)
|
||||
is_ajax = (
|
||||
request.is_json or
|
||||
request.headers.get('X-Requested-With') == 'XMLHttpRequest' or
|
||||
request.headers.get('Accept', '').startswith('application/json') or
|
||||
'/api/' in request.path # API endpoints should return JSON
|
||||
)
|
||||
|
||||
if is_ajax:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Authentication required',
|
||||
'code': 'AUTH_REQUIRED'
|
||||
}), 401
|
||||
else:
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def optional_auth(f):
|
||||
"""Decorator that checks auth but doesn't require it."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if master password is configured
|
||||
if config.has_master_password():
|
||||
# If configured, require authentication
|
||||
if not session_manager.is_authenticated():
|
||||
# Check if this is an AJAX request (JSON, XMLHttpRequest, or fetch API request)
|
||||
is_ajax = (
|
||||
request.is_json or
|
||||
request.headers.get('X-Requested-With') == 'XMLHttpRequest' or
|
||||
request.headers.get('Accept', '').startswith('application/json') or
|
||||
'/api/' in request.path # API endpoints should return JSON
|
||||
)
|
||||
|
||||
if is_ajax:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Authentication required',
|
||||
'code': 'AUTH_REQUIRED'
|
||||
}), 401
|
||||
else:
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
1
src/server/web/middleware/__init__.py
Normal file
1
src/server/web/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Web middleware
|
||||
1554
src/server/web/middleware/accessibility_middleware.py
Normal file
1554
src/server/web/middleware/accessibility_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1431
src/server/web/middleware/contrast_middleware.py
Normal file
1431
src/server/web/middleware/contrast_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
767
src/server/web/middleware/drag_drop_middleware.py
Normal file
767
src/server/web/middleware/drag_drop_middleware.py
Normal file
@@ -0,0 +1,767 @@
|
||||
"""
|
||||
Drag and Drop Functionality for File Operations
|
||||
|
||||
This module provides drag-and-drop capabilities for the AniWorld web interface,
|
||||
including file uploads, series reordering, and batch operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
class DragDropManager:
|
||||
"""Manages drag and drop operations for the web interface."""
|
||||
|
||||
def __init__(self):
|
||||
self.supported_files = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm']
|
||||
self.max_file_size = 50 * 1024 * 1024 * 1024 # 50GB
|
||||
|
||||
def get_drag_drop_js(self):
|
||||
"""Generate JavaScript code for drag and drop functionality."""
|
||||
return f"""
|
||||
// AniWorld Drag & Drop Manager
|
||||
class DragDropManager {{
|
||||
constructor() {{
|
||||
this.supportedFiles = {json.dumps(self.supported_files)};
|
||||
this.maxFileSize = {self.max_file_size};
|
||||
this.dropZones = new Map();
|
||||
this.dragData = null;
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.setupGlobalDragDrop();
|
||||
this.setupSeriesReordering();
|
||||
this.setupBatchOperations();
|
||||
this.createDropZoneOverlay();
|
||||
}}
|
||||
|
||||
setupGlobalDragDrop() {{
|
||||
// Prevent default drag behaviors on document
|
||||
document.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||
document.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
document.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||
document.addEventListener('drop', this.handleDrop.bind(this));
|
||||
|
||||
// Setup file drop zones
|
||||
this.initializeDropZones();
|
||||
}}
|
||||
|
||||
initializeDropZones() {{
|
||||
// Main content area drop zone
|
||||
const mainContent = document.querySelector('.main-content, .container-fluid');
|
||||
if (mainContent) {{
|
||||
this.createDropZone(mainContent, {{
|
||||
types: ['files'],
|
||||
accept: this.supportedFiles,
|
||||
multiple: true,
|
||||
callback: this.handleFileUpload.bind(this)
|
||||
}});
|
||||
}}
|
||||
|
||||
// Series list drop zone for reordering
|
||||
const seriesList = document.querySelector('.series-list, .anime-grid');
|
||||
if (seriesList) {{
|
||||
this.createDropZone(seriesList, {{
|
||||
types: ['series'],
|
||||
callback: this.handleSeriesReorder.bind(this)
|
||||
}});
|
||||
}}
|
||||
|
||||
// Queue drop zone
|
||||
const queueArea = document.querySelector('.queue-area, .download-queue');
|
||||
if (queueArea) {{
|
||||
this.createDropZone(queueArea, {{
|
||||
types: ['series', 'episodes'],
|
||||
callback: this.handleQueueOperation.bind(this)
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
createDropZone(element, options) {{
|
||||
const dropZone = {{
|
||||
element: element,
|
||||
options: options,
|
||||
active: false
|
||||
}};
|
||||
|
||||
this.dropZones.set(element, dropZone);
|
||||
|
||||
// Add drop zone event listeners
|
||||
element.addEventListener('dragenter', (e) => this.onDropZoneEnter(e, dropZone));
|
||||
element.addEventListener('dragover', (e) => this.onDropZoneOver(e, dropZone));
|
||||
element.addEventListener('dragleave', (e) => this.onDropZoneLeave(e, dropZone));
|
||||
element.addEventListener('drop', (e) => this.onDropZoneDrop(e, dropZone));
|
||||
|
||||
// Add visual indicators
|
||||
element.classList.add('drop-zone');
|
||||
|
||||
return dropZone;
|
||||
}}
|
||||
|
||||
setupSeriesReordering() {{
|
||||
const seriesItems = document.querySelectorAll('.series-item, .anime-card');
|
||||
seriesItems.forEach(item => {{
|
||||
item.draggable = true;
|
||||
item.addEventListener('dragstart', this.handleSeriesDragStart.bind(this));
|
||||
item.addEventListener('dragend', this.handleSeriesDragEnd.bind(this));
|
||||
}});
|
||||
}}
|
||||
|
||||
setupBatchOperations() {{
|
||||
// Enable dragging of selected series for batch operations
|
||||
const selectionArea = document.querySelector('.series-selection, .selection-controls');
|
||||
if (selectionArea) {{
|
||||
selectionArea.addEventListener('dragstart', this.handleBatchDragStart.bind(this));
|
||||
}}
|
||||
}}
|
||||
|
||||
handleDragEnter(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.hasFiles(e)) {{
|
||||
this.showDropOverlay();
|
||||
}}
|
||||
}}
|
||||
|
||||
handleDragOver(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
|
||||
handleDragLeave(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only hide overlay if leaving the window
|
||||
if (e.clientX === 0 && e.clientY === 0) {{
|
||||
this.hideDropOverlay();
|
||||
}}
|
||||
}}
|
||||
|
||||
handleDrop(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.hideDropOverlay();
|
||||
|
||||
if (this.hasFiles(e)) {{
|
||||
this.handleFileUpload(e.dataTransfer.files);
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneEnter(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.canAcceptDrop(e, dropZone)) {{
|
||||
dropZone.element.classList.add('drag-over');
|
||||
dropZone.active = true;
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneOver(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (dropZone.active) {{
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneLeave(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
|
||||
// Check if we're actually leaving the drop zone
|
||||
const rect = dropZone.element.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {{
|
||||
dropZone.element.classList.remove('drag-over');
|
||||
dropZone.active = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneDrop(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dropZone.element.classList.remove('drag-over');
|
||||
dropZone.active = false;
|
||||
|
||||
if (dropZone.options.callback) {{
|
||||
if (this.hasFiles(e)) {{
|
||||
dropZone.options.callback(e.dataTransfer.files, 'files');
|
||||
}} else {{
|
||||
dropZone.options.callback(this.dragData, 'data');
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
canAcceptDrop(e, dropZone) {{
|
||||
const types = dropZone.options.types || [];
|
||||
|
||||
if (this.hasFiles(e) && types.includes('files')) {{
|
||||
return this.validateFiles(e.dataTransfer.files, dropZone.options);
|
||||
}}
|
||||
|
||||
if (this.dragData && types.includes(this.dragData.type)) {{
|
||||
return true;
|
||||
}}
|
||||
|
||||
return false;
|
||||
}}
|
||||
|
||||
hasFiles(e) {{
|
||||
return e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0;
|
||||
}}
|
||||
|
||||
validateFiles(files, options) {{
|
||||
const accept = options.accept || [];
|
||||
const maxSize = options.maxSize || this.maxFileSize;
|
||||
const multiple = options.multiple !== false;
|
||||
|
||||
if (!multiple && files.length > 1) {{
|
||||
return false;
|
||||
}}
|
||||
|
||||
for (let file of files) {{
|
||||
// Check file size
|
||||
if (file.size > maxSize) {{
|
||||
return false;
|
||||
}}
|
||||
|
||||
// Check file extension
|
||||
if (accept.length > 0) {{
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
if (!accept.includes(ext)) {{
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
return true;
|
||||
}}
|
||||
|
||||
handleSeriesDragStart(e) {{
|
||||
const seriesItem = e.target.closest('.series-item, .anime-card');
|
||||
if (!seriesItem) return;
|
||||
|
||||
this.dragData = {{
|
||||
type: 'series',
|
||||
element: seriesItem,
|
||||
data: {{
|
||||
id: seriesItem.dataset.seriesId || seriesItem.dataset.id,
|
||||
name: seriesItem.dataset.seriesName || seriesItem.querySelector('.series-name, .anime-title')?.textContent,
|
||||
folder: seriesItem.dataset.folder
|
||||
}}
|
||||
}};
|
||||
|
||||
// Create drag image
|
||||
const dragImage = this.createDragImage(seriesItem);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
seriesItem.classList.add('dragging');
|
||||
}}
|
||||
|
||||
handleSeriesDragEnd(e) {{
|
||||
const seriesItem = e.target.closest('.series-item, .anime-card');
|
||||
if (seriesItem) {{
|
||||
seriesItem.classList.remove('dragging');
|
||||
}}
|
||||
this.dragData = null;
|
||||
}}
|
||||
|
||||
handleBatchDragStart(e) {{
|
||||
const selectedItems = document.querySelectorAll('.series-item.selected, .anime-card.selected');
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
this.dragData = {{
|
||||
type: 'batch',
|
||||
count: selectedItems.length,
|
||||
items: Array.from(selectedItems).map(item => ({{
|
||||
id: item.dataset.seriesId || item.dataset.id,
|
||||
name: item.dataset.seriesName || item.querySelector('.series-name, .anime-title')?.textContent,
|
||||
folder: item.dataset.folder
|
||||
}}))
|
||||
}};
|
||||
|
||||
// Create batch drag image
|
||||
const dragImage = this.createBatchDragImage(selectedItems.length);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
|
||||
handleFileUpload(files, type = 'files') {{
|
||||
if (files.length === 0) return;
|
||||
|
||||
const validFiles = [];
|
||||
const errors = [];
|
||||
|
||||
// Validate each file
|
||||
for (let file of files) {{
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!this.supportedFiles.includes(ext)) {{
|
||||
errors.push(`Unsupported file type: ${{file.name}}`);
|
||||
continue;
|
||||
}}
|
||||
|
||||
if (file.size > this.maxFileSize) {{
|
||||
errors.push(`File too large: ${{file.name}} (${{this.formatFileSize(file.size)}})`);
|
||||
continue;
|
||||
}}
|
||||
|
||||
validFiles.push(file);
|
||||
}}
|
||||
|
||||
// Show errors if any
|
||||
if (errors.length > 0) {{
|
||||
this.showUploadErrors(errors);
|
||||
}}
|
||||
|
||||
// Process valid files
|
||||
if (validFiles.length > 0) {{
|
||||
this.showUploadProgress(validFiles);
|
||||
this.uploadFiles(validFiles);
|
||||
}}
|
||||
}}
|
||||
|
||||
handleSeriesReorder(data, type) {{
|
||||
if (type !== 'data' || !data || data.type !== 'series') return;
|
||||
|
||||
// Find drop position
|
||||
const seriesList = document.querySelector('.series-list, .anime-grid');
|
||||
const items = seriesList.querySelectorAll('.series-item, .anime-card');
|
||||
|
||||
// Implement reordering logic
|
||||
this.reorderSeries(data.data.id, items);
|
||||
}}
|
||||
|
||||
handleQueueOperation(data, type) {{
|
||||
if (type === 'files') {{
|
||||
// Handle file drops to queue
|
||||
this.addFilesToQueue(data);
|
||||
}} else if (type === 'data') {{
|
||||
// Handle series/episode drops to queue
|
||||
this.addToQueue(data);
|
||||
}}
|
||||
}}
|
||||
|
||||
createDropZoneOverlay() {{
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'drop-overlay';
|
||||
overlay.className = 'drop-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="drop-message">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<h3>Drop Files Here</h3>
|
||||
<p>Supported formats: ${{this.supportedFiles.join(', ')}}</p>
|
||||
<p>Maximum size: ${{this.formatFileSize(this.maxFileSize)}}</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
}}
|
||||
|
||||
showDropOverlay() {{
|
||||
const overlay = document.getElementById('drop-overlay');
|
||||
if (overlay) {{
|
||||
overlay.style.display = 'flex';
|
||||
}}
|
||||
}}
|
||||
|
||||
hideDropOverlay() {{
|
||||
const overlay = document.getElementById('drop-overlay');
|
||||
if (overlay) {{
|
||||
overlay.style.display = 'none';
|
||||
}}
|
||||
}}
|
||||
|
||||
createDragImage(element) {{
|
||||
const clone = element.cloneNode(true);
|
||||
clone.style.position = 'absolute';
|
||||
clone.style.top = '-1000px';
|
||||
clone.style.opacity = '0.8';
|
||||
clone.style.transform = 'rotate(5deg)';
|
||||
document.body.appendChild(clone);
|
||||
|
||||
setTimeout(() => document.body.removeChild(clone), 100);
|
||||
return clone;
|
||||
}}
|
||||
|
||||
createBatchDragImage(count) {{
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.className = 'batch-drag-image';
|
||||
dragImage.innerHTML = `
|
||||
<i class="fas fa-files"></i>
|
||||
<span>${{count}} items</span>
|
||||
`;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-1000px';
|
||||
document.body.appendChild(dragImage);
|
||||
|
||||
setTimeout(() => document.body.removeChild(dragImage), 100);
|
||||
return dragImage;
|
||||
}}
|
||||
|
||||
formatFileSize(bytes) {{
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}}
|
||||
|
||||
showUploadErrors(errors) {{
|
||||
const errorModal = document.createElement('div');
|
||||
errorModal.className = 'modal fade';
|
||||
errorModal.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Errors</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="list-unstyled">
|
||||
${{errors.map(error => `<li class="text-danger"><i class="fas fa-exclamation-triangle"></i> ${{error}}</li>`).join('')}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorModal);
|
||||
const modal = new bootstrap.Modal(errorModal);
|
||||
modal.show();
|
||||
|
||||
errorModal.addEventListener('hidden.bs.modal', () => {{
|
||||
document.body.removeChild(errorModal);
|
||||
}});
|
||||
}}
|
||||
|
||||
showUploadProgress(files) {{
|
||||
// Create upload progress modal
|
||||
const progressModal = document.createElement('div');
|
||||
progressModal.className = 'modal fade';
|
||||
progressModal.id = 'upload-progress-modal';
|
||||
progressModal.setAttribute('data-bs-backdrop', 'static');
|
||||
progressModal.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Uploading Files</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="upload-progress-list"></div>
|
||||
<div class="mt-3">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" id="overall-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<small class="text-muted">Overall progress</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-upload">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(progressModal);
|
||||
const modal = new bootstrap.Modal(progressModal);
|
||||
modal.show();
|
||||
|
||||
return modal;
|
||||
}}
|
||||
|
||||
uploadFiles(files) {{
|
||||
// This would implement the actual file upload logic
|
||||
// For now, just simulate upload progress
|
||||
const progressModal = this.showUploadProgress(files);
|
||||
|
||||
files.forEach((file, index) => {{
|
||||
this.simulateFileUpload(file, index, files.length);
|
||||
}});
|
||||
}}
|
||||
|
||||
simulateFileUpload(file, index, total) {{
|
||||
const progressList = document.getElementById('upload-progress-list');
|
||||
const fileProgress = document.createElement('div');
|
||||
fileProgress.className = 'mb-2';
|
||||
fileProgress.innerHTML = `
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-truncate">${{file.name}}</span>
|
||||
<span class="text-muted">${{this.formatFileSize(file.size)}}</span>
|
||||
</div>
|
||||
<div class="progress progress-sm">
|
||||
<div class="progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
`;
|
||||
progressList.appendChild(fileProgress);
|
||||
|
||||
// Simulate progress
|
||||
const progressBar = fileProgress.querySelector('.progress-bar');
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {{
|
||||
progress += Math.random() * 15;
|
||||
if (progress > 100) progress = 100;
|
||||
|
||||
progressBar.style.width = progress + '%';
|
||||
|
||||
if (progress >= 100) {{
|
||||
clearInterval(interval);
|
||||
progressBar.classList.add('bg-success');
|
||||
|
||||
// Update overall progress
|
||||
this.updateOverallProgress(index + 1, total);
|
||||
}}
|
||||
}}, 200);
|
||||
}}
|
||||
|
||||
updateOverallProgress(completed, total) {{
|
||||
const overallProgress = document.getElementById('overall-progress');
|
||||
const percentage = (completed / total) * 100;
|
||||
overallProgress.style.width = percentage + '%';
|
||||
|
||||
if (completed === total) {{
|
||||
setTimeout(() => {{
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('upload-progress-modal'));
|
||||
modal.hide();
|
||||
}}, 1000);
|
||||
}}
|
||||
}}
|
||||
|
||||
reorderSeries(seriesId, items) {{
|
||||
// Implement series reordering logic
|
||||
console.log('Reordering series:', seriesId);
|
||||
|
||||
// This would send an API request to update the order
|
||||
fetch('/api/series/reorder', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json'
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
seriesId: seriesId,
|
||||
newPosition: Array.from(items).findIndex(item =>
|
||||
item.classList.contains('drag-over'))
|
||||
}})
|
||||
}})
|
||||
.then(response => response.json())
|
||||
.then(data => {{
|
||||
if (data.success) {{
|
||||
this.showToast('Series reordered successfully', 'success');
|
||||
}} else {{
|
||||
this.showToast('Failed to reorder series', 'error');
|
||||
}}
|
||||
}})
|
||||
.catch(error => {{
|
||||
console.error('Reorder error:', error);
|
||||
this.showToast('Error reordering series', 'error');
|
||||
}});
|
||||
}}
|
||||
|
||||
addToQueue(data) {{
|
||||
// Add series or episodes to download queue
|
||||
let items = [];
|
||||
|
||||
if (data.type === 'series') {{
|
||||
items = [data.data];
|
||||
}} else if (data.type === 'batch') {{
|
||||
items = data.items;
|
||||
}}
|
||||
|
||||
fetch('/api/queue/add', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json'
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
items: items
|
||||
}})
|
||||
}})
|
||||
.then(response => response.json())
|
||||
.then(result => {{
|
||||
if (result.success) {{
|
||||
this.showToast(`Added ${{items.length}} item(s) to queue`, 'success');
|
||||
}} else {{
|
||||
this.showToast('Failed to add to queue', 'error');
|
||||
}}
|
||||
}})
|
||||
.catch(error => {{
|
||||
console.error('Queue add error:', error);
|
||||
this.showToast('Error adding to queue', 'error');
|
||||
}});
|
||||
}}
|
||||
|
||||
showToast(message, type = 'info') {{
|
||||
// Create and show a toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${{type === 'error' ? 'danger' : type}}`;
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${{message}}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let toastContainer = document.querySelector('.toast-container');
|
||||
if (!toastContainer) {{
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||
document.body.appendChild(toastContainer);
|
||||
}}
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', () => {{
|
||||
toastContainer.removeChild(toast);
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
// Initialize drag and drop when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
window.dragDropManager = new DragDropManager();
|
||||
}});
|
||||
"""
|
||||
|
||||
def get_css(self):
|
||||
"""Generate CSS styles for drag and drop functionality."""
|
||||
return """
|
||||
/* Drag and Drop Styles */
|
||||
.drop-zone {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
border: 2px dashed #0d6efd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(13, 110, 253, 0.05);
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.drop-message {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border: 3px dashed #0d6efd;
|
||||
border-radius: 15px;
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.drop-message i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.drop-message h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-message p {
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.series-item.dragging,
|
||||
.anime-card.dragging {
|
||||
opacity: 0.5;
|
||||
transform: rotate(2deg);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.batch-drag-image {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.progress-sm {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Drag handle for reorderable items */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #6c757d;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Drop indicators */
|
||||
.drop-indicator {
|
||||
height: 3px;
|
||||
background: #0d6efd;
|
||||
margin: 0.25rem 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.drop-indicator.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drop-zone,
|
||||
.series-item.dragging,
|
||||
.anime-card.dragging {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# Export the drag drop manager
|
||||
drag_drop_manager = DragDropManager()
|
||||
462
src/server/web/middleware/error_handler.py
Normal file
462
src/server/web/middleware/error_handler.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
Error Handling & Recovery System for AniWorld App
|
||||
|
||||
This module provides comprehensive error handling for network failures,
|
||||
download errors, and system recovery mechanisms.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import functools
|
||||
import threading
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import requests
|
||||
import socket
|
||||
import ssl
|
||||
from urllib3.exceptions import ConnectionError, TimeoutError, ReadTimeoutError
|
||||
from requests.exceptions import RequestException, ConnectionError as ReqConnectionError
|
||||
from flask import jsonify
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
|
||||
class NetworkError(Exception):
|
||||
"""Base class for network-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class DownloadError(Exception):
|
||||
"""Base class for download-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RetryableError(Exception):
|
||||
"""Base class for errors that can be retried."""
|
||||
pass
|
||||
|
||||
|
||||
class NonRetryableError(Exception):
|
||||
"""Base class for errors that should not be retried."""
|
||||
pass
|
||||
|
||||
|
||||
class ErrorRecoveryManager:
|
||||
"""Manages error recovery strategies and retry mechanisms."""
|
||||
|
||||
def __init__(self, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0):
|
||||
self.max_retries = max_retries
|
||||
self.base_delay = base_delay
|
||||
self.max_delay = max_delay
|
||||
self.error_history: List[Dict] = []
|
||||
self.blacklisted_urls: Dict[str, datetime] = {}
|
||||
self.retry_counts: Dict[str, int] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def is_network_error(self, error: Exception) -> bool:
|
||||
"""Check if error is network-related."""
|
||||
network_errors = (
|
||||
ConnectionError, TimeoutError, ReadTimeoutError,
|
||||
ReqConnectionError, socket.timeout, socket.gaierror,
|
||||
ssl.SSLError, requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError
|
||||
)
|
||||
return isinstance(error, network_errors)
|
||||
|
||||
def is_retryable_error(self, error: Exception) -> bool:
|
||||
"""Determine if an error should be retried."""
|
||||
if isinstance(error, NonRetryableError):
|
||||
return False
|
||||
|
||||
if isinstance(error, RetryableError):
|
||||
return True
|
||||
|
||||
# Network errors are generally retryable
|
||||
if self.is_network_error(error):
|
||||
return True
|
||||
|
||||
# HTTP status codes that are retryable
|
||||
if hasattr(error, 'response') and error.response:
|
||||
status_code = error.response.status_code
|
||||
retryable_codes = [408, 429, 500, 502, 503, 504]
|
||||
return status_code in retryable_codes
|
||||
|
||||
return False
|
||||
|
||||
def calculate_delay(self, attempt: int) -> float:
|
||||
"""Calculate exponential backoff delay."""
|
||||
delay = self.base_delay * (2 ** (attempt - 1))
|
||||
return min(delay, self.max_delay)
|
||||
|
||||
def log_error(self, error: Exception, context: str, attempt: int = None):
|
||||
"""Log error with context information."""
|
||||
error_info = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error_type': type(error).__name__,
|
||||
'error_message': str(error),
|
||||
'context': context,
|
||||
'attempt': attempt,
|
||||
'retryable': self.is_retryable_error(error)
|
||||
}
|
||||
|
||||
self.error_history.append(error_info)
|
||||
|
||||
# Keep only last 1000 errors
|
||||
if len(self.error_history) > 1000:
|
||||
self.error_history = self.error_history[-1000:]
|
||||
|
||||
log_level = logging.WARNING if self.is_retryable_error(error) else logging.ERROR
|
||||
self.logger.log(log_level, f"Error in {context}: {error}", exc_info=True)
|
||||
|
||||
def add_to_blacklist(self, url: str, duration_minutes: int = 30):
|
||||
"""Add URL to temporary blacklist."""
|
||||
self.blacklisted_urls[url] = datetime.now() + timedelta(minutes=duration_minutes)
|
||||
|
||||
def is_blacklisted(self, url: str) -> bool:
|
||||
"""Check if URL is currently blacklisted."""
|
||||
if url in self.blacklisted_urls:
|
||||
if datetime.now() < self.blacklisted_urls[url]:
|
||||
return True
|
||||
else:
|
||||
del self.blacklisted_urls[url]
|
||||
return False
|
||||
|
||||
def cleanup_blacklist(self):
|
||||
"""Remove expired entries from blacklist."""
|
||||
now = datetime.now()
|
||||
expired_keys = [url for url, expiry in self.blacklisted_urls.items() if now >= expiry]
|
||||
for key in expired_keys:
|
||||
del self.blacklisted_urls[key]
|
||||
|
||||
|
||||
class RetryMechanism:
|
||||
"""Advanced retry mechanism with exponential backoff and jitter."""
|
||||
|
||||
def __init__(self, recovery_manager: ErrorRecoveryManager):
|
||||
self.recovery_manager = recovery_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def retry_with_backoff(
|
||||
self,
|
||||
func: Callable,
|
||||
*args,
|
||||
max_retries: int = None,
|
||||
backoff_factor: float = 1.0,
|
||||
jitter: bool = True,
|
||||
retry_on: tuple = None,
|
||||
context: str = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Retry function with exponential backoff and jitter.
|
||||
|
||||
Args:
|
||||
func: Function to retry
|
||||
max_retries: Maximum number of retries (uses recovery manager default if None)
|
||||
backoff_factor: Multiplier for backoff delay
|
||||
jitter: Add random jitter to prevent thundering herd
|
||||
retry_on: Tuple of exception types to retry on
|
||||
context: Context string for logging
|
||||
|
||||
Returns:
|
||||
Function result
|
||||
|
||||
Raises:
|
||||
Last exception if all retries fail
|
||||
"""
|
||||
if max_retries is None:
|
||||
max_retries = self.recovery_manager.max_retries
|
||||
|
||||
if context is None:
|
||||
context = f"{func.__name__}"
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(1, max_retries + 2): # +1 for initial attempt
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
|
||||
# Check if we should retry this error
|
||||
should_retry = (
|
||||
retry_on is None and self.recovery_manager.is_retryable_error(e)
|
||||
) or (
|
||||
retry_on is not None and isinstance(e, retry_on)
|
||||
)
|
||||
|
||||
if attempt > max_retries or not should_retry:
|
||||
self.recovery_manager.log_error(e, context, attempt)
|
||||
raise e
|
||||
|
||||
# Calculate delay with jitter
|
||||
delay = self.recovery_manager.calculate_delay(attempt) * backoff_factor
|
||||
if jitter:
|
||||
import random
|
||||
delay *= (0.5 + random.random() * 0.5) # Add 0-50% jitter
|
||||
|
||||
self.recovery_manager.log_error(e, context, attempt)
|
||||
self.logger.info(f"Retrying {context} in {delay:.2f}s (attempt {attempt}/{max_retries})")
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
class NetworkHealthChecker:
|
||||
"""Monitor network connectivity and health."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.connectivity_cache = {}
|
||||
self.cache_timeout = 60 # seconds
|
||||
|
||||
def check_connectivity(self, host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool:
|
||||
"""Check basic network connectivity."""
|
||||
cache_key = f"{host}:{port}"
|
||||
now = time.time()
|
||||
|
||||
# Check cache
|
||||
if cache_key in self.connectivity_cache:
|
||||
timestamp, result = self.connectivity_cache[cache_key]
|
||||
if now - timestamp < self.cache_timeout:
|
||||
return result
|
||||
|
||||
try:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port))
|
||||
result = True
|
||||
except Exception:
|
||||
result = False
|
||||
|
||||
self.connectivity_cache[cache_key] = (now, result)
|
||||
return result
|
||||
|
||||
def check_url_reachability(self, url: str, timeout: float = 10.0) -> bool:
|
||||
"""Check if a specific URL is reachable."""
|
||||
try:
|
||||
response = requests.head(url, timeout=timeout, allow_redirects=True)
|
||||
return response.status_code < 400
|
||||
except Exception as e:
|
||||
self.logger.debug(f"URL {url} not reachable: {e}")
|
||||
return False
|
||||
|
||||
def get_network_status(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive network status."""
|
||||
return {
|
||||
'basic_connectivity': self.check_connectivity(),
|
||||
'dns_resolution': self.check_connectivity("1.1.1.1", 53),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
class FileCorruptionDetector:
|
||||
"""Detect and handle file corruption."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_checksum(self, file_path: str, algorithm: str = 'md5') -> str:
|
||||
"""Calculate file checksum."""
|
||||
hash_func = getattr(hashlib, algorithm)()
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_func.update(chunk)
|
||||
return hash_func.hexdigest()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to calculate checksum for {file_path}: {e}")
|
||||
raise
|
||||
|
||||
def verify_file_size(self, file_path: str, expected_size: int = None, min_size: int = 1024) -> bool:
|
||||
"""Verify file has reasonable size."""
|
||||
try:
|
||||
actual_size = os.path.getsize(file_path)
|
||||
|
||||
# Check minimum size
|
||||
if actual_size < min_size:
|
||||
self.logger.warning(f"File {file_path} too small: {actual_size} bytes")
|
||||
return False
|
||||
|
||||
# Check expected size if provided
|
||||
if expected_size and abs(actual_size - expected_size) > expected_size * 0.1: # 10% tolerance
|
||||
self.logger.warning(f"File {file_path} size mismatch: expected {expected_size}, got {actual_size}")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to verify file size for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def is_valid_video_file(self, file_path: str) -> bool:
|
||||
"""Basic validation for video files."""
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
|
||||
# Check file size
|
||||
if not self.verify_file_size(file_path):
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
video_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm'}
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in video_extensions:
|
||||
self.logger.warning(f"File {file_path} has unexpected extension: {ext}")
|
||||
|
||||
# Try to read first few bytes to check for valid headers
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(32)
|
||||
# Common video file signatures
|
||||
video_signatures = [
|
||||
b'\x00\x00\x00\x18ftypmp4', # MP4
|
||||
b'\x1a\x45\xdf\xa3', # MKV (Matroska)
|
||||
b'RIFF', # AVI
|
||||
]
|
||||
|
||||
for sig in video_signatures:
|
||||
if header.startswith(sig):
|
||||
return True
|
||||
|
||||
# If no specific signature matches, assume it's valid if size is reasonable
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to read file header for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class RecoveryStrategies:
|
||||
"""Implement various recovery strategies for different error types."""
|
||||
|
||||
def __init__(self, recovery_manager: ErrorRecoveryManager):
|
||||
self.recovery_manager = recovery_manager
|
||||
self.retry_mechanism = RetryMechanism(recovery_manager)
|
||||
self.health_checker = NetworkHealthChecker()
|
||||
self.corruption_detector = FileCorruptionDetector()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def handle_network_failure(self, func: Callable, *args, **kwargs) -> Any:
|
||||
"""Handle network failures with comprehensive recovery."""
|
||||
def recovery_wrapper():
|
||||
# Check basic connectivity first
|
||||
if not self.health_checker.check_connectivity():
|
||||
raise NetworkError("No internet connectivity")
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return self.retry_mechanism.retry_with_backoff(
|
||||
recovery_wrapper,
|
||||
max_retries=5,
|
||||
backoff_factor=1.5,
|
||||
context=f"network_operation_{func.__name__}",
|
||||
retry_on=(NetworkError, ConnectionError, TimeoutError)
|
||||
)
|
||||
|
||||
def handle_download_failure(
|
||||
self,
|
||||
download_func: Callable,
|
||||
file_path: str,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Handle download failures with corruption checking and resume support."""
|
||||
def download_with_verification():
|
||||
result = download_func(*args, **kwargs)
|
||||
|
||||
# Verify downloaded file if it exists
|
||||
if os.path.exists(file_path):
|
||||
if not self.corruption_detector.is_valid_video_file(file_path):
|
||||
self.logger.warning(f"Downloaded file appears corrupted: {file_path}")
|
||||
# Remove corrupted file to force re-download
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to remove corrupted file {file_path}: {e}")
|
||||
raise DownloadError("Downloaded file is corrupted")
|
||||
|
||||
return result
|
||||
|
||||
return self.retry_mechanism.retry_with_backoff(
|
||||
download_with_verification,
|
||||
max_retries=3,
|
||||
backoff_factor=2.0,
|
||||
context=f"download_{os.path.basename(file_path)}",
|
||||
retry_on=(DownloadError, NetworkError, ConnectionError)
|
||||
)
|
||||
|
||||
|
||||
# Singleton instances
|
||||
error_recovery_manager = ErrorRecoveryManager()
|
||||
recovery_strategies = RecoveryStrategies(error_recovery_manager)
|
||||
network_health_checker = NetworkHealthChecker()
|
||||
file_corruption_detector = FileCorruptionDetector()
|
||||
|
||||
|
||||
def with_error_recovery(max_retries: int = None, context: str = None):
|
||||
"""Decorator for adding error recovery to functions."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return recovery_strategies.retry_mechanism.retry_with_backoff(
|
||||
func,
|
||||
*args,
|
||||
max_retries=max_retries,
|
||||
context=context or func.__name__,
|
||||
**kwargs
|
||||
)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_api_errors(func: Callable) -> Callable:
|
||||
"""Decorator for consistent API error handling."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except NonRetryableError as e:
|
||||
error_recovery_manager.log_error(e, f"api_{func.__name__}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Operation failed',
|
||||
'error_type': 'non_retryable',
|
||||
'retry_suggested': False
|
||||
}), 400
|
||||
except RetryableError as e:
|
||||
error_recovery_manager.log_error(e, f"api_{func.__name__}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Temporary failure, please try again',
|
||||
'error_type': 'retryable',
|
||||
'retry_suggested': True
|
||||
}), 503
|
||||
except Exception as e:
|
||||
error_recovery_manager.log_error(e, f"api_{func.__name__}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An unexpected error occurred',
|
||||
'error_type': 'unknown',
|
||||
'retry_suggested': error_recovery_manager.is_retryable_error(e)
|
||||
}), 500
|
||||
return wrapper
|
||||
|
||||
|
||||
# Export main components
|
||||
__all__ = [
|
||||
'ErrorRecoveryManager',
|
||||
'RetryMechanism',
|
||||
'NetworkHealthChecker',
|
||||
'FileCorruptionDetector',
|
||||
'RecoveryStrategies',
|
||||
'NetworkError',
|
||||
'DownloadError',
|
||||
'RetryableError',
|
||||
'NonRetryableError',
|
||||
'with_error_recovery',
|
||||
'handle_api_errors',
|
||||
'error_recovery_manager',
|
||||
'recovery_strategies',
|
||||
'network_health_checker',
|
||||
'file_corruption_detector'
|
||||
]
|
||||
474
src/server/web/middleware/keyboard_middleware.py
Normal file
474
src/server/web/middleware/keyboard_middleware.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Keyboard Shortcuts and Hotkey Management
|
||||
|
||||
This module provides keyboard shortcut functionality for the AniWorld web interface,
|
||||
including customizable hotkeys for common actions and accessibility support.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
class KeyboardShortcutManager:
|
||||
"""Manages keyboard shortcuts for the web interface."""
|
||||
|
||||
def __init__(self):
|
||||
self.shortcuts = {
|
||||
# Navigation shortcuts
|
||||
'home': ['Alt+H', 'h'],
|
||||
'search': ['Ctrl+F', 'Alt+S', '/'],
|
||||
'queue': ['Alt+Q', 'q'],
|
||||
'config': ['Alt+C', 'c'],
|
||||
'logs': ['Alt+L', 'l'],
|
||||
|
||||
# Action shortcuts
|
||||
'rescan': ['F5', 'Ctrl+R', 'r'],
|
||||
'start_download': ['Enter', 'Space', 'd'],
|
||||
'pause_download': ['Ctrl+Space', 'p'],
|
||||
'cancel_download': ['Escape', 'Ctrl+X'],
|
||||
|
||||
# Selection shortcuts
|
||||
'select_all': ['Ctrl+A', 'a'],
|
||||
'deselect_all': ['Ctrl+D', 'Escape'],
|
||||
'toggle_selection': ['Ctrl+Click', 't'],
|
||||
'next_item': ['ArrowDown', 'j'],
|
||||
'prev_item': ['ArrowUp', 'k'],
|
||||
|
||||
# Modal/Dialog shortcuts
|
||||
'close_modal': ['Escape', 'Ctrl+W'],
|
||||
'confirm_action': ['Enter', 'Ctrl+Enter'],
|
||||
'cancel_action': ['Escape', 'Ctrl+C'],
|
||||
|
||||
# View shortcuts
|
||||
'toggle_details': ['Tab', 'i'],
|
||||
'refresh_view': ['F5', 'Ctrl+R'],
|
||||
'toggle_filters': ['f'],
|
||||
'toggle_sort': ['s'],
|
||||
|
||||
# Quick actions
|
||||
'quick_help': ['F1', '?'],
|
||||
'settings': ['Ctrl+,', ','],
|
||||
'logout': ['Ctrl+Shift+L'],
|
||||
}
|
||||
|
||||
self.descriptions = {
|
||||
'home': 'Navigate to home page',
|
||||
'search': 'Focus search input',
|
||||
'queue': 'Open download queue',
|
||||
'config': 'Open configuration',
|
||||
'logs': 'View application logs',
|
||||
'rescan': 'Rescan anime collection',
|
||||
'start_download': 'Start selected downloads',
|
||||
'pause_download': 'Pause active downloads',
|
||||
'cancel_download': 'Cancel active downloads',
|
||||
'select_all': 'Select all items',
|
||||
'deselect_all': 'Deselect all items',
|
||||
'toggle_selection': 'Toggle item selection',
|
||||
'next_item': 'Navigate to next item',
|
||||
'prev_item': 'Navigate to previous item',
|
||||
'close_modal': 'Close modal dialog',
|
||||
'confirm_action': 'Confirm current action',
|
||||
'cancel_action': 'Cancel current action',
|
||||
'toggle_details': 'Toggle detailed view',
|
||||
'refresh_view': 'Refresh current view',
|
||||
'toggle_filters': 'Toggle filter panel',
|
||||
'toggle_sort': 'Change sort order',
|
||||
'quick_help': 'Show help dialog',
|
||||
'settings': 'Open settings panel',
|
||||
'logout': 'Logout from application'
|
||||
}
|
||||
|
||||
def get_shortcuts_js(self):
|
||||
"""Generate JavaScript code for keyboard shortcuts."""
|
||||
return f"""
|
||||
// AniWorld Keyboard Shortcuts Manager
|
||||
class KeyboardShortcutManager {{
|
||||
constructor() {{
|
||||
this.shortcuts = {self._format_shortcuts_for_js()};
|
||||
this.descriptions = {self._format_descriptions_for_js()};
|
||||
this.enabled = true;
|
||||
this.activeModals = [];
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
this.createHelpModal();
|
||||
this.showKeyboardHints();
|
||||
}}
|
||||
|
||||
handleKeyDown(event) {{
|
||||
if (!this.enabled) return;
|
||||
|
||||
const key = this.getKeyString(event);
|
||||
|
||||
// Check for matching shortcuts
|
||||
for (const [action, keys] of Object.entries(this.shortcuts)) {{
|
||||
if (keys.includes(key)) {{
|
||||
if (this.executeAction(action, event)) {{
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
handleKeyUp(event) {{
|
||||
// Handle key up events if needed
|
||||
}}
|
||||
|
||||
getKeyString(event) {{
|
||||
const parts = [];
|
||||
if (event.ctrlKey) parts.push('Ctrl');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
if (event.metaKey) parts.push('Meta');
|
||||
|
||||
let key = event.key;
|
||||
if (key === ' ') key = 'Space';
|
||||
|
||||
parts.push(key);
|
||||
return parts.join('+');
|
||||
}}
|
||||
|
||||
executeAction(action, event) {{
|
||||
// Prevent shortcuts in input fields unless explicitly allowed
|
||||
const allowedInInputs = ['search', 'close_modal', 'cancel_action'];
|
||||
const activeElement = document.activeElement;
|
||||
const isInputElement = activeElement && (
|
||||
activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.contentEditable === 'true'
|
||||
);
|
||||
|
||||
if (isInputElement && !allowedInInputs.includes(action)) {{
|
||||
return false;
|
||||
}}
|
||||
|
||||
switch (action) {{
|
||||
case 'home':
|
||||
window.location.href = '/';
|
||||
return true;
|
||||
|
||||
case 'search':
|
||||
const searchInput = document.querySelector('#search-input, .search-input, [data-search]');
|
||||
if (searchInput) {{
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'queue':
|
||||
window.location.href = '/queue';
|
||||
return true;
|
||||
|
||||
case 'config':
|
||||
window.location.href = '/config';
|
||||
return true;
|
||||
|
||||
case 'logs':
|
||||
window.location.href = '/logs';
|
||||
return true;
|
||||
|
||||
case 'rescan':
|
||||
const rescanBtn = document.querySelector('#rescan-btn, [data-action="rescan"]');
|
||||
if (rescanBtn && !rescanBtn.disabled) {{
|
||||
rescanBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'start_download':
|
||||
const downloadBtn = document.querySelector('#download-btn, [data-action="download"]');
|
||||
if (downloadBtn && !downloadBtn.disabled) {{
|
||||
downloadBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'pause_download':
|
||||
const pauseBtn = document.querySelector('#pause-btn, [data-action="pause"]');
|
||||
if (pauseBtn && !pauseBtn.disabled) {{
|
||||
pauseBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'cancel_download':
|
||||
const cancelBtn = document.querySelector('#cancel-btn, [data-action="cancel"]');
|
||||
if (cancelBtn && !cancelBtn.disabled) {{
|
||||
cancelBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'select_all':
|
||||
const selectAllBtn = document.querySelector('#select-all-btn, [data-action="select-all"]');
|
||||
if (selectAllBtn) {{
|
||||
selectAllBtn.click();
|
||||
}} else {{
|
||||
this.selectAllItems();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'deselect_all':
|
||||
const deselectAllBtn = document.querySelector('#deselect-all-btn, [data-action="deselect-all"]');
|
||||
if (deselectAllBtn) {{
|
||||
deselectAllBtn.click();
|
||||
}} else {{
|
||||
this.deselectAllItems();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'next_item':
|
||||
this.navigateItems('next');
|
||||
return true;
|
||||
|
||||
case 'prev_item':
|
||||
this.navigateItems('prev');
|
||||
return true;
|
||||
|
||||
case 'close_modal':
|
||||
this.closeTopModal();
|
||||
return true;
|
||||
|
||||
case 'confirm_action':
|
||||
const confirmBtn = document.querySelector('.modal.show .btn-primary, .modal.show [data-confirm]');
|
||||
if (confirmBtn) {{
|
||||
confirmBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'cancel_action':
|
||||
const cancelActionBtn = document.querySelector('.modal.show .btn-secondary, .modal.show [data-cancel]');
|
||||
if (cancelActionBtn) {{
|
||||
cancelActionBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'toggle_details':
|
||||
this.toggleDetailView();
|
||||
return true;
|
||||
|
||||
case 'refresh_view':
|
||||
window.location.reload();
|
||||
return true;
|
||||
|
||||
case 'toggle_filters':
|
||||
const filterPanel = document.querySelector('#filter-panel, .filters');
|
||||
if (filterPanel) {{
|
||||
filterPanel.classList.toggle('show');
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'toggle_sort':
|
||||
const sortBtn = document.querySelector('#sort-btn, [data-action="sort"]');
|
||||
if (sortBtn) {{
|
||||
sortBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'quick_help':
|
||||
this.showHelpModal();
|
||||
return true;
|
||||
|
||||
case 'settings':
|
||||
const settingsBtn = document.querySelector('#settings-btn, [data-action="settings"]');
|
||||
if (settingsBtn) {{
|
||||
settingsBtn.click();
|
||||
}} else {{
|
||||
window.location.href = '/config';
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'logout':
|
||||
if (confirm('Are you sure you want to logout?')) {{
|
||||
window.location.href = '/logout';
|
||||
}}
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
|
||||
selectAllItems() {{
|
||||
const checkboxes = document.querySelectorAll('.series-checkbox, [data-selectable]');
|
||||
checkboxes.forEach(cb => {{
|
||||
if (cb.type === 'checkbox') {{
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
}} else {{
|
||||
cb.classList.add('selected');
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
deselectAllItems() {{
|
||||
const checkboxes = document.querySelectorAll('.series-checkbox, [data-selectable]');
|
||||
checkboxes.forEach(cb => {{
|
||||
if (cb.type === 'checkbox') {{
|
||||
cb.checked = false;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
}} else {{
|
||||
cb.classList.remove('selected');
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
navigateItems(direction) {{
|
||||
const items = document.querySelectorAll('.series-item, .list-item, [data-navigable]');
|
||||
const currentIndex = Array.from(items).findIndex(item =>
|
||||
item.classList.contains('focused') || item.classList.contains('active')
|
||||
);
|
||||
|
||||
let newIndex;
|
||||
if (direction === 'next') {{
|
||||
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
}} else {{
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
}}
|
||||
|
||||
// Remove focus from current item
|
||||
if (currentIndex >= 0) {{
|
||||
items[currentIndex].classList.remove('focused', 'active');
|
||||
}}
|
||||
|
||||
// Add focus to new item
|
||||
if (items[newIndex]) {{
|
||||
items[newIndex].classList.add('focused');
|
||||
items[newIndex].scrollIntoView({{ block: 'center' }});
|
||||
}}
|
||||
}}
|
||||
|
||||
closeTopModal() {{
|
||||
const modals = document.querySelectorAll('.modal.show');
|
||||
if (modals.length > 0) {{
|
||||
const topModal = modals[modals.length - 1];
|
||||
const closeBtn = topModal.querySelector('.btn-close, [data-bs-dismiss="modal"]');
|
||||
if (closeBtn) {{
|
||||
closeBtn.click();
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
toggleDetailView() {{
|
||||
const detailToggle = document.querySelector('[data-toggle="details"]');
|
||||
if (detailToggle) {{
|
||||
detailToggle.click();
|
||||
}} else {{
|
||||
document.body.classList.toggle('detailed-view');
|
||||
}}
|
||||
}}
|
||||
|
||||
createHelpModal() {{
|
||||
const helpModal = document.createElement('div');
|
||||
helpModal.className = 'modal fade';
|
||||
helpModal.id = 'keyboard-help-modal';
|
||||
helpModal.innerHTML = `
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Keyboard Shortcuts</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${{this.generateHelpContent()}}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(helpModal);
|
||||
}}
|
||||
|
||||
generateHelpContent() {{
|
||||
let html = '<div class="row">';
|
||||
const categories = {{
|
||||
'Navigation': ['home', 'search', 'queue', 'config', 'logs'],
|
||||
'Actions': ['rescan', 'start_download', 'pause_download', 'cancel_download'],
|
||||
'Selection': ['select_all', 'deselect_all', 'next_item', 'prev_item'],
|
||||
'View': ['toggle_details', 'refresh_view', 'toggle_filters', 'toggle_sort'],
|
||||
'General': ['quick_help', 'settings', 'logout']
|
||||
}};
|
||||
|
||||
Object.entries(categories).forEach(([category, actions]) => {{
|
||||
html += `<div class="col-md-6 mb-4">
|
||||
<h6>${{category}}</h6>
|
||||
<table class="table table-sm">`;
|
||||
|
||||
actions.forEach(action => {{
|
||||
const shortcuts = this.shortcuts[action] || [];
|
||||
const description = this.descriptions[action] || action;
|
||||
html += `<tr>
|
||||
<td><code>${{shortcuts.join('</code> or <code>')}}</code></td>
|
||||
<td>${{description}}</td>
|
||||
</tr>`;
|
||||
}});
|
||||
|
||||
html += '</table></div>';
|
||||
}});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}}
|
||||
|
||||
showHelpModal() {{
|
||||
const helpModal = new bootstrap.Modal(document.getElementById('keyboard-help-modal'));
|
||||
helpModal.show();
|
||||
}}
|
||||
|
||||
showKeyboardHints() {{
|
||||
// Add keyboard hint tooltips to buttons
|
||||
document.querySelectorAll('[data-action]').forEach(btn => {{
|
||||
const action = btn.dataset.action;
|
||||
const shortcuts = this.shortcuts[action];
|
||||
if (shortcuts && shortcuts.length > 0) {{
|
||||
const shortcut = shortcuts[0];
|
||||
const currentTitle = btn.title || '';
|
||||
btn.title = currentTitle + (currentTitle ? ' ' : '') + `(${{shortcut}})`;
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
enable() {{
|
||||
this.enabled = true;
|
||||
}}
|
||||
|
||||
disable() {{
|
||||
this.enabled = false;
|
||||
}}
|
||||
|
||||
setEnabled(enabled) {{
|
||||
this.enabled = enabled;
|
||||
}}
|
||||
|
||||
updateShortcuts(newShortcuts) {{
|
||||
if (newShortcuts && typeof newShortcuts === 'object') {{
|
||||
Object.assign(this.shortcuts, newShortcuts);
|
||||
}}
|
||||
}}
|
||||
|
||||
addCustomShortcut(action, keys, callback) {{
|
||||
this.shortcuts[action] = Array.isArray(keys) ? keys : [keys];
|
||||
this.customCallbacks = this.customCallbacks || {{}};
|
||||
this.customCallbacks[action] = callback;
|
||||
}}
|
||||
}}
|
||||
|
||||
// Initialize keyboard shortcuts when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
window.keyboardManager = new KeyboardShortcutManager();
|
||||
}});
|
||||
"""
|
||||
|
||||
def _format_shortcuts_for_js(self):
|
||||
"""Format shortcuts dictionary for JavaScript."""
|
||||
import json
|
||||
return json.dumps(self.shortcuts)
|
||||
|
||||
def _format_descriptions_for_js(self):
|
||||
"""Format descriptions dictionary for JavaScript."""
|
||||
import json
|
||||
return json.dumps(self.descriptions)
|
||||
|
||||
|
||||
# Export the keyboard shortcut manager
|
||||
keyboard_manager = KeyboardShortcutManager()
|
||||
1048
src/server/web/middleware/mobile_middleware.py
Normal file
1048
src/server/web/middleware/mobile_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1334
src/server/web/middleware/multi_screen_middleware.py
Normal file
1334
src/server/web/middleware/multi_screen_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1667
src/server/web/middleware/screen_reader_middleware.py
Normal file
1667
src/server/web/middleware/screen_reader_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1244
src/server/web/middleware/touch_middleware.py
Normal file
1244
src/server/web/middleware/touch_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
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
0
src/server/web/templates/__init__.py
Normal file
0
src/server/web/templates/__init__.py
Normal file
0
src/server/web/templates/admin/__init__.py
Normal file
0
src/server/web/templates/admin/__init__.py
Normal file
0
src/server/web/templates/anime/__init__.py
Normal file
0
src/server/web/templates/anime/__init__.py
Normal file
0
src/server/web/templates/auth/__init__.py
Normal file
0
src/server/web/templates/auth/__init__.py
Normal file
0
src/server/web/templates/base/__init__.py
Normal file
0
src/server/web/templates/base/__init__.py
Normal file
491
src/server/web/templates/base/index.html
Normal file
491
src/server/web/templates/base/index.html
Normal file
@@ -0,0 +1,491 @@
|
||||
<!DOCTYPE html>
|
||||
<html <div class="header-actions">
|
||||
<a href="/queue" class="btn btn-secondary" title="Download Queue">
|
||||
<i class="fas fa-list-alt"></i>
|
||||
<span data-text="queue">Queue</span>
|
||||
</a>
|
||||
<button id="config-btn" class="btn btn-secondary" title="Show configuration">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span data-text="config-title">Config</span>
|
||||
</button>
|
||||
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme" data-title="toggle-theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
<button id="logout-btn" class="btn btn-secondary" title="Logout" style="display: none;">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span data-text="logout">Logout</span>
|
||||
</button>
|
||||
<button id="rescan-btn" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span data-text="rescan">Rescan</span>
|
||||
</button>
|
||||
</div>ta-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager</title>
|
||||
<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">
|
||||
|
||||
<!-- UX Enhancement and Mobile & Accessibility CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('ux_features_css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
<h1>AniWorld Manager</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- Process Status Indicators -->
|
||||
<div class="process-status" id="process-status">
|
||||
<div class="status-indicator" id="rescan-status">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span class="status-text">Scan</span>
|
||||
<div class="status-dot idle"></div>
|
||||
</div>
|
||||
<div class="status-indicator" id="download-status">
|
||||
<i class="fas fa-download"></i>
|
||||
<span class="status-text">Download</span>
|
||||
<div class="status-dot idle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/queue" class="btn btn-secondary" title="Download Queue">
|
||||
<i class="fas fa-list-alt"></i>
|
||||
<span data-text="queue">Queue</span>
|
||||
</a>
|
||||
|
||||
<button id="logout-btn" class="btn btn-secondary" title="Logout" style="display: none;">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span data-text="logout">Logout</span>
|
||||
</button>
|
||||
<button id="config-btn" class="btn btn-secondary" title="Show configuration">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span data-text="config-title">Config</span>
|
||||
</button>
|
||||
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme" data-title="toggle-theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
<button id="rescan-btn" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span data-text="rescan">Rescan</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content">
|
||||
<!-- Search section -->
|
||||
<section class="search-section">
|
||||
<div class="search-container">
|
||||
<div class="search-input-group">
|
||||
<input type="text" id="search-input" data-text="search-placeholder" placeholder="Search for anime..." class="search-input">
|
||||
<button id="search-btn" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
<button id="clear-search" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="search-results" class="search-results hidden">
|
||||
<h3>Search Results</h3>
|
||||
<div id="search-results-list" class="search-results-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Download Queue Section -->
|
||||
<section id="download-queue-section" class="download-queue-section hidden">
|
||||
<div class="queue-header">
|
||||
<h2>
|
||||
<i class="fas fa-download"></i>
|
||||
<span data-text="download-queue">Download Queue</span>
|
||||
</h2>
|
||||
<div class="queue-stats">
|
||||
<span id="queue-progress" class="queue-progress">0/0 series</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Download -->
|
||||
<div id="current-download" class="current-download hidden">
|
||||
<div class="current-download-header">
|
||||
<h3 data-text="currently-downloading">Currently Downloading</h3>
|
||||
</div>
|
||||
<div class="current-download-item">
|
||||
<div class="download-info">
|
||||
<div id="current-serie-name" class="serie-name">-</div>
|
||||
<div id="current-episode" class="episode-info">-</div>
|
||||
</div>
|
||||
<div class="download-progress">
|
||||
<div class="progress-bar-mini">
|
||||
<div id="current-progress-fill" class="progress-fill-mini"></div>
|
||||
</div>
|
||||
<div id="current-progress-text" class="progress-text-mini">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue List -->
|
||||
<div id="queue-list-container" class="queue-list-container">
|
||||
<h3 data-text="queued-series">Queued Series</h3>
|
||||
<div id="queue-list" class="queue-list">
|
||||
<!-- Queue items will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Series management section -->
|
||||
<section class="series-section">
|
||||
<div class="series-header">
|
||||
<h2 data-text="series-collection">Series Collection</h2>
|
||||
<div class="series-filters">
|
||||
<button id="show-missing-only" class="btn btn-secondary" data-active="false">
|
||||
<i class="fas fa-filter"></i>
|
||||
<span data-text="show-missing-only">Missing Episodes Only</span>
|
||||
</button>
|
||||
<button id="sort-alphabetical" class="btn btn-secondary" data-active="false">
|
||||
<i class="fas fa-sort-alpha-down"></i>
|
||||
<span data-text="sort-alphabetical">A-Z Sort</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="series-actions">
|
||||
<button id="select-all" class="btn btn-secondary">
|
||||
<i class="fas fa-check-double"></i>
|
||||
<span data-text="select-all">Select All</span>
|
||||
</button>
|
||||
<button id="download-selected" class="btn btn-success" disabled>
|
||||
<i class="fas fa-download"></i>
|
||||
<span data-text="download-selected">Download Selected</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series grid -->
|
||||
<div id="series-grid" class="series-grid">
|
||||
<!-- Series cards will be populated here -->
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Status panel -->
|
||||
<div id="status-panel" class="status-panel hidden">
|
||||
<div class="status-header">
|
||||
<h3 id="status-title">Status</h3>
|
||||
<button id="close-status" class="btn btn-icon">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-content">
|
||||
<div id="status-message" class="status-message"></div>
|
||||
<div id="progress-container" class="progress-container hidden">
|
||||
<div class="progress-bar">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<div id="progress-text" class="progress-text">0%</div>
|
||||
</div>
|
||||
<div id="download-controls" class="download-controls hidden">
|
||||
<button id="pause-download" class="btn btn-secondary btn-small">
|
||||
<i class="fas fa-pause"></i>
|
||||
<span data-text="pause">Pause</span>
|
||||
</button>
|
||||
<button id="resume-download" class="btn btn-primary btn-small hidden">
|
||||
<i class="fas fa-play"></i>
|
||||
<span data-text="resume">Resume</span>
|
||||
</button>
|
||||
<button id="cancel-download" class="btn btn-small" style="background-color: var(--color-error); color: white;">
|
||||
<i class="fas fa-stop"></i>
|
||||
<span data-text="cancel">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Modal -->
|
||||
<div id="config-modal" class="modal hidden">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 data-text="config-title">Configuration</h3>
|
||||
<button id="close-config" class="btn btn-icon">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="config-item">
|
||||
<label for="anime-directory-input" data-text="anime-directory">Anime Directory:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="anime-directory-input" class="input-field" placeholder="Enter anime directory path...">
|
||||
<button id="browse-directory" class="btn btn-secondary">
|
||||
<i class="fas fa-folder"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="series-count-input" data-text="series-count">Series Count:</label>
|
||||
<input type="number" id="series-count-input" class="input-field" readonly title="This value is automatically calculated">
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label data-text="connection-status">Connection Status:</label>
|
||||
<div id="connection-status-display" class="config-value">
|
||||
<span class="status-indicator"></span>
|
||||
<span class="status-text">Disconnected</span>
|
||||
</div>
|
||||
<button id="test-connection" class="btn btn-secondary">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
<span data-text="test-connection">Test Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Configuration Actions -->
|
||||
<div class="config-actions">
|
||||
<button id="save-main-config" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span data-text="save-main-config">Save Configuration</span>
|
||||
</button>
|
||||
<button id="reset-main-config" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span data-text="reset-main-config">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler Configuration -->
|
||||
<div class="config-section">
|
||||
<h4 data-text="scheduler-config">Scheduled Operations</h4>
|
||||
|
||||
<div class="config-item">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="scheduled-rescan-enabled">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span data-text="enable-scheduled-rescan">Enable Daily Rescan</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-item" id="rescan-time-config">
|
||||
<label for="scheduled-rescan-time" data-text="rescan-time">Rescan Time (24h format):</label>
|
||||
<input type="time" id="scheduled-rescan-time" value="03:00" class="input-field">
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="auto-download-after-rescan">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span data-text="auto-download-after-rescan">Auto-download missing episodes after rescan</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-item scheduler-status" id="scheduler-status">
|
||||
<div class="scheduler-info">
|
||||
<div class="info-row">
|
||||
<span data-text="next-rescan">Next Scheduled Rescan:</span>
|
||||
<span id="next-rescan-time" class="info-value">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span data-text="last-rescan">Last Scheduled Rescan:</span>
|
||||
<span id="last-rescan-time" class="info-value">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span data-text="scheduler-running">Scheduler Status:</span>
|
||||
<span id="scheduler-running-status" class="info-value status-badge">Stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<button id="save-scheduler-config" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span data-text="save-config">Save Configuration</span>
|
||||
</button>
|
||||
<button id="test-scheduled-rescan" class="btn btn-secondary">
|
||||
<i class="fas fa-play"></i>
|
||||
<span data-text="test-rescan">Test Rescan Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logging Configuration -->
|
||||
<div class="config-section">
|
||||
<h4 data-text="logging-config">Logging Configuration</h4>
|
||||
|
||||
<div class="config-item">
|
||||
<label for="log-level" data-text="log-level">Log Level:</label>
|
||||
<select id="log-level" class="input-field">
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<div class="checkbox-container">
|
||||
<input type="checkbox" id="enable-console-logging">
|
||||
<label for="enable-console-logging">
|
||||
<span data-text="enable-console-logging">Enable Console Logging</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<div class="checkbox-container">
|
||||
<input type="checkbox" id="enable-console-progress">
|
||||
<label for="enable-console-progress">
|
||||
<span data-text="enable-console-progress">Show Progress Bars in Console</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<div class="checkbox-container">
|
||||
<input type="checkbox" id="enable-fail2ban-logging">
|
||||
<label for="enable-fail2ban-logging">
|
||||
<span data-text="enable-fail2ban-logging">Enable Fail2Ban Logging</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5 data-text="log-files">Log Files</h5>
|
||||
<div id="log-files-list" class="log-files-container">
|
||||
<!-- Log files will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<button id="save-logging-config" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span data-text="save-logging-config">Save Logging Config</span>
|
||||
</button>
|
||||
<button id="test-logging" class="btn btn-secondary">
|
||||
<i class="fas fa-bug"></i>
|
||||
<span data-text="test-logging">Test Logging</span>
|
||||
</button>
|
||||
<button id="refresh-log-files" class="btn btn-secondary">
|
||||
<i class="fas fa-refresh"></i>
|
||||
<span data-text="refresh-logs">Refresh Log Files</span>
|
||||
</button>
|
||||
<button id="cleanup-logs" class="btn btn-warning">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span data-text="cleanup-logs">Cleanup Old Logs</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Management -->
|
||||
<div class="config-section">
|
||||
<h4 data-text="config-management">Configuration Management</h4>
|
||||
|
||||
<div class="config-item">
|
||||
<h5 data-text="config-backup-restore">Backup & Restore</h5>
|
||||
<p class="config-description" data-text="backup-description">
|
||||
Create backups of your configuration or restore from previous backups.
|
||||
</p>
|
||||
|
||||
<div class="config-actions">
|
||||
<button id="create-config-backup" class="btn btn-secondary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span data-text="create-backup">Create Backup</span>
|
||||
</button>
|
||||
<button id="view-config-backups" class="btn btn-secondary">
|
||||
<i class="fas fa-history"></i>
|
||||
<span data-text="view-backups">View Backups</span>
|
||||
</button>
|
||||
<button id="export-config" class="btn btn-secondary">
|
||||
<i class="fas fa-download"></i>
|
||||
<span data-text="export-config">Export Config</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5 data-text="config-validation">Configuration Validation</h5>
|
||||
<p class="config-description" data-text="validation-description">
|
||||
Validate your current configuration for errors and warnings.
|
||||
</p>
|
||||
|
||||
<div id="validation-results" class="validation-results hidden">
|
||||
<!-- Validation results will be displayed here -->
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<button id="validate-config" class="btn btn-primary">
|
||||
<i class="fas fa-check"></i>
|
||||
<span data-text="validate-config">Validate Configuration</span>
|
||||
</button>
|
||||
<button id="reset-config" class="btn btn-warning">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span data-text="reset-config">Reset to Defaults</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<h5 data-text="advanced-config">Advanced Settings</h5>
|
||||
|
||||
<label for="max-concurrent-downloads" data-text="max-downloads">Max Concurrent Downloads:</label>
|
||||
<input type="number" id="max-concurrent-downloads" min="1" max="20" value="3" class="input-field">
|
||||
|
||||
<label for="provider-timeout" data-text="provider-timeout">Provider Timeout (seconds):</label>
|
||||
<input type="number" id="provider-timeout" min="5" max="300" value="30" class="input-field">
|
||||
|
||||
<div class="checkbox-container">
|
||||
<input type="checkbox" id="enable-debug-mode">
|
||||
<label for="enable-debug-mode">
|
||||
<span data-text="enable-debug">Enable Debug Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<button id="save-advanced-config" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span data-text="save-advanced">Save Advanced Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="loading-overlay" class="loading-overlay hidden">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/localization.js') }}"></script>
|
||||
|
||||
<!-- UX Enhancement Scripts -->
|
||||
<script src="{{ url_for('keyboard_shortcuts_js') }}"></script>
|
||||
<script src="{{ url_for('drag_drop_js') }}"></script>
|
||||
<script src="{{ url_for('bulk_operations_js') }}"></script>
|
||||
<script src="{{ url_for('user_preferences_js') }}"></script>
|
||||
<script src="{{ url_for('advanced_search_js') }}"></script>
|
||||
<script src="{{ url_for('undo_redo_js') }}"></script>
|
||||
|
||||
<!-- Mobile & Accessibility Scripts -->
|
||||
<script src="{{ url_for('mobile_responsive_js') }}"></script>
|
||||
<script src="{{ url_for('touch_gestures_js') }}"></script>
|
||||
<script src="{{ url_for('accessibility_features_js') }}"></script>
|
||||
<script src="{{ url_for('screen_reader_support_js') }}"></script>
|
||||
<script src="{{ url_for('color_contrast_compliance_js') }}"></script>
|
||||
<script src="{{ url_for('multi_screen_support_js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
380
src/server/web/templates/base/login.html
Normal file
380
src/server/web/templates/base/login.html
Normal file
@@ -0,0 +1,380 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Login</title>
|
||||
<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">
|
||||
<style>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-header .logo {
|
||||
font-size: 3rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 3rem 0.75rem 1rem;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.password-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-error-light);
|
||||
color: var(--color-error);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-error);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-success);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.security-info {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-info-light);
|
||||
border: 1px solid var(--color-info);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</div>
|
||||
<h1>AniWorld Manager</h1>
|
||||
<p>Please enter your master password to continue</p>
|
||||
</div>
|
||||
|
||||
<form class="login-form" id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Master Password</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="password-input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus>
|
||||
<button type="button" class="password-toggle" id="password-toggle" tabindex="-1">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="message-container"></div>
|
||||
|
||||
<button type="submit" class="login-button" id="login-button">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span>Login</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="security-info">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
Your session will expire after {{ session_timeout }} hours of inactivity.
|
||||
<br>
|
||||
After {{ max_attempts }} failed attempts, this IP will be locked for {{ lockout_duration }} minutes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle functionality
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
htmlElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = htmlElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
htmlElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
});
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = themeToggle.querySelector('i');
|
||||
icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
|
||||
}
|
||||
|
||||
// Password visibility toggle
|
||||
const passwordToggle = document.getElementById('password-toggle');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
passwordToggle.addEventListener('click', () => {
|
||||
const type = passwordInput.getAttribute('type');
|
||||
const newType = type === 'password' ? 'text' : 'password';
|
||||
const icon = passwordToggle.querySelector('i');
|
||||
|
||||
passwordInput.setAttribute('type', newType);
|
||||
icon.className = newType === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
|
||||
});
|
||||
|
||||
// Form submission
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const password = passwordInput.value.trim();
|
||||
if (!password) {
|
||||
showMessage('Please enter your password', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
showMessage(data.message, 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
passwordInput.value = '';
|
||||
passwordInput.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Connection error. Please try again.', 'error');
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function showMessage(message, type) {
|
||||
messageContainer.innerHTML = `
|
||||
<div class="${type}-message">
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
loginButton.disabled = loading;
|
||||
const buttonText = loginButton.querySelector('span');
|
||||
const buttonIcon = loginButton.querySelector('i');
|
||||
|
||||
if (loading) {
|
||||
buttonIcon.className = 'loading-spinner';
|
||||
buttonText.textContent = 'Logging in...';
|
||||
} else {
|
||||
buttonIcon.className = 'fas fa-sign-in-alt';
|
||||
buttonText.textContent = 'Login';
|
||||
}
|
||||
}
|
||||
|
||||
// Clear message on input
|
||||
passwordInput.addEventListener('input', () => {
|
||||
messageContainer.innerHTML = '';
|
||||
});
|
||||
|
||||
// Enter key on password toggle
|
||||
passwordToggle.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
passwordToggle.click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
241
src/server/web/templates/base/queue.html
Normal file
241
src/server/web/templates/base/queue.html
Normal file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Download Queue - AniWorld Manager</title>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
<i class="fas fa-download"></i>
|
||||
<h1>Download Queue</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Back to Main</span>
|
||||
</a>
|
||||
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
<button id="logout-btn" class="btn btn-secondary" title="Logout" style="display: none;">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content">
|
||||
<!-- Queue Statistics -->
|
||||
<section class="queue-stats-section">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-download text-primary"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="total-items">0</div>
|
||||
<div class="stat-label">Total Items</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-clock text-warning"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="pending-items">0</div>
|
||||
<div class="stat-label">In Queue</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="completed-items">0</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-exclamation-triangle text-error"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="failed-items">0</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speed and ETA -->
|
||||
<div class="speed-eta-section">
|
||||
<div class="speed-info">
|
||||
<div class="speed-current">
|
||||
<span class="label">Current Speed:</span>
|
||||
<span class="value" id="current-speed">0 MB/s</span>
|
||||
</div>
|
||||
<div class="speed-average">
|
||||
<span class="label">Average Speed:</span>
|
||||
<span class="value" id="average-speed">0 MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eta-info">
|
||||
<span class="label">Estimated Time Remaining:</span>
|
||||
<span class="value" id="eta-time">--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Downloads -->
|
||||
<section class="active-downloads-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<i class="fas fa-play-circle"></i>
|
||||
Active Downloads
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button id="pause-all-btn" class="btn btn-secondary" disabled>
|
||||
<i class="fas fa-pause"></i>
|
||||
Pause All
|
||||
</button>
|
||||
<button id="resume-all-btn" class="btn btn-primary" disabled style="display: none;">
|
||||
<i class="fas fa-play"></i>
|
||||
Resume All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="active-downloads-list" id="active-downloads">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-pause-circle"></i>
|
||||
<p>No active downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pending Queue -->
|
||||
<section class="pending-queue-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<i class="fas fa-clock"></i>
|
||||
Download Queue
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button id="clear-queue-btn" class="btn btn-secondary" disabled>
|
||||
<i class="fas fa-trash"></i>
|
||||
Clear Queue
|
||||
</button>
|
||||
<button id="reorder-queue-btn" class="btn btn-secondary" disabled>
|
||||
<i class="fas fa-sort"></i>
|
||||
Reorder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pending-queue-list" id="pending-queue">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-list"></i>
|
||||
<p>No items in queue</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Completed Downloads -->
|
||||
<section class="completed-downloads-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Recent Completed
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button id="clear-completed-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-broom"></i>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="completed-downloads-list" id="completed-downloads">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<p>No completed downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Failed Downloads -->
|
||||
<section class="failed-downloads-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Failed Downloads
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button id="retry-all-btn" class="btn btn-warning" disabled>
|
||||
<i class="fas fa-redo"></i>
|
||||
Retry All
|
||||
</button>
|
||||
<button id="clear-failed-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-trash"></i>
|
||||
Clear Failed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="failed-downloads-list" id="failed-downloads">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
<p>No failed downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="loading-overlay" class="loading-overlay hidden">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-modal" class="modal hidden">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="confirm-title">Confirm Action</h3>
|
||||
<button id="close-confirm" class="btn btn-icon">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirm-message">Are you sure you want to perform this action?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="confirm-cancel" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirm-ok" class="btn btn-primary">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
563
src/server/web/templates/base/setup.html
Normal file
563
src/server/web/templates/base/setup.html
Normal file
@@ -0,0 +1,563 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Setup</title>
|
||||
<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">
|
||||
<style>
|
||||
.setup-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setup-header .logo {
|
||||
font-size: 3rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.setup-header h1 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-header p {
|
||||
margin: 1rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.strength-bar.active.weak { background: var(--color-error); }
|
||||
.strength-bar.active.fair { background: var(--color-warning); }
|
||||
.strength-bar.active.good { background: var(--color-info); }
|
||||
.strength-bar.active.strong { background: var(--color-success); }
|
||||
|
||||
.strength-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setup-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.setup-button:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.setup-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-error-light);
|
||||
color: var(--color-error);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-error);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-success);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-info-light);
|
||||
border: 1px solid var(--color-info);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.security-tips h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--color-info);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.security-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-container">
|
||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<div class="logo">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</div>
|
||||
<h1>Welcome to AniWorld Manager</h1>
|
||||
<p>Let's set up your master password to secure your anime collection.</p>
|
||||
</div>
|
||||
|
||||
<form class="setup-form" id="setup-form">
|
||||
<div class="form-group">
|
||||
<label for="directory" class="form-label">Anime Directory</label>
|
||||
<input
|
||||
type="text"
|
||||
id="directory"
|
||||
name="directory"
|
||||
class="form-input"
|
||||
placeholder="C:\Anime"
|
||||
value="{{ current_directory }}"
|
||||
required>
|
||||
<div class="form-help">
|
||||
The directory where your anime series are stored. This can be changed later in settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Master Password</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-input password-input"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
minlength="8">
|
||||
<button type="button" class="password-toggle" id="password-toggle" tabindex="-1">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="password-strength">
|
||||
<div class="strength-bar" id="strength-1"></div>
|
||||
<div class="strength-bar" id="strength-2"></div>
|
||||
<div class="strength-bar" id="strength-3"></div>
|
||||
<div class="strength-bar" id="strength-4"></div>
|
||||
</div>
|
||||
<div class="strength-text" id="strength-text">Password strength will be shown here</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm-password" class="form-label">Confirm Password</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
class="form-input password-input"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
minlength="8">
|
||||
<button type="button" class="password-toggle" id="confirm-password-toggle" tabindex="-1">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="message-container"></div>
|
||||
|
||||
<button type="submit" class="setup-button" id="setup-button">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Complete Setup</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="security-tips">
|
||||
<h4><i class="fas fa-shield-alt"></i> Security Tips</h4>
|
||||
<ul>
|
||||
<li>Use a password with at least 12 characters</li>
|
||||
<li>Include uppercase, lowercase, numbers, and symbols</li>
|
||||
<li>Don't use personal information or common words</li>
|
||||
<li>Consider using a password manager</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle functionality
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
htmlElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = htmlElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
htmlElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
});
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = themeToggle.querySelector('i');
|
||||
icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
|
||||
}
|
||||
|
||||
// Password visibility toggles
|
||||
document.querySelectorAll('.password-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', () => {
|
||||
const input = toggle.parentElement.querySelector('input');
|
||||
const type = input.getAttribute('type');
|
||||
const newType = type === 'password' ? 'text' : 'password';
|
||||
const icon = toggle.querySelector('i');
|
||||
|
||||
input.setAttribute('type', newType);
|
||||
icon.className = newType === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
|
||||
});
|
||||
});
|
||||
|
||||
// Password strength checker
|
||||
const passwordInput = document.getElementById('password');
|
||||
const strengthBars = document.querySelectorAll('.strength-bar');
|
||||
const strengthText = document.getElementById('strength-text');
|
||||
|
||||
passwordInput.addEventListener('input', () => {
|
||||
const password = passwordInput.value;
|
||||
const strength = calculatePasswordStrength(password);
|
||||
updatePasswordStrength(strength);
|
||||
});
|
||||
|
||||
function calculatePasswordStrength(password) {
|
||||
let score = 0;
|
||||
let feedback = [];
|
||||
|
||||
// Length check
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
|
||||
// Character variety
|
||||
if (/[a-z]/.test(password)) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
|
||||
// Penalties
|
||||
if (password.length < 8) {
|
||||
feedback.push('Too short');
|
||||
score = Math.max(0, score - 2);
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) feedback.push('Add uppercase');
|
||||
if (!/[0-9]/.test(password)) feedback.push('Add numbers');
|
||||
if (!/[^A-Za-z0-9]/.test(password)) feedback.push('Add symbols');
|
||||
|
||||
const strengthLevels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];
|
||||
const strengthLevel = Math.min(Math.floor(score / 1.2), 5);
|
||||
|
||||
return {
|
||||
score: Math.min(score, 6),
|
||||
level: strengthLevel,
|
||||
text: strengthLevels[strengthLevel],
|
||||
feedback
|
||||
};
|
||||
}
|
||||
|
||||
function updatePasswordStrength(strength) {
|
||||
const colors = ['weak', 'weak', 'fair', 'good', 'strong', 'strong'];
|
||||
const color = colors[strength.level];
|
||||
|
||||
strengthBars.forEach((bar, index) => {
|
||||
bar.className = 'strength-bar';
|
||||
if (index < strength.score) {
|
||||
bar.classList.add('active', color);
|
||||
}
|
||||
});
|
||||
|
||||
if (passwordInput.value) {
|
||||
let text = `Password strength: ${strength.text}`;
|
||||
if (strength.feedback.length > 0) {
|
||||
text += ` (${strength.feedback.join(', ')})`;
|
||||
}
|
||||
strengthText.textContent = text;
|
||||
strengthText.style.color = strength.level >= 3 ? 'var(--color-success)' : 'var(--color-warning)';
|
||||
} else {
|
||||
strengthText.textContent = 'Password strength will be shown here';
|
||||
strengthText.style.color = 'var(--color-text-secondary)';
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission
|
||||
const setupForm = document.getElementById('setup-form');
|
||||
const setupButton = document.getElementById('setup-button');
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
const confirmPasswordInput = document.getElementById('confirm-password');
|
||||
const directoryInput = document.getElementById('directory');
|
||||
|
||||
// Real-time password confirmation
|
||||
confirmPasswordInput.addEventListener('input', validatePasswordMatch);
|
||||
passwordInput.addEventListener('input', validatePasswordMatch);
|
||||
|
||||
function validatePasswordMatch() {
|
||||
const password = passwordInput.value;
|
||||
const confirmPassword = confirmPasswordInput.value;
|
||||
|
||||
if (confirmPassword && password !== confirmPassword) {
|
||||
confirmPasswordInput.setCustomValidity('Passwords do not match');
|
||||
confirmPasswordInput.style.borderColor = 'var(--color-error)';
|
||||
} else {
|
||||
confirmPasswordInput.setCustomValidity('');
|
||||
confirmPasswordInput.style.borderColor = 'var(--color-border)';
|
||||
}
|
||||
}
|
||||
|
||||
setupForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const password = passwordInput.value;
|
||||
const confirmPassword = confirmPasswordInput.value;
|
||||
const directory = directoryInput.value.trim();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showMessage('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const strength = calculatePasswordStrength(password);
|
||||
if (strength.level < 2) {
|
||||
showMessage('Password is too weak. Please use a stronger password.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!directory) {
|
||||
showMessage('Please enter a valid anime directory', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
directory
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
showMessage('Setup completed successfully! Redirecting...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Setup failed. Please try again.', 'error');
|
||||
console.error('Setup error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function showMessage(message, type) {
|
||||
messageContainer.innerHTML = `
|
||||
<div class="${type}-message">
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
setupButton.disabled = loading;
|
||||
const buttonText = setupButton.querySelector('span');
|
||||
const buttonIcon = setupButton.querySelector('i');
|
||||
|
||||
if (loading) {
|
||||
buttonIcon.className = 'loading-spinner';
|
||||
buttonText.textContent = 'Setting up...';
|
||||
} else {
|
||||
buttonIcon.className = 'fas fa-check';
|
||||
buttonText.textContent = 'Complete Setup';
|
||||
}
|
||||
}
|
||||
|
||||
// Clear message on input
|
||||
[passwordInput, confirmPasswordInput, directoryInput].forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
messageContainer.innerHTML = '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
0
src/server/web/templates/config/__init__.py
Normal file
0
src/server/web/templates/config/__init__.py
Normal file
0
src/server/web/templates/downloads/__init__.py
Normal file
0
src/server/web/templates/downloads/__init__.py
Normal file
0
src/server/web/templates/errors/__init__.py
Normal file
0
src/server/web/templates/errors/__init__.py
Normal file
Reference in New Issue
Block a user