download re implemented
This commit is contained in:
@@ -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)}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user