Aniworld/src/server/services/bulk_service.py
2025-10-12 18:05:31 +02:00

1122 lines
42 KiB
Python

"""
Bulk Operations Manager for Multiple Series Management
This module provides bulk operation capabilities for managing multiple series
simultaneously, including batch downloads, deletions, updates, and organization.
"""
from typing import List, Dict, Any, Optional, Set
import asyncio
import json
import os
from datetime import datetime
import threading
from concurrent.futures import ThreadPoolExecutor
import time
class BulkOperationsManager:
"""Manages bulk operations for multiple series."""
def __init__(self, app=None):
self.app = app
self.active_operations = {}
self.operation_history = []
self.max_concurrent_operations = 5
self.executor = ThreadPoolExecutor(max_workers=self.max_concurrent_operations)
def init_app(self, app):
"""Initialize with Flask app."""
self.app = app
def get_bulk_operations_js(self):
"""Generate JavaScript code for bulk operations functionality."""
return """
// AniWorld Bulk Operations Manager
class BulkOperationsManager {
constructor() {
this.selectedItems = new Set();
this.operations = new Map();
this.init();
}
init() {
this.setupSelectionControls();
this.setupBulkActions();
this.setupOperationProgress();
this.setupKeyboardShortcuts();
}
setupSelectionControls() {
// Add selection checkboxes to series items
const seriesItems = document.querySelectorAll('.series-item, .anime-card');
seriesItems.forEach(item => {
this.addSelectionCheckbox(item);
});
// Add bulk selection controls
this.createBulkSelectionBar();
// Setup click handlers
document.addEventListener('click', this.handleItemClick.bind(this));
document.addEventListener('change', this.handleCheckboxChange.bind(this));
}
addSelectionCheckbox(item) {
if (item.querySelector('.bulk-select-checkbox')) return;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'bulk-select-checkbox form-check-input position-absolute';
checkbox.style.top = '10px';
checkbox.style.left = '10px';
checkbox.style.zIndex = '10';
checkbox.dataset.itemId = item.dataset.seriesId || item.dataset.id;
item.style.position = 'relative';
item.appendChild(checkbox);
}
createBulkSelectionBar() {
const existingBar = document.querySelector('.bulk-selection-bar');
if (existingBar) return;
const selectionBar = document.createElement('div');
selectionBar.className = 'bulk-selection-bar bg-primary text-white p-3 mb-3 rounded d-none';
selectionBar.innerHTML = `
<div class="row align-items-center">
<div class="col-md-6">
<span class="selection-count">0 items selected</span>
<div class="btn-group ms-3">
<button class="btn btn-sm btn-outline-light" id="select-all">Select All</button>
<button class="btn btn-sm btn-outline-light" id="select-none">Clear</button>
<button class="btn btn-sm btn-outline-light" id="select-visible">Select Visible</button>
</div>
</div>
<div class="col-md-6 text-end">
<div class="btn-group">
<button class="btn btn-sm btn-success" id="bulk-download">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-sm btn-warning" id="bulk-update">
<i class="fas fa-sync"></i> Update
</button>
<button class="btn btn-sm btn-info" id="bulk-organize">
<i class="fas fa-folder-open"></i> Organize
</button>
<button class="btn btn-sm btn-secondary" id="bulk-export">
<i class="fas fa-file-export"></i> Export
</button>
<button class="btn btn-sm btn-danger" id="bulk-delete">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</div>
`;
const mainContent = document.querySelector('.main-content, .container-fluid');
if (mainContent) {
mainContent.insertBefore(selectionBar, mainContent.firstChild);
}
}
setupBulkActions() {
// Bulk action button handlers
document.addEventListener('click', (e) => {
if (e.target.id === 'select-all') this.selectAll();
else if (e.target.id === 'select-none') this.selectNone();
else if (e.target.id === 'select-visible') this.selectVisible();
else if (e.target.id === 'bulk-download') this.bulkDownload();
else if (e.target.id === 'bulk-update') this.bulkUpdate();
else if (e.target.id === 'bulk-organize') this.bulkOrganize();
else if (e.target.id === 'bulk-export') this.bulkExport();
else if (e.target.id === 'bulk-delete') this.bulkDelete();
});
}
setupOperationProgress() {
// Create progress tracking container
const progressContainer = document.createElement('div');
progressContainer.id = 'bulk-progress-container';
progressContainer.className = 'position-fixed bottom-0 end-0 p-3';
progressContainer.style.zIndex = '9998';
document.body.appendChild(progressContainer);
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch(e.key) {
case 'a':
if (this.selectedItems.size > 0) {
e.preventDefault();
this.selectAll();
}
break;
case 'd':
if (this.selectedItems.size > 0) {
e.preventDefault();
this.bulkDownload();
}
break;
}
}
});
}
handleItemClick(e) {
const item = e.target.closest('.series-item, .anime-card');
if (!item) return;
// Handle shift+click for range selection
if (e.shiftKey && this.selectedItems.size > 0) {
this.selectRange(item);
}
}
handleCheckboxChange(e) {
if (!e.target.classList.contains('bulk-select-checkbox')) return;
const itemId = e.target.dataset.itemId;
const item = e.target.closest('.series-item, .anime-card');
if (e.target.checked) {
this.selectedItems.add(itemId);
item.classList.add('selected');
} else {
this.selectedItems.delete(itemId);
item.classList.remove('selected');
}
this.updateSelectionUI();
}
selectAll() {
const checkboxes = document.querySelectorAll('.bulk-select-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = true;
this.selectedItems.add(checkbox.dataset.itemId);
checkbox.closest('.series-item, .anime-card').classList.add('selected');
});
this.updateSelectionUI();
}
selectNone() {
const checkboxes = document.querySelectorAll('.bulk-select-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = false;
checkbox.closest('.series-item, .anime-card').classList.remove('selected');
});
this.selectedItems.clear();
this.updateSelectionUI();
}
selectVisible() {
const visibleItems = document.querySelectorAll('.series-item:not(.d-none), .anime-card:not(.d-none)');
visibleItems.forEach(item => {
const checkbox = item.querySelector('.bulk-select-checkbox');
if (checkbox) {
checkbox.checked = true;
this.selectedItems.add(checkbox.dataset.itemId);
item.classList.add('selected');
}
});
this.updateSelectionUI();
}
selectRange(endItem) {
const items = Array.from(document.querySelectorAll('.series-item, .anime-card'));
const selectedItems = Array.from(document.querySelectorAll('.series-item.selected, .anime-card.selected'));
if (selectedItems.length === 0) return;
const lastSelected = selectedItems[selectedItems.length - 1];
const startIndex = items.indexOf(lastSelected);
const endIndex = items.indexOf(endItem);
const min = Math.min(startIndex, endIndex);
const max = Math.max(startIndex, endIndex);
for (let i = min; i <= max; i++) {
const item = items[i];
const checkbox = item.querySelector('.bulk-select-checkbox');
if (checkbox) {
checkbox.checked = true;
this.selectedItems.add(checkbox.dataset.itemId);
item.classList.add('selected');
}
}
this.updateSelectionUI();
}
updateSelectionUI() {
const count = this.selectedItems.size;
const selectionBar = document.querySelector('.bulk-selection-bar');
const countElement = document.querySelector('.selection-count');
if (count > 0) {
selectionBar.classList.remove('d-none');
countElement.textContent = `${count} item${count === 1 ? '' : 's'} selected`;
} else {
selectionBar.classList.add('d-none');
}
}
async bulkDownload() {
if (this.selectedItems.size === 0) return;
const confirmed = await this.confirmOperation(
'Bulk Download',
`Download ${this.selectedItems.size} selected series?`
);
if (!confirmed) return;
const operationId = this.startOperation('download', Array.from(this.selectedItems));
try {
const response = await fetch('/api/bulk/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
operation_id: operationId,
series_ids: Array.from(this.selectedItems)
})
});
const result = await response.json();
if (result.success) {
this.trackOperation(operationId, result.task_id);
} else {
this.showError('Failed to start bulk download');
this.completeOperation(operationId, false);
}
} catch (error) {
this.showError('Error starting bulk download: ' + error.message);
this.completeOperation(operationId, false);
}
}
async bulkUpdate() {
if (this.selectedItems.size === 0) return;
const confirmed = await this.confirmOperation(
'Bulk Update',
`Update metadata for ${this.selectedItems.size} selected series?`
);
if (!confirmed) return;
const operationId = this.startOperation('update', Array.from(this.selectedItems));
try {
const response = await fetch('/api/bulk/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
operation_id: operationId,
series_ids: Array.from(this.selectedItems)
})
});
const result = await response.json();
if (result.success) {
this.trackOperation(operationId, result.task_id);
} else {
this.showError('Failed to start bulk update');
this.completeOperation(operationId, false);
}
} catch (error) {
this.showError('Error starting bulk update: ' + error.message);
this.completeOperation(operationId, false);
}
}
async bulkOrganize() {
if (this.selectedItems.size === 0) return;
const options = await this.showOrganizeModal();
if (!options) return;
const operationId = this.startOperation('organize', Array.from(this.selectedItems));
try {
const response = await fetch('/api/bulk/organize', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
operation_id: operationId,
series_ids: Array.from(this.selectedItems),
options: options
})
});
const result = await response.json();
if (result.success) {
this.trackOperation(operationId, result.task_id);
} else {
this.showError('Failed to start bulk organization');
this.completeOperation(operationId, false);
}
} catch (error) {
this.showError('Error starting bulk organization: ' + error.message);
this.completeOperation(operationId, false);
}
}
async bulkExport() {
if (this.selectedItems.size === 0) return;
const format = await this.showExportModal();
if (!format) return;
const operationId = this.startOperation('export', Array.from(this.selectedItems));
try {
const response = await fetch('/api/bulk/export', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
operation_id: operationId,
series_ids: Array.from(this.selectedItems),
format: format
})
});
if (response.ok) {
const blob = await response.blob();
this.downloadFile(blob, `series_export_${Date.now()}.${format}`);
this.completeOperation(operationId, true);
} else {
this.showError('Failed to export series data');
this.completeOperation(operationId, false);
}
} catch (error) {
this.showError('Error exporting series data: ' + error.message);
this.completeOperation(operationId, false);
}
}
async bulkDelete() {
if (this.selectedItems.size === 0) return;
const confirmed = await this.confirmOperation(
'Bulk Delete',
`Permanently delete ${this.selectedItems.size} selected series?\\n\\nThis action cannot be undone!`,
'danger'
);
if (!confirmed) return;
const operationId = this.startOperation('delete', Array.from(this.selectedItems));
try {
const response = await fetch('/api/bulk/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
operation_id: operationId,
series_ids: Array.from(this.selectedItems)
})
});
const result = await response.json();
if (result.success) {
this.trackOperation(operationId, result.task_id);
// Remove deleted items from selection
this.selectedItems.clear();
this.updateSelectionUI();
} else {
this.showError('Failed to start bulk deletion');
this.completeOperation(operationId, false);
}
} catch (error) {
this.showError('Error starting bulk deletion: ' + error.message);
this.completeOperation(operationId, false);
}
}
startOperation(type, itemIds) {
const operationId = 'op_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
this.operations.set(operationId, {
type: type,
itemIds: itemIds,
startTime: Date.now(),
status: 'running'
});
this.showOperationProgress(operationId, type, itemIds.length);
return operationId;
}
trackOperation(operationId, taskId) {
const operation = this.operations.get(operationId);
if (!operation) return;
operation.taskId = taskId;
// Poll for progress updates
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/bulk/status/${taskId}`);
const status = await response.json();
this.updateOperationProgress(operationId, status);
if (status.complete) {
clearInterval(pollInterval);
this.completeOperation(operationId, status.success);
}
} catch (error) {
console.error('Error polling operation status:', error);
clearInterval(pollInterval);
this.completeOperation(operationId, false);
}
}, 1000);
operation.pollInterval = pollInterval;
}
showOperationProgress(operationId, type, count) {
const progressContainer = document.getElementById('bulk-progress-container');
const progressCard = document.createElement('div');
progressCard.id = `progress-${operationId}`;
progressCard.className = 'card mb-2';
progressCard.style.width = '300px';
progressCard.innerHTML = `
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted">${type.charAt(0).toUpperCase() + type.slice(1)} ${count} items</small>
<button class="btn btn-sm btn-outline-secondary" onclick="bulkOpsManager.cancelOperation('${operationId}')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="progress progress-sm">
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
<small class="operation-status text-muted">Starting...</small>
</div>
`;
progressContainer.appendChild(progressCard);
}
updateOperationProgress(operationId, status) {
const progressCard = document.getElementById(`progress-${operationId}`);
if (!progressCard) return;
const progressBar = progressCard.querySelector('.progress-bar');
const statusText = progressCard.querySelector('.operation-status');
const percentage = Math.round((status.completed / status.total) * 100);
progressBar.style.width = `${percentage}%`;
statusText.textContent = status.message || `${status.completed}/${status.total} completed`;
if (status.error) {
progressBar.classList.add('bg-danger');
statusText.textContent = 'Error: ' + status.error;
}
}
completeOperation(operationId, success) {
const operation = this.operations.get(operationId);
if (!operation) return;
// Clear polling if active
if (operation.pollInterval) {
clearInterval(operation.pollInterval);
}
operation.status = success ? 'completed' : 'failed';
operation.endTime = Date.now();
const progressCard = document.getElementById(`progress-${operationId}`);
if (progressCard) {
const progressBar = progressCard.querySelector('.progress-bar');
const statusText = progressCard.querySelector('.operation-status');
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
if (success) {
progressBar.classList.add('bg-success');
progressBar.style.width = '100%';
statusText.textContent = 'Completed successfully';
} else {
progressBar.classList.add('bg-danger');
statusText.textContent = 'Operation failed';
}
// Auto-remove after 5 seconds
setTimeout(() => {
if (progressCard.parentNode) {
progressCard.parentNode.removeChild(progressCard);
}
}, 5000);
}
this.operations.delete(operationId);
}
cancelOperation(operationId) {
const operation = this.operations.get(operationId);
if (!operation) return;
if (operation.taskId) {
fetch(`/api/bulk/cancel/${operation.taskId}`, {
method: 'POST'
});
}
this.completeOperation(operationId, false);
}
confirmOperation(title, message, type = 'primary') {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>${message.replace(/\\n/g, '<br>')}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-${type}" id="confirm-operation">Confirm</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
modal.querySelector('#confirm-operation').addEventListener('click', () => {
resolve(true);
bsModal.hide();
});
modal.addEventListener('hidden.bs.modal', () => {
if (modal.parentNode) {
document.body.removeChild(modal);
}
resolve(false);
});
});
}
showOrganizeModal() {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Organize Series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Organization Method</label>
<select class="form-select" id="organize-method">
<option value="genre">By Genre</option>
<option value="year">By Year</option>
<option value="status">By Status</option>
<option value="custom">Custom Folders</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Target Directory</label>
<input type="text" class="form-control" id="target-dir" placeholder="Leave empty for default">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="create-symlinks">
<label class="form-check-label" for="create-symlinks">
Create symbolic links instead of moving files
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-organize">Organize</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
modal.querySelector('#confirm-organize').addEventListener('click', () => {
const options = {
method: modal.querySelector('#organize-method').value,
targetDir: modal.querySelector('#target-dir').value,
createSymlinks: modal.querySelector('#create-symlinks').checked
};
resolve(options);
bsModal.hide();
});
modal.addEventListener('hidden.bs.modal', () => {
if (modal.parentNode) {
document.body.removeChild(modal);
}
resolve(null);
});
});
}
showExportModal() {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Export Series Data</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Export Format</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="export-format" id="format-json" value="json" checked>
<label class="form-check-label" for="format-json">JSON</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="export-format" id="format-csv" value="csv">
<label class="form-check-label" for="format-csv">CSV</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="export-format" id="format-xml" value="xml">
<label class="form-check-label" for="format-xml">XML</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-export">Export</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
modal.querySelector('#confirm-export').addEventListener('click', () => {
const format = modal.querySelector('input[name="export-format"]:checked').value;
resolve(format);
bsModal.hide();
});
modal.addEventListener('hidden.bs.modal', () => {
if (modal.parentNode) {
document.body.removeChild(modal);
}
resolve(null);
});
});
}
downloadFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
showError(message) {
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-white bg-danger';
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', () => {
if (toast.parentNode) {
toastContainer.removeChild(toast);
}
});
}
}
// Initialize bulk operations when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.bulkOpsManager = new BulkOperationsManager();
});
"""
def get_css(self):
"""Generate CSS styles for bulk operations."""
return """
/* Bulk Operations Styles */
.bulk-selection-bar {
animation: slideDown 0.3s ease-out;
border-left: 4px solid #0d6efd;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bulk-select-checkbox {
opacity: 0;
transition: opacity 0.2s ease;
}
.series-item:hover .bulk-select-checkbox,
.anime-card:hover .bulk-select-checkbox,
.bulk-select-checkbox:checked {
opacity: 1;
}
.series-item.selected,
.anime-card.selected {
background-color: rgba(13, 110, 253, 0.1);
border: 2px solid #0d6efd;
border-radius: 8px;
}
#bulk-progress-container {
max-height: 400px;
overflow-y: auto;
}
.progress-sm {
height: 0.5rem;
}
.operation-status {
font-size: 0.75rem;
}
.btn-group .btn {
white-space: nowrap;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.bulk-selection-bar .row {
flex-direction: column;
}
.bulk-selection-bar .col-md-6 {
margin-bottom: 1rem;
text-align: center;
}
.bulk-selection-bar .text-end {
text-align: center !important;
}
.btn-group {
flex-wrap: wrap;
justify-content: center;
}
.btn-group .btn {
margin: 0.25rem;
}
}
/* Accessibility */
.bulk-select-checkbox:focus {
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
@media (prefers-reduced-motion: reduce) {
.bulk-selection-bar {
animation: none;
}
}
"""
async def bulk_download(self, series_ids: List[str], operation_id: str) -> Dict[str, Any]:
"""Execute bulk download operation."""
try:
results = []
total = len(series_ids)
completed = 0
for series_id in series_ids:
try:
# Update progress
await self.update_operation_progress(operation_id, completed, total, f"Downloading {series_id}")
# Simulate download (replace with actual download logic)
await asyncio.sleep(1) # Simulated work
results.append({
'series_id': series_id,
'status': 'success',
'message': 'Download completed'
})
completed += 1
except Exception as e:
results.append({
'series_id': series_id,
'status': 'error',
'message': str(e)
})
await self.update_operation_progress(operation_id, total, total, "All downloads completed")
return {
'success': True,
'results': results,
'completed': completed,
'total': total
}
except Exception as e:
return {
'success': False,
'error': str(e),
'completed': completed,
'total': total
}
async def bulk_update(self, series_ids: List[str], operation_id: str) -> Dict[str, Any]:
"""Execute bulk update operation."""
try:
results = []
total = len(series_ids)
completed = 0
for series_id in series_ids:
try:
await self.update_operation_progress(operation_id, completed, total, f"Updating {series_id}")
# Simulate update (replace with actual update logic)
await asyncio.sleep(0.5)
results.append({
'series_id': series_id,
'status': 'success',
'message': 'Metadata updated'
})
completed += 1
except Exception as e:
results.append({
'series_id': series_id,
'status': 'error',
'message': str(e)
})
await self.update_operation_progress(operation_id, total, total, "All updates completed")
return {
'success': True,
'results': results,
'completed': completed,
'total': total
}
except Exception as e:
return {
'success': False,
'error': str(e),
'completed': completed,
'total': total
}
async def bulk_organize(self, series_ids: List[str], options: Dict[str, Any], operation_id: str) -> Dict[str, Any]:
"""Execute bulk organize operation."""
try:
results = []
total = len(series_ids)
completed = 0
method = options.get('method', 'genre')
target_dir = options.get('targetDir', '')
create_symlinks = options.get('createSymlinks', False)
for series_id in series_ids:
try:
await self.update_operation_progress(operation_id, completed, total, f"Organizing {series_id}")
# Simulate organization (replace with actual logic)
await asyncio.sleep(0.3)
results.append({
'series_id': series_id,
'status': 'success',
'message': f'Organized by {method}'
})
completed += 1
except Exception as e:
results.append({
'series_id': series_id,
'status': 'error',
'message': str(e)
})
await self.update_operation_progress(operation_id, total, total, "Organization completed")
return {
'success': True,
'results': results,
'completed': completed,
'total': total
}
except Exception as e:
return {
'success': False,
'error': str(e),
'completed': completed,
'total': total
}
async def bulk_delete(self, series_ids: List[str], operation_id: str) -> Dict[str, Any]:
"""Execute bulk delete operation."""
try:
results = []
total = len(series_ids)
completed = 0
for series_id in series_ids:
try:
await self.update_operation_progress(operation_id, completed, total, f"Deleting {series_id}")
# Simulate deletion (replace with actual deletion logic)
await asyncio.sleep(0.2)
results.append({
'series_id': series_id,
'status': 'success',
'message': 'Series deleted'
})
completed += 1
except Exception as e:
results.append({
'series_id': series_id,
'status': 'error',
'message': str(e)
})
await self.update_operation_progress(operation_id, total, total, "Deletion completed")
return {
'success': True,
'results': results,
'completed': completed,
'total': total
}
except Exception as e:
return {
'success': False,
'error': str(e),
'completed': completed,
'total': total
}
async def export_series_data(self, series_ids: List[str], format: str) -> bytes:
"""Export series data in specified format."""
# This would implement actual data export logic
# For now, return dummy data
if format == 'json':
data = {
'series': [{'id': sid, 'name': f'Series {sid}'} for sid in series_ids],
'exported_at': datetime.now().isoformat()
}
return json.dumps(data, indent=2).encode('utf-8')
elif format == 'csv':
import csv
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['ID', 'Name', 'Status'])
for sid in series_ids:
writer.writerow([sid, f'Series {sid}', 'Active'])
return output.getvalue().encode('utf-8')
elif format == 'xml':
xml_data = '<?xml version="1.0" encoding="UTF-8"?>\n<series>\n'
for sid in series_ids:
xml_data += f' <item id="{sid}" name="Series {sid}" />\n'
xml_data += '</series>'
return xml_data.encode('utf-8')
return b''
async def update_operation_progress(self, operation_id: str, completed: int, total: int, message: str):
"""Update operation progress (implement with WebSocket or polling)."""
# This would send progress updates to the frontend
# For now, just store the progress
pass
# Export the bulk operations manager
bulk_operations_manager = BulkOperationsManager()