This commit is contained in:
Lukas Pupka-Lipinski 2025-10-04 20:24:00 +02:00
parent e477780ed6
commit 94e6b77456
21 changed files with 46 additions and 10217 deletions

View File

@ -1,9 +1,7 @@
# --- Global UTF-8 logging setup (fix UnicodeEncodeError) --- # --- Global UTF-8 logging setup (fix UnicodeEncodeError) ---
import sys import sys
import io
import logging import logging
import os import os
import threading
from datetime import datetime from datetime import datetime
# Add the parent directory to sys.path to import our modules # 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 from flask_socketio import SocketIO, emit
import logging import logging
import atexit 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 web.controllers.auth_controller import session_manager, require_auth, optional_auth
from config import config from config import config
from application.services.queue_service import download_queue_bp 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 # 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 application.services.scheduler_service import init_scheduler, get_scheduler
from shared.utils.process_utils import (with_process_lock, RESCAN_LOCK, DOWNLOAD_LOCK, from shared.utils.process_utils import (with_process_lock, RESCAN_LOCK, DOWNLOAD_LOCK,
ProcessLockError, is_process_running, check_process_locks) 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__, app = Flask(__name__,
template_folder='web/templates/base', template_folder='web/templates/base',
@ -89,48 +64,6 @@ def cleanup_on_exit():
except Exception as e: except Exception as e:
logging.error(f"Error during cleanup: {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 # Register all blueprints
app.register_blueprint(download_queue_bp) app.register_blueprint(download_queue_bp)
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
@ -154,10 +87,9 @@ from web.routes.api_routes import set_socketio
set_socketio(socketio) set_socketio(socketio)
# Initialize scheduler # Initialize scheduler
scheduler = init_scheduler(config, socketio)
scheduler.set_rescan_callback(rescan_callback) CurrentSeriesApp = None
scheduler.set_download_callback(download_callback) scheduler = init_scheduler(config, socketio, CurrentSeriesApp)
if __name__ == '__main__': if __name__ == '__main__':
# Configure enhanced logging system first # Configure enhanced logging system first

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

View File

@ -419,60 +419,6 @@ def api_start_download():
raise RetryableError(f"Failed to start download: {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']) @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}") 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 # Export the blueprint
__all__ = ['api_integration_bp'] __all__ = ['api_integration_bp']

View File

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

View File

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

View File

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

View File

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

View File

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