download re implemented

This commit is contained in:
2025-10-30 22:06:41 +01:00
parent 6ebc2ed2ea
commit 3be175522f
16 changed files with 359 additions and 1335 deletions

View File

@@ -10,7 +10,6 @@ from fastapi.responses import JSONResponse
from src.server.models.download import (
DownloadRequest,
QueueOperationRequest,
QueueReorderRequest,
QueueStatusResponse,
)
from src.server.services.download_service import DownloadService, DownloadServiceError
@@ -283,39 +282,41 @@ async def remove_from_queue(
)
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
async def remove_multiple_from_queue(
request: QueueOperationRequest,
@router.post("/start", status_code=status.HTTP_200_OK)
async def start_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Remove multiple items from the download queue.
"""Start the next download from pending queue.
Batch removal of multiple download items. Each item is processed
individually, and the operation continues even if some items are not
found.
Manually starts the first pending download in the queue. Only one download
can be active at a time. If the queue is empty or a download is already
active, an error is returned.
Requires authentication.
Args:
request: List of download item IDs to remove
Returns:
dict: Status message with started item ID
Raises:
HTTPException: 401 if not authenticated, 400 for invalid request,
500 on service error
HTTPException: 401 if not authenticated, 400 if queue is empty or
download already active, 500 on service error
"""
try:
if not request.item_ids:
item_id = await download_service.start_next_download()
if item_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one item ID must be specified",
detail="No pending downloads in queue",
)
await download_service.remove_from_queue(request.item_ids)
# Note: We don't raise 404 if some items weren't found, as this is
# a batch operation and partial success is acceptable
return {
"status": "success",
"message": "Download started",
"item_id": item_id,
}
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -326,41 +327,7 @@ async def remove_multiple_from_queue(
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove items from queue: {str(e)}",
)
@router.post("/start", status_code=status.HTTP_200_OK)
async def start_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Start the download queue processor.
Starts processing the download queue. Downloads will be processed according
to priority and concurrency limits. If the queue is already running, this
operation is idempotent.
Requires authentication.
Returns:
dict: Status message indicating queue has been started
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.start()
return {
"status": "success",
"message": "Download queue processing started",
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start download queue: {str(e)}",
detail=f"Failed to start download: {str(e)}",
)
@@ -369,208 +336,34 @@ async def stop_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Stop the download queue processor.
"""Stop processing new downloads from queue.
Stops processing the download queue. Active downloads will be allowed to
complete (with a timeout), then the queue processor will shut down.
Queue state is persisted before shutdown.
Prevents new downloads from starting. The current active download will
continue to completion, but no new downloads will be started from the
pending queue.
Requires authentication.
Returns:
dict: Status message indicating queue has been stopped
dict: Status message indicating queue processing has been stopped
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.stop()
await download_service.stop_downloads()
return {
"status": "success",
"message": "Download queue processing stopped",
"message": (
"Queue processing stopped (current download will continue)"
),
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to stop download queue: {str(e)}",
)
@router.post("/pause", status_code=status.HTTP_200_OK)
async def pause_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Pause the download queue processor.
Pauses download processing. Active downloads will continue, but no new
downloads will be started until the queue is resumed.
Requires authentication.
Returns:
dict: Status message indicating queue has been paused
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.pause_queue()
return {
"status": "success",
"message": "Download queue paused",
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to pause download queue: {str(e)}",
)
@router.post("/resume", status_code=status.HTTP_200_OK)
async def resume_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Resume the download queue processor.
Resumes download processing after being paused. The queue will continue
processing pending items according to priority.
Requires authentication.
Returns:
dict: Status message indicating queue has been resumed
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.resume_queue()
return {
"status": "success",
"message": "Download queue resumed",
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to resume download queue: {str(e)}",
)
@router.post("/reorder", status_code=status.HTTP_200_OK)
async def reorder_queue(
request: dict,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Reorder items in the pending queue.
Changes the order of pending download items in the queue. This only
affects items that haven't started downloading yet. Supports both
bulk reordering with item_ids array and single item reorder.
Requires authentication.
Args:
request: Either {"item_ids": ["id1", "id2", ...]} for bulk reorder
or {"item_id": "id", "new_position": 0} for single item
Returns:
dict: Status message indicating items have been reordered
Raises:
HTTPException: 401 if not authenticated, 404 if item not found,
400 for invalid request, 500 on service error
"""
try:
# Support new bulk reorder payload: {"item_ids": ["id1", "id2", ...]}
if "item_ids" in request:
item_order = request.get("item_ids", [])
if not isinstance(item_order, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="item_ids must be a list of item IDs",
)
success = await download_service.reorder_queue_bulk(item_order)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or more items in item_ids were not found in pending queue",
)
return {
"status": "success",
"message": "Queue reordered successfully",
}
# Support legacy bulk reorder payload: {"item_order": ["id1", "id2", ...]}
elif "item_order" in request:
item_order = request.get("item_order", [])
if not isinstance(item_order, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="item_order must be a list of item IDs",
)
success = await download_service.reorder_queue_bulk(item_order)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or more items in item_order were not found in pending queue",
)
return {
"status": "success",
"message": "Queue item reordered successfully",
}
else:
# Fallback to single-item reorder shape
# Validate request
try:
req = QueueReorderRequest(**request)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)
success = await download_service.reorder_queue(
item_id=req.item_id,
new_position=req.new_position,
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {req.item_id} not found in pending queue",
)
return {
"status": "success",
"message": "Queue item reordered successfully",
}
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reorder queue item: {str(e)}",
detail=f"Failed to stop queue processing: {str(e)}",
)

View File

@@ -1,8 +1,8 @@
"""Download queue service for managing anime episode downloads.
This module provides a comprehensive queue management system for handling
concurrent anime episode downloads with priority-based scheduling, progress
tracking, persistence, and automatic retry functionality.
This module provides a simplified queue management system for handling
anime episode downloads with manual start/stop controls, progress tracking,
persistence, and retry functionality.
"""
from __future__ import annotations
@@ -41,11 +41,11 @@ class DownloadServiceError(Exception):
class DownloadService:
"""Manages the download queue with concurrent processing and persistence.
"""Manages the download queue with manual start/stop controls.
Features:
- Priority-based queue management
- Concurrent download processing
- Manual download start/stop
- FIFO queue processing
- Real-time progress tracking
- Queue persistence and recovery
- Automatic retry logic
@@ -55,7 +55,6 @@ class DownloadService:
def __init__(
self,
anime_service: AnimeService,
max_concurrent_downloads: int = 2,
max_retries: int = 3,
persistence_path: str = "./data/download_queue.json",
progress_service: Optional[ProgressService] = None,
@@ -64,13 +63,11 @@ class DownloadService:
Args:
anime_service: Service for anime operations
max_concurrent_downloads: Maximum simultaneous downloads
max_retries: Maximum retry attempts for failed downloads
persistence_path: Path to persist queue state
progress_service: Optional progress service for tracking
"""
self._anime_service = anime_service
self._max_concurrent = max_concurrent_downloads
self._max_retries = max_retries
self._persistence_path = Path(persistence_path)
self._progress_service = progress_service or get_progress_service()
@@ -79,19 +76,15 @@ class DownloadService:
self._pending_queue: deque[DownloadItem] = deque()
# Helper dict for O(1) lookup of pending items by ID
self._pending_items_by_id: Dict[str, DownloadItem] = {}
self._active_downloads: Dict[str, DownloadItem] = {}
self._active_download: Optional[DownloadItem] = None
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
# Control flags
self._is_running = False
self._is_paused = False
self._shutdown_event = asyncio.Event()
self._is_stopped = True # Queue processing is stopped by default
# Executor for blocking operations
self._executor = ThreadPoolExecutor(
max_workers=max_concurrent_downloads
)
self._executor = ThreadPoolExecutor(max_workers=1)
# WebSocket broadcast callback
self._broadcast_callback: Optional[Callable] = None
@@ -105,7 +98,6 @@ class DownloadService:
logger.info(
"DownloadService initialized",
max_concurrent=max_concurrent_downloads,
max_retries=max_retries,
)
@@ -212,14 +204,17 @@ class DownloadService:
try:
self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
active_items = (
[self._active_download] if self._active_download else []
)
data = {
"pending": [
item.model_dump(mode="json")
for item in self._pending_queue
],
"active": [
item.model_dump(mode="json")
for item in self._active_downloads.values()
item.model_dump(mode="json") for item in active_items
],
"failed": [
item.model_dump(mode="json")
@@ -242,13 +237,13 @@ class DownloadService:
episodes: List[EpisodeIdentifier],
priority: DownloadPriority = DownloadPriority.NORMAL,
) -> List[str]:
"""Add episodes to the download queue.
"""Add episodes to the download queue (FIFO order).
Args:
serie_id: Series identifier
serie_name: Series display name
episodes: List of episodes to download
priority: Queue priority level
priority: Queue priority level (ignored, kept for compatibility)
Returns:
List of created download item IDs
@@ -270,12 +265,8 @@ class DownloadService:
added_at=datetime.now(timezone.utc),
)
# Insert based on priority. High-priority downloads jump the
# line via appendleft so they execute before existing work;
# everything else is appended to preserve FIFO order.
self._add_to_pending_queue(
item, front=(priority == DownloadPriority.HIGH)
)
# Always append to end (FIFO order)
self._add_to_pending_queue(item, front=False)
created_ids.append(item.id)
@@ -285,7 +276,6 @@ class DownloadService:
serie=serie_name,
season=episode.season,
episode=episode.episode,
priority=priority.value,
)
self._save_queue()
@@ -324,12 +314,13 @@ class DownloadService:
try:
for item_id in item_ids:
# Check if item is currently downloading
if item_id in self._active_downloads:
item = self._active_downloads[item_id]
active = self._active_download
if active and active.id == item_id:
item = active
item.status = DownloadStatus.CANCELLED
item.completed_at = datetime.now(timezone.utc)
self._failed_items.append(item)
del self._active_downloads[item_id]
self._active_download = None
removed_ids.append(item_id)
logger.info("Cancelled active download", item_id=item_id)
continue
@@ -365,118 +356,81 @@ class DownloadService:
f"Failed to remove items: {str(e)}"
) from e
async def reorder_queue(self, item_id: str, new_position: int) -> bool:
"""Reorder an item in the pending queue.
Args:
item_id: Download item ID to reorder
new_position: New position in queue (0-based)
async def start_next_download(self) -> Optional[str]:
"""Manually start the next download from pending queue.
Returns:
True if reordering was successful
Item ID of started download, or None if queue is empty
Raises:
DownloadServiceError: If reordering fails
DownloadServiceError: If a download is already active
"""
try:
# Find and remove item - O(1) lookup using helper dict
item_to_move = self._pending_items_by_id.get(item_id)
if not item_to_move:
# Check if download already active
if self._active_download:
raise DownloadServiceError(
f"Item {item_id} not found in pending queue"
"A download is already in progress"
)
# Remove from current position
self._pending_queue.remove(item_to_move)
del self._pending_items_by_id[item_id]
# Check if queue is empty
if not self._pending_queue:
logger.info("No pending downloads to start")
return None
# Insert at new position
queue_list = list(self._pending_queue)
new_position = max(0, min(new_position, len(queue_list)))
queue_list.insert(new_position, item_to_move)
self._pending_queue = deque(queue_list)
# Re-add to helper dict
self._pending_items_by_id[item_id] = item_to_move
# Get first item from queue
item = self._pending_queue.popleft()
del self._pending_items_by_id[item.id]
self._save_queue()
# Mark queue as running
self._is_stopped = False
# Broadcast queue status update
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_status",
{
"action": "queue_reordered",
"item_id": item_id,
"new_position": new_position,
"queue_status": queue_status.model_dump(mode="json"),
},
)
# Start download in background
asyncio.create_task(self._process_download(item))
logger.info(
"Queue item reordered",
item_id=item_id,
new_position=new_position
"Started download manually",
item_id=item.id,
serie=item.serie_name
)
return True
except Exception as e:
logger.error("Failed to reorder queue", error=str(e))
raise DownloadServiceError(
f"Failed to reorder: {str(e)}"
) from e
async def reorder_queue_bulk(self, item_order: List[str]) -> bool:
"""Reorder pending queue to match provided item order for the specified
item IDs. Any pending items not mentioned will be appended after the
ordered items preserving their relative order.
Args:
item_order: Desired ordering of item IDs for pending queue
Returns:
True if operation completed
"""
try:
# Map existing pending items by id
existing = {item.id: item for item in list(self._pending_queue)}
new_queue: List[DownloadItem] = []
# Add items in the requested order if present
for item_id in item_order:
item = existing.pop(item_id, None)
if item:
new_queue.append(item)
# Append any remaining items preserving original order
for item in list(self._pending_queue):
if item.id in existing:
new_queue.append(item)
existing.pop(item.id, None)
# Replace pending queue
self._pending_queue = deque(new_queue)
self._save_queue()
# Broadcast queue status update
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_status",
"download_started",
{
"action": "queue_bulk_reordered",
"item_order": item_order,
"item_id": item.id,
"serie_name": item.serie_name,
"season": item.episode.season,
"episode": item.episode.episode,
"queue_status": queue_status.model_dump(mode="json"),
},
)
logger.info("Bulk queue reorder applied", ordered_count=len(item_order))
return True
return item.id
except Exception as e:
logger.error("Failed to apply bulk reorder", error=str(e))
raise DownloadServiceError(f"Failed to reorder: {str(e)}") from e
logger.error("Failed to start download", error=str(e))
raise DownloadServiceError(
f"Failed to start download: {str(e)}"
) from e
async def stop_downloads(self) -> None:
"""Stop processing new downloads from queue.
Current download will continue, but no new downloads will start.
"""
self._is_stopped = True
logger.info("Download processing stopped")
# Broadcast queue status update
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_stopped",
{
"is_stopped": True,
"queue_status": queue_status.model_dump(mode="json"),
},
)
async def get_queue_status(self) -> QueueStatus:
"""Get current status of all queues.
@@ -484,10 +438,13 @@ class DownloadService:
Returns:
Complete queue status with all items
"""
active_downloads = (
[self._active_download] if self._active_download else []
)
return QueueStatus(
is_running=self._is_running,
is_paused=self._is_paused,
active_downloads=list(self._active_downloads.values()),
is_running=not self._is_stopped,
is_paused=False, # Kept for compatibility
active_downloads=active_downloads,
pending_queue=list(self._pending_queue),
completed_downloads=list(self._completed_items),
failed_downloads=list(self._failed_items),
@@ -499,7 +456,7 @@ class DownloadService:
Returns:
Statistics about the download queue
"""
active_count = len(self._active_downloads)
active_count = 1 if self._active_download else 0
pending_count = len(self._pending_queue)
completed_count = len(self._completed_items)
failed_count = len(self._failed_items)
@@ -532,36 +489,6 @@ class DownloadService:
estimated_time_remaining=eta_seconds,
)
async def pause_queue(self) -> None:
"""Pause download processing."""
self._is_paused = True
logger.info("Download queue paused")
# Broadcast queue status update
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_paused",
{
"is_paused": True,
"queue_status": queue_status.model_dump(mode="json"),
},
)
async def resume_queue(self) -> None:
"""Resume download processing."""
self._is_paused = False
logger.info("Download queue resumed")
# Broadcast queue status update
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_resumed",
{
"is_paused": False,
"queue_status": queue_status.model_dump(mode="json"),
},
)
async def clear_completed(self) -> int:
"""Clear completed downloads from history.
@@ -742,7 +669,7 @@ class DownloadService:
# Update status
item.status = DownloadStatus.DOWNLOADING
item.started_at = datetime.now(timezone.utc)
self._active_downloads[item.id] = item
self._active_download = item
logger.info(
"Starting download",
@@ -858,83 +785,31 @@ class DownloadService:
finally:
# Remove from active downloads
if item.id in self._active_downloads:
del self._active_downloads[item.id]
if self._active_download and self._active_download.id == item.id:
self._active_download = None
self._save_queue()
async def _queue_processor(self) -> None:
"""Main queue processing loop."""
logger.info("Queue processor started")
while not self._shutdown_event.is_set():
try:
# Wait if paused
if self._is_paused:
await asyncio.sleep(1)
continue
# Check if we can start more downloads
if len(self._active_downloads) >= self._max_concurrent:
await asyncio.sleep(1)
continue
# Get next item from queue
if not self._pending_queue:
await asyncio.sleep(1)
continue
item = self._pending_queue.popleft()
# Process download in background
asyncio.create_task(self._process_download(item))
except Exception as e:
logger.error("Queue processor error", error=str(e))
await asyncio.sleep(5)
logger.info("Queue processor stopped")
async def start(self) -> None:
"""Start the download queue processor."""
if self._is_running:
logger.warning("Queue processor already running")
return
"""Initialize the download queue service (compatibility method).
self._is_running = True
self._shutdown_event.clear()
# Start processor task
asyncio.create_task(self._queue_processor())
logger.info("Download queue service started")
# Broadcast queue started event
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_started",
{
"is_running": True,
"queue_status": queue_status.model_dump(mode="json"),
},
)
Note: Downloads are started manually via start_next_download().
"""
logger.info("Download queue service initialized")
async def stop(self) -> None:
"""Stop the download queue processor."""
if not self._is_running:
return
"""Stop the download queue service and wait for active download.
Note: This waits for the current download to complete.
"""
logger.info("Stopping download queue service...")
self._is_running = False
self._shutdown_event.set()
# Wait for active downloads to complete (with timeout)
# Wait for active download to complete (with timeout)
timeout = 30 # seconds
start_time = asyncio.get_event_loop().time()
while (
self._active_downloads
self._active_download
and (asyncio.get_event_loop().time() - start_time) < timeout
):
await asyncio.sleep(1)
@@ -946,16 +821,6 @@ class DownloadService:
self._executor.shutdown(wait=True)
logger.info("Download queue service stopped")
# Broadcast queue stopped event
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_stopped",
{
"is_running": False,
"queue_status": queue_status.model_dump(mode="json"),
},
)
# Singleton instance

View File

@@ -505,19 +505,6 @@ class AniWorldApp {
this.hideStatus();
});
// Download controls
document.getElementById('pause-download').addEventListener('click', () => {
this.pauseDownload();
});
document.getElementById('resume-download').addEventListener('click', () => {
this.resumeDownload();
});
document.getElementById('cancel-download').addEventListener('click', () => {
this.cancelDownload();
});
// Logout functionality
document.getElementById('logout-btn').addEventListener('click', () => {
this.logout();
@@ -2006,57 +1993,6 @@ class AniWorldApp {
}
}
async pauseDownload() {
if (!this.isDownloading || this.isPaused) return;
try {
const response = await this.makeAuthenticatedRequest('/api/queue/pause', { method: 'POST' });
if (!response) return;
const data = await response.json();
document.getElementById('pause-download').classList.add('hidden');
document.getElementById('resume-download').classList.remove('hidden');
this.showToast('Queue paused', 'warning');
} catch (error) {
console.error('Pause error:', error);
this.showToast('Failed to pause queue', 'error');
}
}
async resumeDownload() {
if (!this.isDownloading || !this.isPaused) return;
try {
const response = await this.makeAuthenticatedRequest('/api/queue/resume', { method: 'POST' });
if (!response) return;
const data = await response.json();
document.getElementById('pause-download').classList.remove('hidden');
document.getElementById('resume-download').classList.add('hidden');
this.showToast('Queue resumed', 'success');
} catch (error) {
console.error('Resume error:', error);
this.showToast('Failed to resume queue', 'error');
}
}
async cancelDownload() {
if (!this.isDownloading) return;
if (confirm('Are you sure you want to stop the download queue?')) {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/stop', { method: 'POST' });
if (!response) return;
const data = await response.json();
this.showToast('Queue stopped', 'warning');
} catch (error) {
console.error('Stop error:', error);
this.showToast('Failed to stop queue', 'error');
}
}
}
showDownloadQueue(data) {
const queueSection = document.getElementById('download-queue-section');
const queueProgress = document.getElementById('queue-progress');

View File

@@ -6,9 +6,6 @@ class QueueManager {
constructor() {
this.socket = null;
this.refreshInterval = null;
this.isReordering = false;
this.draggedElement = null;
this.draggedId = null;
this.init();
}
@@ -19,7 +16,6 @@ class QueueManager {
this.initTheme();
this.startRefreshTimer();
this.loadQueueData();
this.initDragAndDrop();
}
initSocket() {
@@ -131,10 +127,6 @@ class QueueManager {
});
// Queue management actions
document.getElementById('clear-queue-btn').addEventListener('click', () => {
this.clearQueue('pending');
});
document.getElementById('clear-completed-btn').addEventListener('click', () => {
this.clearQueue('completed');
});
@@ -149,11 +141,11 @@ class QueueManager {
// Download controls
document.getElementById('start-queue-btn').addEventListener('click', () => {
this.startDownloadQueue();
this.startDownload();
});
document.getElementById('stop-queue-btn').addEventListener('click', () => {
this.stopDownloadQueue();
this.stopDownloads();
});
// Modal events
@@ -326,23 +318,15 @@ class QueueManager {
}
container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join('');
// Re-attach drag and drop event listeners
this.attachDragListeners();
}
createPendingQueueCard(download, index) {
const addedAt = new Date(download.added_at).toLocaleString();
const priorityClass = download.priority === 'high' ? 'high-priority' : '';
return `
<div class="download-card pending ${priorityClass} draggable-item"
<div class="download-card pending"
data-id="${download.id}"
data-index="${index}"
draggable="true">
<div class="drag-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</div>
data-index="${index}">
<div class="queue-position">${index + 1}</div>
<div class="download-header">
<div class="download-info">
@@ -351,7 +335,6 @@ class QueueManager {
<small>Added: ${addedAt}</small>
</div>
<div class="download-actions">
${download.priority === 'high' ? '<i class="fas fa-arrow-up priority-indicator" title="High Priority"></i>' : ''}
<button class="btn btn-small btn-secondary" onclick="queueManager.removeFromQueue('${download.id}')">
<i class="fas fa-trash"></i>
</button>
@@ -470,13 +453,11 @@ class QueueManager {
async clearQueue(type) {
const titles = {
pending: 'Clear Queue',
completed: 'Clear Completed Downloads',
failed: 'Clear Failed Downloads'
};
const messages = {
pending: 'Are you sure you want to clear all pending downloads from the queue?',
completed: 'Are you sure you want to clear all completed downloads?',
failed: 'Are you sure you want to clear all failed downloads?'
};
@@ -505,26 +486,6 @@ class QueueManager {
this.showToast(`Cleared ${data.count} failed downloads`, 'success');
this.loadQueueData();
} else if (type === 'pending') {
// Get all pending items
const pendingCards = document.querySelectorAll('#pending-queue .download-card.pending');
const itemIds = Array.from(pendingCards).map(card => card.dataset.id).filter(id => id);
if (itemIds.length === 0) {
this.showToast('No pending items to clear', 'info');
return;
}
const response = await this.makeAuthenticatedRequest('/api/queue/', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_ids: itemIds })
});
if (!response) return;
this.showToast(`Cleared ${itemIds.length} pending items`, 'success');
this.loadQueueData();
}
} catch (error) {
@@ -617,7 +578,7 @@ class QueueManager {
return `${minutes}m ${seconds}s`;
}
async startDownloadQueue() {
async startDownload() {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/start', {
method: 'POST'
@@ -627,22 +588,24 @@ class QueueManager {
const data = await response.json();
if (data.status === 'success') {
this.showToast('Download queue started', 'success');
this.showToast('Download started', 'success');
// Update UI
document.getElementById('start-queue-btn').style.display = 'none';
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
document.getElementById('stop-queue-btn').disabled = false;
this.loadQueueData(); // Refresh display
} else {
this.showToast(`Failed to start queue: ${data.message}`, 'error');
this.showToast(`Failed to start download: ${data.message || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error starting download queue:', error);
this.showToast('Failed to start download queue', 'error');
console.error('Error starting download:', error);
this.showToast('Failed to start download', 'error');
}
}
async stopDownloadQueue() {
async stopDownloads() {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/stop', {
method: 'POST'
@@ -652,156 +615,20 @@ class QueueManager {
const data = await response.json();
if (data.status === 'success') {
this.showToast('Download queue stopped', 'success');
this.showToast('Queue processing stopped', 'success');
// Update UI
document.getElementById('stop-queue-btn').style.display = 'none';
document.getElementById('start-queue-btn').style.display = 'inline-flex';
document.getElementById('start-queue-btn').disabled = false;
this.loadQueueData(); // Refresh display
} else {
this.showToast(`Failed to stop queue: ${data.message}`, 'error');
}
} catch (error) {
console.error('Error stopping download queue:', error);
this.showToast('Failed to stop download queue', 'error');
}
}
initDragAndDrop() {
// Initialize drag and drop on the pending queue container
const container = document.getElementById('pending-queue');
if (container) {
container.addEventListener('dragover', this.handleDragOver.bind(this));
container.addEventListener('drop', this.handleDrop.bind(this));
}
}
attachDragListeners() {
// Attach listeners to all draggable items
const items = document.querySelectorAll('.draggable-item');
items.forEach(item => {
item.addEventListener('dragstart', this.handleDragStart.bind(this));
item.addEventListener('dragend', this.handleDragEnd.bind(this));
item.addEventListener('dragenter', this.handleDragEnter.bind(this));
item.addEventListener('dragleave', this.handleDragLeave.bind(this));
});
}
handleDragStart(e) {
this.draggedElement = e.currentTarget;
this.draggedId = e.currentTarget.dataset.id;
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', e.currentTarget.innerHTML);
}
handleDragEnd(e) {
e.currentTarget.classList.remove('dragging');
// Remove all drag-over classes
document.querySelectorAll('.drag-over').forEach(item => {
item.classList.remove('drag-over');
});
}
handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
handleDragEnter(e) {
if (e.currentTarget.classList.contains('draggable-item') &&
e.currentTarget !== this.draggedElement) {
e.currentTarget.classList.add('drag-over');
}
}
handleDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
async handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
e.preventDefault();
// Get the target element (the item we dropped onto)
let target = e.target;
while (target && !target.classList.contains('draggable-item')) {
target = target.parentElement;
if (target === document.getElementById('pending-queue')) {
return false;
}
}
if (!target || target === this.draggedElement) {
return false;
}
// Get all items to determine new order
const container = document.getElementById('pending-queue');
const items = Array.from(container.querySelectorAll('.draggable-item'));
const draggedIndex = items.indexOf(this.draggedElement);
const targetIndex = items.indexOf(target);
if (draggedIndex === targetIndex) {
return false;
}
// Reorder visually
if (draggedIndex < targetIndex) {
target.parentNode.insertBefore(this.draggedElement, target.nextSibling);
} else {
target.parentNode.insertBefore(this.draggedElement, target);
}
// Update position numbers
const updatedItems = Array.from(container.querySelectorAll('.draggable-item'));
updatedItems.forEach((item, index) => {
const posElement = item.querySelector('.queue-position');
if (posElement) {
posElement.textContent = index + 1;
}
item.dataset.index = index;
});
// Get the new order of IDs
const newOrder = updatedItems.map(item => item.dataset.id);
// Send reorder request to backend
await this.reorderQueue(newOrder);
return false;
}
async reorderQueue(newOrder) {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_ids: newOrder })
});
if (!response) return;
if (response.ok) {
this.showToast('Queue reordered successfully', 'success');
} else {
const data = await response.json();
this.showToast(`Failed to reorder: ${data.detail || 'Unknown error'}`, 'error');
// Reload to restore correct order
this.loadQueueData();
}
} catch (error) {
console.error('Error reordering queue:', error);
this.showToast('Failed to reorder queue', 'error');
// Reload to restore correct order
this.loadQueueData();
console.error('Error stopping queue:', error);
this.showToast('Failed to stop queue', 'error');
}
}

View File

@@ -171,21 +171,7 @@
</div>
<div id="progress-text" class="progress-text">0%</div>
</div>
<div id="download-controls" class="download-controls hidden">
<button id="pause-download" class="btn btn-secondary btn-small">
<i class="fas fa-pause"></i>
<span data-text="pause">Pause</span>
</button>
<button id="resume-download" class="btn btn-primary btn-small hidden">
<i class="fas fa-play"></i>
<span data-text="resume">Resume</span>
</button>
<button id="cancel-download" class="btn btn-small"
style="background-color: var(--color-error); color: white;">
<i class="fas fa-stop"></i>
<span data-text="cancel">Cancel</span>
</button>
</div>
<!-- Download controls removed - use dedicated queue page -->
</div>
</div>

View File

@@ -126,20 +126,16 @@
<div class="section-actions">
<button id="start-queue-btn" class="btn btn-primary" disabled>
<i class="fas fa-play"></i>
Start Downloads
Start
</button>
<button id="stop-queue-btn" class="btn btn-secondary" disabled style="display: none;">
<i class="fas fa-stop"></i>
Stop Downloads
</button>
<button id="clear-queue-btn" class="btn btn-secondary" disabled>
<i class="fas fa-trash"></i>
Clear Queue
Stop
</button>
</div>
</div>
<div class="pending-queue-list sortable-list" id="pending-queue" data-sortable="true">
<div class="pending-queue-list" id="pending-queue">
<div class="empty-state">
<i class="fas fa-list"></i>
<p>No items in queue</p>