new folder structure

This commit is contained in:
2025-09-29 09:17:13 +02:00
parent 38117ab875
commit 78fc6068fb
197 changed files with 3490 additions and 1117 deletions

View File

@@ -0,0 +1,3 @@
"""
Web presentation layer with controllers, middleware, and templates.
"""

View File

@@ -0,0 +1 @@
# Web controllers - Flask blueprints

View File

@@ -0,0 +1 @@
# Admin controllers

View File

@@ -0,0 +1 @@
# API endpoints version 1

View File

@@ -0,0 +1 @@
# API middleware

View File

@@ -0,0 +1 @@
# API version 1 endpoints

View 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

View 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

View 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']

View 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']

View 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

View 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']

View 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

View 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

View 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']

View 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

View File

@@ -0,0 +1 @@
# Web middleware

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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()

View 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'
]

View 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()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

View File

File diff suppressed because it is too large Load Diff

View File

View File

View File

View File

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View File

View File

View 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>

View 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>

View 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>

View 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>