new folder structure
This commit is contained in:
1
src/server/web/middleware/__init__.py
Normal file
1
src/server/web/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Web middleware
|
||||
1554
src/server/web/middleware/accessibility_middleware.py
Normal file
1554
src/server/web/middleware/accessibility_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1431
src/server/web/middleware/contrast_middleware.py
Normal file
1431
src/server/web/middleware/contrast_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
767
src/server/web/middleware/drag_drop_middleware.py
Normal file
767
src/server/web/middleware/drag_drop_middleware.py
Normal file
@@ -0,0 +1,767 @@
|
||||
"""
|
||||
Drag and Drop Functionality for File Operations
|
||||
|
||||
This module provides drag-and-drop capabilities for the AniWorld web interface,
|
||||
including file uploads, series reordering, and batch operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
class DragDropManager:
|
||||
"""Manages drag and drop operations for the web interface."""
|
||||
|
||||
def __init__(self):
|
||||
self.supported_files = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm']
|
||||
self.max_file_size = 50 * 1024 * 1024 * 1024 # 50GB
|
||||
|
||||
def get_drag_drop_js(self):
|
||||
"""Generate JavaScript code for drag and drop functionality."""
|
||||
return f"""
|
||||
// AniWorld Drag & Drop Manager
|
||||
class DragDropManager {{
|
||||
constructor() {{
|
||||
this.supportedFiles = {json.dumps(self.supported_files)};
|
||||
this.maxFileSize = {self.max_file_size};
|
||||
this.dropZones = new Map();
|
||||
this.dragData = null;
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.setupGlobalDragDrop();
|
||||
this.setupSeriesReordering();
|
||||
this.setupBatchOperations();
|
||||
this.createDropZoneOverlay();
|
||||
}}
|
||||
|
||||
setupGlobalDragDrop() {{
|
||||
// Prevent default drag behaviors on document
|
||||
document.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||
document.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
document.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||
document.addEventListener('drop', this.handleDrop.bind(this));
|
||||
|
||||
// Setup file drop zones
|
||||
this.initializeDropZones();
|
||||
}}
|
||||
|
||||
initializeDropZones() {{
|
||||
// Main content area drop zone
|
||||
const mainContent = document.querySelector('.main-content, .container-fluid');
|
||||
if (mainContent) {{
|
||||
this.createDropZone(mainContent, {{
|
||||
types: ['files'],
|
||||
accept: this.supportedFiles,
|
||||
multiple: true,
|
||||
callback: this.handleFileUpload.bind(this)
|
||||
}});
|
||||
}}
|
||||
|
||||
// Series list drop zone for reordering
|
||||
const seriesList = document.querySelector('.series-list, .anime-grid');
|
||||
if (seriesList) {{
|
||||
this.createDropZone(seriesList, {{
|
||||
types: ['series'],
|
||||
callback: this.handleSeriesReorder.bind(this)
|
||||
}});
|
||||
}}
|
||||
|
||||
// Queue drop zone
|
||||
const queueArea = document.querySelector('.queue-area, .download-queue');
|
||||
if (queueArea) {{
|
||||
this.createDropZone(queueArea, {{
|
||||
types: ['series', 'episodes'],
|
||||
callback: this.handleQueueOperation.bind(this)
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
createDropZone(element, options) {{
|
||||
const dropZone = {{
|
||||
element: element,
|
||||
options: options,
|
||||
active: false
|
||||
}};
|
||||
|
||||
this.dropZones.set(element, dropZone);
|
||||
|
||||
// Add drop zone event listeners
|
||||
element.addEventListener('dragenter', (e) => this.onDropZoneEnter(e, dropZone));
|
||||
element.addEventListener('dragover', (e) => this.onDropZoneOver(e, dropZone));
|
||||
element.addEventListener('dragleave', (e) => this.onDropZoneLeave(e, dropZone));
|
||||
element.addEventListener('drop', (e) => this.onDropZoneDrop(e, dropZone));
|
||||
|
||||
// Add visual indicators
|
||||
element.classList.add('drop-zone');
|
||||
|
||||
return dropZone;
|
||||
}}
|
||||
|
||||
setupSeriesReordering() {{
|
||||
const seriesItems = document.querySelectorAll('.series-item, .anime-card');
|
||||
seriesItems.forEach(item => {{
|
||||
item.draggable = true;
|
||||
item.addEventListener('dragstart', this.handleSeriesDragStart.bind(this));
|
||||
item.addEventListener('dragend', this.handleSeriesDragEnd.bind(this));
|
||||
}});
|
||||
}}
|
||||
|
||||
setupBatchOperations() {{
|
||||
// Enable dragging of selected series for batch operations
|
||||
const selectionArea = document.querySelector('.series-selection, .selection-controls');
|
||||
if (selectionArea) {{
|
||||
selectionArea.addEventListener('dragstart', this.handleBatchDragStart.bind(this));
|
||||
}}
|
||||
}}
|
||||
|
||||
handleDragEnter(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.hasFiles(e)) {{
|
||||
this.showDropOverlay();
|
||||
}}
|
||||
}}
|
||||
|
||||
handleDragOver(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
|
||||
handleDragLeave(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only hide overlay if leaving the window
|
||||
if (e.clientX === 0 && e.clientY === 0) {{
|
||||
this.hideDropOverlay();
|
||||
}}
|
||||
}}
|
||||
|
||||
handleDrop(e) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.hideDropOverlay();
|
||||
|
||||
if (this.hasFiles(e)) {{
|
||||
this.handleFileUpload(e.dataTransfer.files);
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneEnter(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.canAcceptDrop(e, dropZone)) {{
|
||||
dropZone.element.classList.add('drag-over');
|
||||
dropZone.active = true;
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneOver(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (dropZone.active) {{
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneLeave(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
|
||||
// Check if we're actually leaving the drop zone
|
||||
const rect = dropZone.element.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {{
|
||||
dropZone.element.classList.remove('drag-over');
|
||||
dropZone.active = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
onDropZoneDrop(e, dropZone) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dropZone.element.classList.remove('drag-over');
|
||||
dropZone.active = false;
|
||||
|
||||
if (dropZone.options.callback) {{
|
||||
if (this.hasFiles(e)) {{
|
||||
dropZone.options.callback(e.dataTransfer.files, 'files');
|
||||
}} else {{
|
||||
dropZone.options.callback(this.dragData, 'data');
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
canAcceptDrop(e, dropZone) {{
|
||||
const types = dropZone.options.types || [];
|
||||
|
||||
if (this.hasFiles(e) && types.includes('files')) {{
|
||||
return this.validateFiles(e.dataTransfer.files, dropZone.options);
|
||||
}}
|
||||
|
||||
if (this.dragData && types.includes(this.dragData.type)) {{
|
||||
return true;
|
||||
}}
|
||||
|
||||
return false;
|
||||
}}
|
||||
|
||||
hasFiles(e) {{
|
||||
return e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0;
|
||||
}}
|
||||
|
||||
validateFiles(files, options) {{
|
||||
const accept = options.accept || [];
|
||||
const maxSize = options.maxSize || this.maxFileSize;
|
||||
const multiple = options.multiple !== false;
|
||||
|
||||
if (!multiple && files.length > 1) {{
|
||||
return false;
|
||||
}}
|
||||
|
||||
for (let file of files) {{
|
||||
// Check file size
|
||||
if (file.size > maxSize) {{
|
||||
return false;
|
||||
}}
|
||||
|
||||
// Check file extension
|
||||
if (accept.length > 0) {{
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
if (!accept.includes(ext)) {{
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
return true;
|
||||
}}
|
||||
|
||||
handleSeriesDragStart(e) {{
|
||||
const seriesItem = e.target.closest('.series-item, .anime-card');
|
||||
if (!seriesItem) return;
|
||||
|
||||
this.dragData = {{
|
||||
type: 'series',
|
||||
element: seriesItem,
|
||||
data: {{
|
||||
id: seriesItem.dataset.seriesId || seriesItem.dataset.id,
|
||||
name: seriesItem.dataset.seriesName || seriesItem.querySelector('.series-name, .anime-title')?.textContent,
|
||||
folder: seriesItem.dataset.folder
|
||||
}}
|
||||
}};
|
||||
|
||||
// Create drag image
|
||||
const dragImage = this.createDragImage(seriesItem);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
seriesItem.classList.add('dragging');
|
||||
}}
|
||||
|
||||
handleSeriesDragEnd(e) {{
|
||||
const seriesItem = e.target.closest('.series-item, .anime-card');
|
||||
if (seriesItem) {{
|
||||
seriesItem.classList.remove('dragging');
|
||||
}}
|
||||
this.dragData = null;
|
||||
}}
|
||||
|
||||
handleBatchDragStart(e) {{
|
||||
const selectedItems = document.querySelectorAll('.series-item.selected, .anime-card.selected');
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
this.dragData = {{
|
||||
type: 'batch',
|
||||
count: selectedItems.length,
|
||||
items: Array.from(selectedItems).map(item => ({{
|
||||
id: item.dataset.seriesId || item.dataset.id,
|
||||
name: item.dataset.seriesName || item.querySelector('.series-name, .anime-title')?.textContent,
|
||||
folder: item.dataset.folder
|
||||
}}))
|
||||
}};
|
||||
|
||||
// Create batch drag image
|
||||
const dragImage = this.createBatchDragImage(selectedItems.length);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
|
||||
handleFileUpload(files, type = 'files') {{
|
||||
if (files.length === 0) return;
|
||||
|
||||
const validFiles = [];
|
||||
const errors = [];
|
||||
|
||||
// Validate each file
|
||||
for (let file of files) {{
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!this.supportedFiles.includes(ext)) {{
|
||||
errors.push(`Unsupported file type: ${{file.name}}`);
|
||||
continue;
|
||||
}}
|
||||
|
||||
if (file.size > this.maxFileSize) {{
|
||||
errors.push(`File too large: ${{file.name}} (${{this.formatFileSize(file.size)}})`);
|
||||
continue;
|
||||
}}
|
||||
|
||||
validFiles.push(file);
|
||||
}}
|
||||
|
||||
// Show errors if any
|
||||
if (errors.length > 0) {{
|
||||
this.showUploadErrors(errors);
|
||||
}}
|
||||
|
||||
// Process valid files
|
||||
if (validFiles.length > 0) {{
|
||||
this.showUploadProgress(validFiles);
|
||||
this.uploadFiles(validFiles);
|
||||
}}
|
||||
}}
|
||||
|
||||
handleSeriesReorder(data, type) {{
|
||||
if (type !== 'data' || !data || data.type !== 'series') return;
|
||||
|
||||
// Find drop position
|
||||
const seriesList = document.querySelector('.series-list, .anime-grid');
|
||||
const items = seriesList.querySelectorAll('.series-item, .anime-card');
|
||||
|
||||
// Implement reordering logic
|
||||
this.reorderSeries(data.data.id, items);
|
||||
}}
|
||||
|
||||
handleQueueOperation(data, type) {{
|
||||
if (type === 'files') {{
|
||||
// Handle file drops to queue
|
||||
this.addFilesToQueue(data);
|
||||
}} else if (type === 'data') {{
|
||||
// Handle series/episode drops to queue
|
||||
this.addToQueue(data);
|
||||
}}
|
||||
}}
|
||||
|
||||
createDropZoneOverlay() {{
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'drop-overlay';
|
||||
overlay.className = 'drop-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="drop-message">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<h3>Drop Files Here</h3>
|
||||
<p>Supported formats: ${{this.supportedFiles.join(', ')}}</p>
|
||||
<p>Maximum size: ${{this.formatFileSize(this.maxFileSize)}}</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
}}
|
||||
|
||||
showDropOverlay() {{
|
||||
const overlay = document.getElementById('drop-overlay');
|
||||
if (overlay) {{
|
||||
overlay.style.display = 'flex';
|
||||
}}
|
||||
}}
|
||||
|
||||
hideDropOverlay() {{
|
||||
const overlay = document.getElementById('drop-overlay');
|
||||
if (overlay) {{
|
||||
overlay.style.display = 'none';
|
||||
}}
|
||||
}}
|
||||
|
||||
createDragImage(element) {{
|
||||
const clone = element.cloneNode(true);
|
||||
clone.style.position = 'absolute';
|
||||
clone.style.top = '-1000px';
|
||||
clone.style.opacity = '0.8';
|
||||
clone.style.transform = 'rotate(5deg)';
|
||||
document.body.appendChild(clone);
|
||||
|
||||
setTimeout(() => document.body.removeChild(clone), 100);
|
||||
return clone;
|
||||
}}
|
||||
|
||||
createBatchDragImage(count) {{
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.className = 'batch-drag-image';
|
||||
dragImage.innerHTML = `
|
||||
<i class="fas fa-files"></i>
|
||||
<span>${{count}} items</span>
|
||||
`;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-1000px';
|
||||
document.body.appendChild(dragImage);
|
||||
|
||||
setTimeout(() => document.body.removeChild(dragImage), 100);
|
||||
return dragImage;
|
||||
}}
|
||||
|
||||
formatFileSize(bytes) {{
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}}
|
||||
|
||||
showUploadErrors(errors) {{
|
||||
const errorModal = document.createElement('div');
|
||||
errorModal.className = 'modal fade';
|
||||
errorModal.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Errors</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="list-unstyled">
|
||||
${{errors.map(error => `<li class="text-danger"><i class="fas fa-exclamation-triangle"></i> ${{error}}</li>`).join('')}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorModal);
|
||||
const modal = new bootstrap.Modal(errorModal);
|
||||
modal.show();
|
||||
|
||||
errorModal.addEventListener('hidden.bs.modal', () => {{
|
||||
document.body.removeChild(errorModal);
|
||||
}});
|
||||
}}
|
||||
|
||||
showUploadProgress(files) {{
|
||||
// Create upload progress modal
|
||||
const progressModal = document.createElement('div');
|
||||
progressModal.className = 'modal fade';
|
||||
progressModal.id = 'upload-progress-modal';
|
||||
progressModal.setAttribute('data-bs-backdrop', 'static');
|
||||
progressModal.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Uploading Files</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="upload-progress-list"></div>
|
||||
<div class="mt-3">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" id="overall-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<small class="text-muted">Overall progress</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-upload">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(progressModal);
|
||||
const modal = new bootstrap.Modal(progressModal);
|
||||
modal.show();
|
||||
|
||||
return modal;
|
||||
}}
|
||||
|
||||
uploadFiles(files) {{
|
||||
// This would implement the actual file upload logic
|
||||
// For now, just simulate upload progress
|
||||
const progressModal = this.showUploadProgress(files);
|
||||
|
||||
files.forEach((file, index) => {{
|
||||
this.simulateFileUpload(file, index, files.length);
|
||||
}});
|
||||
}}
|
||||
|
||||
simulateFileUpload(file, index, total) {{
|
||||
const progressList = document.getElementById('upload-progress-list');
|
||||
const fileProgress = document.createElement('div');
|
||||
fileProgress.className = 'mb-2';
|
||||
fileProgress.innerHTML = `
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-truncate">${{file.name}}</span>
|
||||
<span class="text-muted">${{this.formatFileSize(file.size)}}</span>
|
||||
</div>
|
||||
<div class="progress progress-sm">
|
||||
<div class="progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
`;
|
||||
progressList.appendChild(fileProgress);
|
||||
|
||||
// Simulate progress
|
||||
const progressBar = fileProgress.querySelector('.progress-bar');
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {{
|
||||
progress += Math.random() * 15;
|
||||
if (progress > 100) progress = 100;
|
||||
|
||||
progressBar.style.width = progress + '%';
|
||||
|
||||
if (progress >= 100) {{
|
||||
clearInterval(interval);
|
||||
progressBar.classList.add('bg-success');
|
||||
|
||||
// Update overall progress
|
||||
this.updateOverallProgress(index + 1, total);
|
||||
}}
|
||||
}}, 200);
|
||||
}}
|
||||
|
||||
updateOverallProgress(completed, total) {{
|
||||
const overallProgress = document.getElementById('overall-progress');
|
||||
const percentage = (completed / total) * 100;
|
||||
overallProgress.style.width = percentage + '%';
|
||||
|
||||
if (completed === total) {{
|
||||
setTimeout(() => {{
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('upload-progress-modal'));
|
||||
modal.hide();
|
||||
}}, 1000);
|
||||
}}
|
||||
}}
|
||||
|
||||
reorderSeries(seriesId, items) {{
|
||||
// Implement series reordering logic
|
||||
console.log('Reordering series:', seriesId);
|
||||
|
||||
// This would send an API request to update the order
|
||||
fetch('/api/series/reorder', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json'
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
seriesId: seriesId,
|
||||
newPosition: Array.from(items).findIndex(item =>
|
||||
item.classList.contains('drag-over'))
|
||||
}})
|
||||
}})
|
||||
.then(response => response.json())
|
||||
.then(data => {{
|
||||
if (data.success) {{
|
||||
this.showToast('Series reordered successfully', 'success');
|
||||
}} else {{
|
||||
this.showToast('Failed to reorder series', 'error');
|
||||
}}
|
||||
}})
|
||||
.catch(error => {{
|
||||
console.error('Reorder error:', error);
|
||||
this.showToast('Error reordering series', 'error');
|
||||
}});
|
||||
}}
|
||||
|
||||
addToQueue(data) {{
|
||||
// Add series or episodes to download queue
|
||||
let items = [];
|
||||
|
||||
if (data.type === 'series') {{
|
||||
items = [data.data];
|
||||
}} else if (data.type === 'batch') {{
|
||||
items = data.items;
|
||||
}}
|
||||
|
||||
fetch('/api/queue/add', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json'
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
items: items
|
||||
}})
|
||||
}})
|
||||
.then(response => response.json())
|
||||
.then(result => {{
|
||||
if (result.success) {{
|
||||
this.showToast(`Added ${{items.length}} item(s) to queue`, 'success');
|
||||
}} else {{
|
||||
this.showToast('Failed to add to queue', 'error');
|
||||
}}
|
||||
}})
|
||||
.catch(error => {{
|
||||
console.error('Queue add error:', error);
|
||||
this.showToast('Error adding to queue', 'error');
|
||||
}});
|
||||
}}
|
||||
|
||||
showToast(message, type = 'info') {{
|
||||
// Create and show a toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${{type === 'error' ? 'danger' : type}}`;
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${{message}}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let toastContainer = document.querySelector('.toast-container');
|
||||
if (!toastContainer) {{
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||
document.body.appendChild(toastContainer);
|
||||
}}
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', () => {{
|
||||
toastContainer.removeChild(toast);
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
// Initialize drag and drop when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
window.dragDropManager = new DragDropManager();
|
||||
}});
|
||||
"""
|
||||
|
||||
def get_css(self):
|
||||
"""Generate CSS styles for drag and drop functionality."""
|
||||
return """
|
||||
/* Drag and Drop Styles */
|
||||
.drop-zone {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
border: 2px dashed #0d6efd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(13, 110, 253, 0.05);
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.drop-message {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border: 3px dashed #0d6efd;
|
||||
border-radius: 15px;
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.drop-message i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.drop-message h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-message p {
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.series-item.dragging,
|
||||
.anime-card.dragging {
|
||||
opacity: 0.5;
|
||||
transform: rotate(2deg);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.batch-drag-image {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.progress-sm {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Drag handle for reorderable items */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #6c757d;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Drop indicators */
|
||||
.drop-indicator {
|
||||
height: 3px;
|
||||
background: #0d6efd;
|
||||
margin: 0.25rem 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.drop-indicator.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drop-zone,
|
||||
.series-item.dragging,
|
||||
.anime-card.dragging {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# Export the drag drop manager
|
||||
drag_drop_manager = DragDropManager()
|
||||
462
src/server/web/middleware/error_handler.py
Normal file
462
src/server/web/middleware/error_handler.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
Error Handling & Recovery System for AniWorld App
|
||||
|
||||
This module provides comprehensive error handling for network failures,
|
||||
download errors, and system recovery mechanisms.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import functools
|
||||
import threading
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import requests
|
||||
import socket
|
||||
import ssl
|
||||
from urllib3.exceptions import ConnectionError, TimeoutError, ReadTimeoutError
|
||||
from requests.exceptions import RequestException, ConnectionError as ReqConnectionError
|
||||
from flask import jsonify
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
|
||||
class NetworkError(Exception):
|
||||
"""Base class for network-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class DownloadError(Exception):
|
||||
"""Base class for download-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RetryableError(Exception):
|
||||
"""Base class for errors that can be retried."""
|
||||
pass
|
||||
|
||||
|
||||
class NonRetryableError(Exception):
|
||||
"""Base class for errors that should not be retried."""
|
||||
pass
|
||||
|
||||
|
||||
class ErrorRecoveryManager:
|
||||
"""Manages error recovery strategies and retry mechanisms."""
|
||||
|
||||
def __init__(self, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0):
|
||||
self.max_retries = max_retries
|
||||
self.base_delay = base_delay
|
||||
self.max_delay = max_delay
|
||||
self.error_history: List[Dict] = []
|
||||
self.blacklisted_urls: Dict[str, datetime] = {}
|
||||
self.retry_counts: Dict[str, int] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def is_network_error(self, error: Exception) -> bool:
|
||||
"""Check if error is network-related."""
|
||||
network_errors = (
|
||||
ConnectionError, TimeoutError, ReadTimeoutError,
|
||||
ReqConnectionError, socket.timeout, socket.gaierror,
|
||||
ssl.SSLError, requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError
|
||||
)
|
||||
return isinstance(error, network_errors)
|
||||
|
||||
def is_retryable_error(self, error: Exception) -> bool:
|
||||
"""Determine if an error should be retried."""
|
||||
if isinstance(error, NonRetryableError):
|
||||
return False
|
||||
|
||||
if isinstance(error, RetryableError):
|
||||
return True
|
||||
|
||||
# Network errors are generally retryable
|
||||
if self.is_network_error(error):
|
||||
return True
|
||||
|
||||
# HTTP status codes that are retryable
|
||||
if hasattr(error, 'response') and error.response:
|
||||
status_code = error.response.status_code
|
||||
retryable_codes = [408, 429, 500, 502, 503, 504]
|
||||
return status_code in retryable_codes
|
||||
|
||||
return False
|
||||
|
||||
def calculate_delay(self, attempt: int) -> float:
|
||||
"""Calculate exponential backoff delay."""
|
||||
delay = self.base_delay * (2 ** (attempt - 1))
|
||||
return min(delay, self.max_delay)
|
||||
|
||||
def log_error(self, error: Exception, context: str, attempt: int = None):
|
||||
"""Log error with context information."""
|
||||
error_info = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error_type': type(error).__name__,
|
||||
'error_message': str(error),
|
||||
'context': context,
|
||||
'attempt': attempt,
|
||||
'retryable': self.is_retryable_error(error)
|
||||
}
|
||||
|
||||
self.error_history.append(error_info)
|
||||
|
||||
# Keep only last 1000 errors
|
||||
if len(self.error_history) > 1000:
|
||||
self.error_history = self.error_history[-1000:]
|
||||
|
||||
log_level = logging.WARNING if self.is_retryable_error(error) else logging.ERROR
|
||||
self.logger.log(log_level, f"Error in {context}: {error}", exc_info=True)
|
||||
|
||||
def add_to_blacklist(self, url: str, duration_minutes: int = 30):
|
||||
"""Add URL to temporary blacklist."""
|
||||
self.blacklisted_urls[url] = datetime.now() + timedelta(minutes=duration_minutes)
|
||||
|
||||
def is_blacklisted(self, url: str) -> bool:
|
||||
"""Check if URL is currently blacklisted."""
|
||||
if url in self.blacklisted_urls:
|
||||
if datetime.now() < self.blacklisted_urls[url]:
|
||||
return True
|
||||
else:
|
||||
del self.blacklisted_urls[url]
|
||||
return False
|
||||
|
||||
def cleanup_blacklist(self):
|
||||
"""Remove expired entries from blacklist."""
|
||||
now = datetime.now()
|
||||
expired_keys = [url for url, expiry in self.blacklisted_urls.items() if now >= expiry]
|
||||
for key in expired_keys:
|
||||
del self.blacklisted_urls[key]
|
||||
|
||||
|
||||
class RetryMechanism:
|
||||
"""Advanced retry mechanism with exponential backoff and jitter."""
|
||||
|
||||
def __init__(self, recovery_manager: ErrorRecoveryManager):
|
||||
self.recovery_manager = recovery_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def retry_with_backoff(
|
||||
self,
|
||||
func: Callable,
|
||||
*args,
|
||||
max_retries: int = None,
|
||||
backoff_factor: float = 1.0,
|
||||
jitter: bool = True,
|
||||
retry_on: tuple = None,
|
||||
context: str = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Retry function with exponential backoff and jitter.
|
||||
|
||||
Args:
|
||||
func: Function to retry
|
||||
max_retries: Maximum number of retries (uses recovery manager default if None)
|
||||
backoff_factor: Multiplier for backoff delay
|
||||
jitter: Add random jitter to prevent thundering herd
|
||||
retry_on: Tuple of exception types to retry on
|
||||
context: Context string for logging
|
||||
|
||||
Returns:
|
||||
Function result
|
||||
|
||||
Raises:
|
||||
Last exception if all retries fail
|
||||
"""
|
||||
if max_retries is None:
|
||||
max_retries = self.recovery_manager.max_retries
|
||||
|
||||
if context is None:
|
||||
context = f"{func.__name__}"
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(1, max_retries + 2): # +1 for initial attempt
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
|
||||
# Check if we should retry this error
|
||||
should_retry = (
|
||||
retry_on is None and self.recovery_manager.is_retryable_error(e)
|
||||
) or (
|
||||
retry_on is not None and isinstance(e, retry_on)
|
||||
)
|
||||
|
||||
if attempt > max_retries or not should_retry:
|
||||
self.recovery_manager.log_error(e, context, attempt)
|
||||
raise e
|
||||
|
||||
# Calculate delay with jitter
|
||||
delay = self.recovery_manager.calculate_delay(attempt) * backoff_factor
|
||||
if jitter:
|
||||
import random
|
||||
delay *= (0.5 + random.random() * 0.5) # Add 0-50% jitter
|
||||
|
||||
self.recovery_manager.log_error(e, context, attempt)
|
||||
self.logger.info(f"Retrying {context} in {delay:.2f}s (attempt {attempt}/{max_retries})")
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
class NetworkHealthChecker:
|
||||
"""Monitor network connectivity and health."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.connectivity_cache = {}
|
||||
self.cache_timeout = 60 # seconds
|
||||
|
||||
def check_connectivity(self, host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool:
|
||||
"""Check basic network connectivity."""
|
||||
cache_key = f"{host}:{port}"
|
||||
now = time.time()
|
||||
|
||||
# Check cache
|
||||
if cache_key in self.connectivity_cache:
|
||||
timestamp, result = self.connectivity_cache[cache_key]
|
||||
if now - timestamp < self.cache_timeout:
|
||||
return result
|
||||
|
||||
try:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port))
|
||||
result = True
|
||||
except Exception:
|
||||
result = False
|
||||
|
||||
self.connectivity_cache[cache_key] = (now, result)
|
||||
return result
|
||||
|
||||
def check_url_reachability(self, url: str, timeout: float = 10.0) -> bool:
|
||||
"""Check if a specific URL is reachable."""
|
||||
try:
|
||||
response = requests.head(url, timeout=timeout, allow_redirects=True)
|
||||
return response.status_code < 400
|
||||
except Exception as e:
|
||||
self.logger.debug(f"URL {url} not reachable: {e}")
|
||||
return False
|
||||
|
||||
def get_network_status(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive network status."""
|
||||
return {
|
||||
'basic_connectivity': self.check_connectivity(),
|
||||
'dns_resolution': self.check_connectivity("1.1.1.1", 53),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
class FileCorruptionDetector:
|
||||
"""Detect and handle file corruption."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_checksum(self, file_path: str, algorithm: str = 'md5') -> str:
|
||||
"""Calculate file checksum."""
|
||||
hash_func = getattr(hashlib, algorithm)()
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_func.update(chunk)
|
||||
return hash_func.hexdigest()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to calculate checksum for {file_path}: {e}")
|
||||
raise
|
||||
|
||||
def verify_file_size(self, file_path: str, expected_size: int = None, min_size: int = 1024) -> bool:
|
||||
"""Verify file has reasonable size."""
|
||||
try:
|
||||
actual_size = os.path.getsize(file_path)
|
||||
|
||||
# Check minimum size
|
||||
if actual_size < min_size:
|
||||
self.logger.warning(f"File {file_path} too small: {actual_size} bytes")
|
||||
return False
|
||||
|
||||
# Check expected size if provided
|
||||
if expected_size and abs(actual_size - expected_size) > expected_size * 0.1: # 10% tolerance
|
||||
self.logger.warning(f"File {file_path} size mismatch: expected {expected_size}, got {actual_size}")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to verify file size for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def is_valid_video_file(self, file_path: str) -> bool:
|
||||
"""Basic validation for video files."""
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
|
||||
# Check file size
|
||||
if not self.verify_file_size(file_path):
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
video_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm'}
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in video_extensions:
|
||||
self.logger.warning(f"File {file_path} has unexpected extension: {ext}")
|
||||
|
||||
# Try to read first few bytes to check for valid headers
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(32)
|
||||
# Common video file signatures
|
||||
video_signatures = [
|
||||
b'\x00\x00\x00\x18ftypmp4', # MP4
|
||||
b'\x1a\x45\xdf\xa3', # MKV (Matroska)
|
||||
b'RIFF', # AVI
|
||||
]
|
||||
|
||||
for sig in video_signatures:
|
||||
if header.startswith(sig):
|
||||
return True
|
||||
|
||||
# If no specific signature matches, assume it's valid if size is reasonable
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to read file header for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class RecoveryStrategies:
|
||||
"""Implement various recovery strategies for different error types."""
|
||||
|
||||
def __init__(self, recovery_manager: ErrorRecoveryManager):
|
||||
self.recovery_manager = recovery_manager
|
||||
self.retry_mechanism = RetryMechanism(recovery_manager)
|
||||
self.health_checker = NetworkHealthChecker()
|
||||
self.corruption_detector = FileCorruptionDetector()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def handle_network_failure(self, func: Callable, *args, **kwargs) -> Any:
|
||||
"""Handle network failures with comprehensive recovery."""
|
||||
def recovery_wrapper():
|
||||
# Check basic connectivity first
|
||||
if not self.health_checker.check_connectivity():
|
||||
raise NetworkError("No internet connectivity")
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return self.retry_mechanism.retry_with_backoff(
|
||||
recovery_wrapper,
|
||||
max_retries=5,
|
||||
backoff_factor=1.5,
|
||||
context=f"network_operation_{func.__name__}",
|
||||
retry_on=(NetworkError, ConnectionError, TimeoutError)
|
||||
)
|
||||
|
||||
def handle_download_failure(
|
||||
self,
|
||||
download_func: Callable,
|
||||
file_path: str,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Handle download failures with corruption checking and resume support."""
|
||||
def download_with_verification():
|
||||
result = download_func(*args, **kwargs)
|
||||
|
||||
# Verify downloaded file if it exists
|
||||
if os.path.exists(file_path):
|
||||
if not self.corruption_detector.is_valid_video_file(file_path):
|
||||
self.logger.warning(f"Downloaded file appears corrupted: {file_path}")
|
||||
# Remove corrupted file to force re-download
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to remove corrupted file {file_path}: {e}")
|
||||
raise DownloadError("Downloaded file is corrupted")
|
||||
|
||||
return result
|
||||
|
||||
return self.retry_mechanism.retry_with_backoff(
|
||||
download_with_verification,
|
||||
max_retries=3,
|
||||
backoff_factor=2.0,
|
||||
context=f"download_{os.path.basename(file_path)}",
|
||||
retry_on=(DownloadError, NetworkError, ConnectionError)
|
||||
)
|
||||
|
||||
|
||||
# Singleton instances
|
||||
error_recovery_manager = ErrorRecoveryManager()
|
||||
recovery_strategies = RecoveryStrategies(error_recovery_manager)
|
||||
network_health_checker = NetworkHealthChecker()
|
||||
file_corruption_detector = FileCorruptionDetector()
|
||||
|
||||
|
||||
def with_error_recovery(max_retries: int = None, context: str = None):
|
||||
"""Decorator for adding error recovery to functions."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return recovery_strategies.retry_mechanism.retry_with_backoff(
|
||||
func,
|
||||
*args,
|
||||
max_retries=max_retries,
|
||||
context=context or func.__name__,
|
||||
**kwargs
|
||||
)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_api_errors(func: Callable) -> Callable:
|
||||
"""Decorator for consistent API error handling."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except NonRetryableError as e:
|
||||
error_recovery_manager.log_error(e, f"api_{func.__name__}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Operation failed',
|
||||
'error_type': 'non_retryable',
|
||||
'retry_suggested': False
|
||||
}), 400
|
||||
except RetryableError as e:
|
||||
error_recovery_manager.log_error(e, f"api_{func.__name__}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Temporary failure, please try again',
|
||||
'error_type': 'retryable',
|
||||
'retry_suggested': True
|
||||
}), 503
|
||||
except Exception as e:
|
||||
error_recovery_manager.log_error(e, f"api_{func.__name__}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An unexpected error occurred',
|
||||
'error_type': 'unknown',
|
||||
'retry_suggested': error_recovery_manager.is_retryable_error(e)
|
||||
}), 500
|
||||
return wrapper
|
||||
|
||||
|
||||
# Export main components
|
||||
__all__ = [
|
||||
'ErrorRecoveryManager',
|
||||
'RetryMechanism',
|
||||
'NetworkHealthChecker',
|
||||
'FileCorruptionDetector',
|
||||
'RecoveryStrategies',
|
||||
'NetworkError',
|
||||
'DownloadError',
|
||||
'RetryableError',
|
||||
'NonRetryableError',
|
||||
'with_error_recovery',
|
||||
'handle_api_errors',
|
||||
'error_recovery_manager',
|
||||
'recovery_strategies',
|
||||
'network_health_checker',
|
||||
'file_corruption_detector'
|
||||
]
|
||||
474
src/server/web/middleware/keyboard_middleware.py
Normal file
474
src/server/web/middleware/keyboard_middleware.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Keyboard Shortcuts and Hotkey Management
|
||||
|
||||
This module provides keyboard shortcut functionality for the AniWorld web interface,
|
||||
including customizable hotkeys for common actions and accessibility support.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
class KeyboardShortcutManager:
|
||||
"""Manages keyboard shortcuts for the web interface."""
|
||||
|
||||
def __init__(self):
|
||||
self.shortcuts = {
|
||||
# Navigation shortcuts
|
||||
'home': ['Alt+H', 'h'],
|
||||
'search': ['Ctrl+F', 'Alt+S', '/'],
|
||||
'queue': ['Alt+Q', 'q'],
|
||||
'config': ['Alt+C', 'c'],
|
||||
'logs': ['Alt+L', 'l'],
|
||||
|
||||
# Action shortcuts
|
||||
'rescan': ['F5', 'Ctrl+R', 'r'],
|
||||
'start_download': ['Enter', 'Space', 'd'],
|
||||
'pause_download': ['Ctrl+Space', 'p'],
|
||||
'cancel_download': ['Escape', 'Ctrl+X'],
|
||||
|
||||
# Selection shortcuts
|
||||
'select_all': ['Ctrl+A', 'a'],
|
||||
'deselect_all': ['Ctrl+D', 'Escape'],
|
||||
'toggle_selection': ['Ctrl+Click', 't'],
|
||||
'next_item': ['ArrowDown', 'j'],
|
||||
'prev_item': ['ArrowUp', 'k'],
|
||||
|
||||
# Modal/Dialog shortcuts
|
||||
'close_modal': ['Escape', 'Ctrl+W'],
|
||||
'confirm_action': ['Enter', 'Ctrl+Enter'],
|
||||
'cancel_action': ['Escape', 'Ctrl+C'],
|
||||
|
||||
# View shortcuts
|
||||
'toggle_details': ['Tab', 'i'],
|
||||
'refresh_view': ['F5', 'Ctrl+R'],
|
||||
'toggle_filters': ['f'],
|
||||
'toggle_sort': ['s'],
|
||||
|
||||
# Quick actions
|
||||
'quick_help': ['F1', '?'],
|
||||
'settings': ['Ctrl+,', ','],
|
||||
'logout': ['Ctrl+Shift+L'],
|
||||
}
|
||||
|
||||
self.descriptions = {
|
||||
'home': 'Navigate to home page',
|
||||
'search': 'Focus search input',
|
||||
'queue': 'Open download queue',
|
||||
'config': 'Open configuration',
|
||||
'logs': 'View application logs',
|
||||
'rescan': 'Rescan anime collection',
|
||||
'start_download': 'Start selected downloads',
|
||||
'pause_download': 'Pause active downloads',
|
||||
'cancel_download': 'Cancel active downloads',
|
||||
'select_all': 'Select all items',
|
||||
'deselect_all': 'Deselect all items',
|
||||
'toggle_selection': 'Toggle item selection',
|
||||
'next_item': 'Navigate to next item',
|
||||
'prev_item': 'Navigate to previous item',
|
||||
'close_modal': 'Close modal dialog',
|
||||
'confirm_action': 'Confirm current action',
|
||||
'cancel_action': 'Cancel current action',
|
||||
'toggle_details': 'Toggle detailed view',
|
||||
'refresh_view': 'Refresh current view',
|
||||
'toggle_filters': 'Toggle filter panel',
|
||||
'toggle_sort': 'Change sort order',
|
||||
'quick_help': 'Show help dialog',
|
||||
'settings': 'Open settings panel',
|
||||
'logout': 'Logout from application'
|
||||
}
|
||||
|
||||
def get_shortcuts_js(self):
|
||||
"""Generate JavaScript code for keyboard shortcuts."""
|
||||
return f"""
|
||||
// AniWorld Keyboard Shortcuts Manager
|
||||
class KeyboardShortcutManager {{
|
||||
constructor() {{
|
||||
this.shortcuts = {self._format_shortcuts_for_js()};
|
||||
this.descriptions = {self._format_descriptions_for_js()};
|
||||
this.enabled = true;
|
||||
this.activeModals = [];
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
this.createHelpModal();
|
||||
this.showKeyboardHints();
|
||||
}}
|
||||
|
||||
handleKeyDown(event) {{
|
||||
if (!this.enabled) return;
|
||||
|
||||
const key = this.getKeyString(event);
|
||||
|
||||
// Check for matching shortcuts
|
||||
for (const [action, keys] of Object.entries(this.shortcuts)) {{
|
||||
if (keys.includes(key)) {{
|
||||
if (this.executeAction(action, event)) {{
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
handleKeyUp(event) {{
|
||||
// Handle key up events if needed
|
||||
}}
|
||||
|
||||
getKeyString(event) {{
|
||||
const parts = [];
|
||||
if (event.ctrlKey) parts.push('Ctrl');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
if (event.metaKey) parts.push('Meta');
|
||||
|
||||
let key = event.key;
|
||||
if (key === ' ') key = 'Space';
|
||||
|
||||
parts.push(key);
|
||||
return parts.join('+');
|
||||
}}
|
||||
|
||||
executeAction(action, event) {{
|
||||
// Prevent shortcuts in input fields unless explicitly allowed
|
||||
const allowedInInputs = ['search', 'close_modal', 'cancel_action'];
|
||||
const activeElement = document.activeElement;
|
||||
const isInputElement = activeElement && (
|
||||
activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.contentEditable === 'true'
|
||||
);
|
||||
|
||||
if (isInputElement && !allowedInInputs.includes(action)) {{
|
||||
return false;
|
||||
}}
|
||||
|
||||
switch (action) {{
|
||||
case 'home':
|
||||
window.location.href = '/';
|
||||
return true;
|
||||
|
||||
case 'search':
|
||||
const searchInput = document.querySelector('#search-input, .search-input, [data-search]');
|
||||
if (searchInput) {{
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'queue':
|
||||
window.location.href = '/queue';
|
||||
return true;
|
||||
|
||||
case 'config':
|
||||
window.location.href = '/config';
|
||||
return true;
|
||||
|
||||
case 'logs':
|
||||
window.location.href = '/logs';
|
||||
return true;
|
||||
|
||||
case 'rescan':
|
||||
const rescanBtn = document.querySelector('#rescan-btn, [data-action="rescan"]');
|
||||
if (rescanBtn && !rescanBtn.disabled) {{
|
||||
rescanBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'start_download':
|
||||
const downloadBtn = document.querySelector('#download-btn, [data-action="download"]');
|
||||
if (downloadBtn && !downloadBtn.disabled) {{
|
||||
downloadBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'pause_download':
|
||||
const pauseBtn = document.querySelector('#pause-btn, [data-action="pause"]');
|
||||
if (pauseBtn && !pauseBtn.disabled) {{
|
||||
pauseBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'cancel_download':
|
||||
const cancelBtn = document.querySelector('#cancel-btn, [data-action="cancel"]');
|
||||
if (cancelBtn && !cancelBtn.disabled) {{
|
||||
cancelBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'select_all':
|
||||
const selectAllBtn = document.querySelector('#select-all-btn, [data-action="select-all"]');
|
||||
if (selectAllBtn) {{
|
||||
selectAllBtn.click();
|
||||
}} else {{
|
||||
this.selectAllItems();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'deselect_all':
|
||||
const deselectAllBtn = document.querySelector('#deselect-all-btn, [data-action="deselect-all"]');
|
||||
if (deselectAllBtn) {{
|
||||
deselectAllBtn.click();
|
||||
}} else {{
|
||||
this.deselectAllItems();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'next_item':
|
||||
this.navigateItems('next');
|
||||
return true;
|
||||
|
||||
case 'prev_item':
|
||||
this.navigateItems('prev');
|
||||
return true;
|
||||
|
||||
case 'close_modal':
|
||||
this.closeTopModal();
|
||||
return true;
|
||||
|
||||
case 'confirm_action':
|
||||
const confirmBtn = document.querySelector('.modal.show .btn-primary, .modal.show [data-confirm]');
|
||||
if (confirmBtn) {{
|
||||
confirmBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'cancel_action':
|
||||
const cancelActionBtn = document.querySelector('.modal.show .btn-secondary, .modal.show [data-cancel]');
|
||||
if (cancelActionBtn) {{
|
||||
cancelActionBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'toggle_details':
|
||||
this.toggleDetailView();
|
||||
return true;
|
||||
|
||||
case 'refresh_view':
|
||||
window.location.reload();
|
||||
return true;
|
||||
|
||||
case 'toggle_filters':
|
||||
const filterPanel = document.querySelector('#filter-panel, .filters');
|
||||
if (filterPanel) {{
|
||||
filterPanel.classList.toggle('show');
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'toggle_sort':
|
||||
const sortBtn = document.querySelector('#sort-btn, [data-action="sort"]');
|
||||
if (sortBtn) {{
|
||||
sortBtn.click();
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'quick_help':
|
||||
this.showHelpModal();
|
||||
return true;
|
||||
|
||||
case 'settings':
|
||||
const settingsBtn = document.querySelector('#settings-btn, [data-action="settings"]');
|
||||
if (settingsBtn) {{
|
||||
settingsBtn.click();
|
||||
}} else {{
|
||||
window.location.href = '/config';
|
||||
}}
|
||||
return true;
|
||||
|
||||
case 'logout':
|
||||
if (confirm('Are you sure you want to logout?')) {{
|
||||
window.location.href = '/logout';
|
||||
}}
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
|
||||
selectAllItems() {{
|
||||
const checkboxes = document.querySelectorAll('.series-checkbox, [data-selectable]');
|
||||
checkboxes.forEach(cb => {{
|
||||
if (cb.type === 'checkbox') {{
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
}} else {{
|
||||
cb.classList.add('selected');
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
deselectAllItems() {{
|
||||
const checkboxes = document.querySelectorAll('.series-checkbox, [data-selectable]');
|
||||
checkboxes.forEach(cb => {{
|
||||
if (cb.type === 'checkbox') {{
|
||||
cb.checked = false;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
}} else {{
|
||||
cb.classList.remove('selected');
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
navigateItems(direction) {{
|
||||
const items = document.querySelectorAll('.series-item, .list-item, [data-navigable]');
|
||||
const currentIndex = Array.from(items).findIndex(item =>
|
||||
item.classList.contains('focused') || item.classList.contains('active')
|
||||
);
|
||||
|
||||
let newIndex;
|
||||
if (direction === 'next') {{
|
||||
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
}} else {{
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
}}
|
||||
|
||||
// Remove focus from current item
|
||||
if (currentIndex >= 0) {{
|
||||
items[currentIndex].classList.remove('focused', 'active');
|
||||
}}
|
||||
|
||||
// Add focus to new item
|
||||
if (items[newIndex]) {{
|
||||
items[newIndex].classList.add('focused');
|
||||
items[newIndex].scrollIntoView({{ block: 'center' }});
|
||||
}}
|
||||
}}
|
||||
|
||||
closeTopModal() {{
|
||||
const modals = document.querySelectorAll('.modal.show');
|
||||
if (modals.length > 0) {{
|
||||
const topModal = modals[modals.length - 1];
|
||||
const closeBtn = topModal.querySelector('.btn-close, [data-bs-dismiss="modal"]');
|
||||
if (closeBtn) {{
|
||||
closeBtn.click();
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
toggleDetailView() {{
|
||||
const detailToggle = document.querySelector('[data-toggle="details"]');
|
||||
if (detailToggle) {{
|
||||
detailToggle.click();
|
||||
}} else {{
|
||||
document.body.classList.toggle('detailed-view');
|
||||
}}
|
||||
}}
|
||||
|
||||
createHelpModal() {{
|
||||
const helpModal = document.createElement('div');
|
||||
helpModal.className = 'modal fade';
|
||||
helpModal.id = 'keyboard-help-modal';
|
||||
helpModal.innerHTML = `
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Keyboard Shortcuts</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${{this.generateHelpContent()}}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(helpModal);
|
||||
}}
|
||||
|
||||
generateHelpContent() {{
|
||||
let html = '<div class="row">';
|
||||
const categories = {{
|
||||
'Navigation': ['home', 'search', 'queue', 'config', 'logs'],
|
||||
'Actions': ['rescan', 'start_download', 'pause_download', 'cancel_download'],
|
||||
'Selection': ['select_all', 'deselect_all', 'next_item', 'prev_item'],
|
||||
'View': ['toggle_details', 'refresh_view', 'toggle_filters', 'toggle_sort'],
|
||||
'General': ['quick_help', 'settings', 'logout']
|
||||
}};
|
||||
|
||||
Object.entries(categories).forEach(([category, actions]) => {{
|
||||
html += `<div class="col-md-6 mb-4">
|
||||
<h6>${{category}}</h6>
|
||||
<table class="table table-sm">`;
|
||||
|
||||
actions.forEach(action => {{
|
||||
const shortcuts = this.shortcuts[action] || [];
|
||||
const description = this.descriptions[action] || action;
|
||||
html += `<tr>
|
||||
<td><code>${{shortcuts.join('</code> or <code>')}}</code></td>
|
||||
<td>${{description}}</td>
|
||||
</tr>`;
|
||||
}});
|
||||
|
||||
html += '</table></div>';
|
||||
}});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}}
|
||||
|
||||
showHelpModal() {{
|
||||
const helpModal = new bootstrap.Modal(document.getElementById('keyboard-help-modal'));
|
||||
helpModal.show();
|
||||
}}
|
||||
|
||||
showKeyboardHints() {{
|
||||
// Add keyboard hint tooltips to buttons
|
||||
document.querySelectorAll('[data-action]').forEach(btn => {{
|
||||
const action = btn.dataset.action;
|
||||
const shortcuts = this.shortcuts[action];
|
||||
if (shortcuts && shortcuts.length > 0) {{
|
||||
const shortcut = shortcuts[0];
|
||||
const currentTitle = btn.title || '';
|
||||
btn.title = currentTitle + (currentTitle ? ' ' : '') + `(${{shortcut}})`;
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
enable() {{
|
||||
this.enabled = true;
|
||||
}}
|
||||
|
||||
disable() {{
|
||||
this.enabled = false;
|
||||
}}
|
||||
|
||||
setEnabled(enabled) {{
|
||||
this.enabled = enabled;
|
||||
}}
|
||||
|
||||
updateShortcuts(newShortcuts) {{
|
||||
if (newShortcuts && typeof newShortcuts === 'object') {{
|
||||
Object.assign(this.shortcuts, newShortcuts);
|
||||
}}
|
||||
}}
|
||||
|
||||
addCustomShortcut(action, keys, callback) {{
|
||||
this.shortcuts[action] = Array.isArray(keys) ? keys : [keys];
|
||||
this.customCallbacks = this.customCallbacks || {{}};
|
||||
this.customCallbacks[action] = callback;
|
||||
}}
|
||||
}}
|
||||
|
||||
// Initialize keyboard shortcuts when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
window.keyboardManager = new KeyboardShortcutManager();
|
||||
}});
|
||||
"""
|
||||
|
||||
def _format_shortcuts_for_js(self):
|
||||
"""Format shortcuts dictionary for JavaScript."""
|
||||
import json
|
||||
return json.dumps(self.shortcuts)
|
||||
|
||||
def _format_descriptions_for_js(self):
|
||||
"""Format descriptions dictionary for JavaScript."""
|
||||
import json
|
||||
return json.dumps(self.descriptions)
|
||||
|
||||
|
||||
# Export the keyboard shortcut manager
|
||||
keyboard_manager = KeyboardShortcutManager()
|
||||
1048
src/server/web/middleware/mobile_middleware.py
Normal file
1048
src/server/web/middleware/mobile_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1334
src/server/web/middleware/multi_screen_middleware.py
Normal file
1334
src/server/web/middleware/multi_screen_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1667
src/server/web/middleware/screen_reader_middleware.py
Normal file
1667
src/server/web/middleware/screen_reader_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
1244
src/server/web/middleware/touch_middleware.py
Normal file
1244
src/server/web/middleware/touch_middleware.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user