backup
This commit is contained in:
parent
e477780ed6
commit
94e6b77456
@ -1,9 +1,7 @@
|
||||
# --- Global UTF-8 logging setup (fix UnicodeEncodeError) ---
|
||||
import sys
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
# Add the parent directory to sys.path to import our modules
|
||||
@ -16,40 +14,17 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for
|
||||
from flask_socketio import SocketIO, emit
|
||||
import logging
|
||||
import atexit
|
||||
|
||||
from src.cli.Main import SeriesApp
|
||||
|
||||
# --- Fix Unicode logging error for Windows console ---
|
||||
import sys
|
||||
import io
|
||||
|
||||
|
||||
from server.core.entities.series import Serie
|
||||
from server.core.entities import SerieList
|
||||
from server.core import SerieScanner
|
||||
from server.infrastructure.providers.provider_factory import Loaders
|
||||
from web.controllers.auth_controller import session_manager, require_auth, optional_auth
|
||||
from config import config
|
||||
from application.services.queue_service import download_queue_bp
|
||||
|
||||
# Import route blueprints
|
||||
from web.routes import (
|
||||
auth_bp, auth_api_bp, api_bp, main_bp, static_bp,
|
||||
diagnostic_bp, config_bp
|
||||
)
|
||||
from web.routes.websocket_handlers import register_socketio_handlers
|
||||
|
||||
# Import API blueprints from their correct locations
|
||||
from web.controllers.api.v1.process import process_bp
|
||||
from web.controllers.api.v1.scheduler import scheduler_bp
|
||||
from web.controllers.api.v1.logging import logging_bp
|
||||
from web.controllers.api.v1.health import health_bp
|
||||
|
||||
from application.services.scheduler_service import init_scheduler, get_scheduler
|
||||
from shared.utils.process_utils import (with_process_lock, RESCAN_LOCK, DOWNLOAD_LOCK,
|
||||
ProcessLockError, is_process_running, check_process_locks)
|
||||
|
||||
# Import error handling and monitoring modules
|
||||
from web.middleware.error_handler import handle_api_errors
|
||||
|
||||
app = Flask(__name__,
|
||||
template_folder='web/templates/base',
|
||||
@ -89,48 +64,6 @@ def cleanup_on_exit():
|
||||
except Exception as e:
|
||||
logging.error(f"Error during cleanup: {e}")
|
||||
|
||||
|
||||
def rescan_callback():
|
||||
"""Callback for scheduled rescan operations."""
|
||||
try:
|
||||
# Reinit and scan
|
||||
series_app.SerieScanner.Reinit()
|
||||
series_app.SerieScanner.Scan()
|
||||
|
||||
# Refresh the series list
|
||||
series_app.List = SerieList.SerieList(series_app.directory_to_search)
|
||||
series_app.__InitList__()
|
||||
|
||||
return {"status": "success", "message": "Scheduled rescan completed"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Scheduled rescan failed: {e}")
|
||||
|
||||
def download_callback():
|
||||
"""Callback for auto-download after scheduled rescan."""
|
||||
try:
|
||||
if not series_app or not series_app.List:
|
||||
return {"status": "skipped", "message": "No series data available"}
|
||||
|
||||
# Find series with missing episodes
|
||||
series_with_missing = []
|
||||
for serie in series_app.List.GetList():
|
||||
if serie.episodeDict:
|
||||
series_with_missing.append(serie)
|
||||
|
||||
if not series_with_missing:
|
||||
return {"status": "skipped", "message": "No series with missing episodes found"}
|
||||
|
||||
# Note: Actual download implementation would go here
|
||||
# For now, just return the count of series that would be downloaded
|
||||
return {
|
||||
"status": "started",
|
||||
"message": f"Auto-download initiated for {len(series_with_missing)} series",
|
||||
"series_count": len(series_with_missing)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Auto-download failed: {e}")
|
||||
|
||||
# Register all blueprints
|
||||
app.register_blueprint(download_queue_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
@ -154,10 +87,9 @@ from web.routes.api_routes import set_socketio
|
||||
set_socketio(socketio)
|
||||
|
||||
# Initialize scheduler
|
||||
scheduler = init_scheduler(config, socketio)
|
||||
|
||||
scheduler.set_rescan_callback(rescan_callback)
|
||||
scheduler.set_download_callback(download_callback)
|
||||
|
||||
CurrentSeriesApp = None
|
||||
scheduler = init_scheduler(config, socketio, CurrentSeriesApp)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Configure enhanced logging system first
|
||||
|
||||
42
src/server/application/SeriesApp.py
Normal file
42
src/server/application/SeriesApp.py
Normal file
@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
|
||||
|
||||
class SeriesApp:
|
||||
|
||||
def __init__(self, directory_to_search: str):
|
||||
|
||||
# Only show initialization message for the first instance
|
||||
if SeriesApp._initialization_count <= 1:
|
||||
print("Please wait while initializing...")
|
||||
|
||||
self.progress = None
|
||||
self.directory_to_search = directory_to_search
|
||||
self.Loaders = Loaders()
|
||||
self.loader = self.Loaders.GetLoader(key="aniworld.to")
|
||||
self.SerieScanner = SerieScanner(directory_to_search, self.loader)
|
||||
|
||||
self.List = SerieList(self.directory_to_search)
|
||||
self.__InitList__()
|
||||
|
||||
def __InitList__(self):
|
||||
self.series_list = self.List.GetMissingEpisode()
|
||||
|
||||
def search(self, words :str) -> list:
|
||||
return self.loader.Search(words)
|
||||
|
||||
def download(self, serieFolder: str, season: int, episode: int, key: str, callback) -> bool:
|
||||
self.loader.Download(self.directory_to_search, serieFolder, season, episode, key, "German Dub", callback)
|
||||
|
||||
def ReScan(self, callback):
|
||||
|
||||
self.SerieScanner.Reinit()
|
||||
self.SerieScanner.Scan(callback)
|
||||
|
||||
self.List = SerieList(self.directory_to_search)
|
||||
self.__InitList__()
|
||||
@ -419,60 +419,6 @@ def api_start_download():
|
||||
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'])
|
||||
@ -499,72 +445,5 @@ def test_notifications():
|
||||
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']
|
||||
@ -1 +0,0 @@
|
||||
# API version 1 endpoints
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,767 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@ -1,462 +0,0 @@
|
||||
"""
|
||||
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'
|
||||
]
|
||||
@ -1,474 +0,0 @@
|
||||
"""
|
||||
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
@ -1,42 +0,0 @@
|
||||
"""
|
||||
Routes package for Aniworld web application.
|
||||
"""
|
||||
|
||||
# Import blueprints that are available
|
||||
__all__ = []
|
||||
|
||||
try:
|
||||
from .auth_routes import auth_bp, auth_api_bp
|
||||
__all__.extend(['auth_bp', 'auth_api_bp'])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .api_routes import api_bp
|
||||
__all__.append('api_bp')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .main_routes import main_bp
|
||||
__all__.append('main_bp')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .static_routes import static_bp
|
||||
__all__.append('static_bp')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .diagnostic_routes import diagnostic_bp
|
||||
__all__.append('diagnostic_bp')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .config_routes import config_bp
|
||||
__all__.append('config_bp')
|
||||
except ImportError:
|
||||
pass
|
||||
Loading…
x
Reference in New Issue
Block a user