Aniworld/src/server/api/download.py
Lukas 27108aacda Fix architecture issues from todolist
- Add documentation warnings for in-memory rate limiting and failed login attempts
- Consolidate duplicate health endpoints into api/health.py
- Fix CLI to use correct async rescan method names
- Update download.py and anime.py to use custom exception classes
- Add WebSocket room validation and rate limiting
2025-12-15 14:23:41 +01:00

505 lines
15 KiB
Python

"""Download queue API endpoints for Aniworld web application.
This module provides REST API endpoints for managing the anime download queue,
including adding episodes, removing items, controlling queue processing, and
retrieving queue status and statistics.
"""
from fastapi import APIRouter, Depends, Path, status
from fastapi.responses import JSONResponse
from src.server.exceptions import BadRequestError, NotFoundError, ServerError
from src.server.models.download import (
DownloadRequest,
QueueOperationRequest,
QueueStatusResponse,
)
from src.server.services.download_service import DownloadService, DownloadServiceError
from src.server.utils.dependencies import get_download_service, require_auth
router = APIRouter(prefix="/api/queue", tags=["download"])
@router.get("/status", response_model=QueueStatusResponse)
async def get_queue_status(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Get current download queue status and statistics.
Returns comprehensive information about all queue items including:
- Active downloads with progress
- Pending items waiting to be processed
- Recently completed downloads
- Failed downloads
Requires authentication.
Returns:
QueueStatusResponse: Complete queue status and statistics
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
queue_status = await download_service.get_queue_status()
queue_stats = await download_service.get_queue_stats()
# Build response matching QueueStatusResponse model
response = QueueStatusResponse(
status=queue_status,
statistics=queue_stats,
)
return response
except Exception as e:
raise ServerError(
message=f"Failed to retrieve queue status: {str(e)}"
)
@router.post("/add", status_code=status.HTTP_201_CREATED)
async def add_to_queue(
request: DownloadRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Add episodes to the download queue.
Adds one or more episodes to the download queue with specified priority.
Episodes are validated and queued for processing based on priority level:
- HIGH priority items are processed first
- NORMAL and LOW priority items follow FIFO order
Requires authentication.
Args:
request: Download request containing:
- serie_id: Series key (primary identifier, 'attack-on-titan')
- serie_folder: Filesystem folder name for storing downloads
- serie_name: Display name for the series
- episodes: List of episodes to download
- priority: Queue priority level
Returns:
DownloadResponse: Status and list of created download item IDs
Raises:
HTTPException: 401 if not authenticated, 400 for invalid request,
500 on service error
"""
try:
# Validate request
if not request.episodes:
raise BadRequestError(
message="At least one episode must be specified"
)
# Add to queue
added_ids = await download_service.add_to_queue(
serie_id=request.serie_id,
serie_folder=request.serie_folder,
serie_name=request.serie_name,
episodes=request.episodes,
priority=request.priority,
)
# Keep a backwards-compatible response shape and return it as a
# raw JSONResponse so FastAPI won't coerce it based on any
# response_model defined elsewhere.
payload = {
"status": "success",
"message": f"Added {len(added_ids)} episode(s) to download queue",
"added_items": added_ids,
"item_ids": added_ids,
"failed_items": [],
}
return JSONResponse(
content=payload,
status_code=status.HTTP_201_CREATED,
)
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise ServerError(
message=f"Failed to add episodes to queue: {str(e)}"
)
@router.delete("/completed", status_code=status.HTTP_200_OK)
async def clear_completed(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Clear completed downloads from history.
Removes all completed download items from the queue history. This helps
keep the queue display clean and manageable.
Requires authentication.
Returns:
dict: Status message with count of cleared items
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
cleared_count = await download_service.clear_completed()
return {
"status": "success",
"message": f"Cleared {cleared_count} completed item(s)",
"count": cleared_count,
}
except Exception as e:
raise ServerError(
message=f"Failed to clear completed items: {str(e)}"
)
@router.delete("/failed", status_code=status.HTTP_200_OK)
async def clear_failed(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Clear failed downloads from history.
Removes all failed download items from the queue history. This helps
keep the queue display clean and manageable.
Requires authentication.
Returns:
dict: Status message with count of cleared items
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
cleared_count = await download_service.clear_failed()
return {
"status": "success",
"message": f"Cleared {cleared_count} failed item(s)",
"count": cleared_count,
}
except Exception as e:
raise ServerError(
message=f"Failed to clear failed items: {str(e)}"
)
@router.delete("/pending", status_code=status.HTTP_200_OK)
async def clear_pending(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Clear all pending downloads from the queue.
Removes all pending download items from the queue. This is useful for
clearing the entire queue at once instead of removing items one by one.
Requires authentication.
Returns:
dict: Status message with count of cleared items
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
cleared_count = await download_service.clear_pending()
return {
"status": "success",
"message": f"Removed {cleared_count} pending item(s)",
"count": cleared_count,
}
except Exception as e:
raise ServerError(
message=f"Failed to clear pending items: {str(e)}"
)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_from_queue(
item_id: str = Path(..., description="Download item ID to remove"),
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Remove a specific item from the download queue.
Removes a download item from the queue. If the item is currently
downloading, it will be cancelled and marked as cancelled. If it's
pending, it will simply be removed from the queue.
Requires authentication.
Args:
item_id: Unique identifier of the download item to remove
Raises:
HTTPException: 401 if not authenticated, 404 if item not found,
500 on service error
"""
try:
removed_ids = await download_service.remove_from_queue([item_id])
if not removed_ids:
raise NotFoundError(
message=f"Download item {item_id} not found in queue",
resource_type="download_item",
resource_id=item_id
)
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise ServerError(
message=f"Failed to remove item from queue: {str(e)}"
)
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
async def remove_multiple_from_queue(
request: QueueOperationRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Remove multiple items from the download queue.
Removes multiple download items from the queue based on provided IDs.
Items that are currently downloading will be cancelled.
Requires authentication.
Args:
request: Request containing list of item IDs to remove
Raises:
HTTPException: 401 if not authenticated, 404 if no items found,
500 on service error
"""
try:
removed_ids = await download_service.remove_from_queue(
request.item_ids
)
if not removed_ids:
raise NotFoundError(
message="No matching items found in queue",
resource_type="download_items"
)
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise ServerError(
message=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 automatic queue processing.
Starts processing all pending downloads sequentially, one at a time.
The queue will continue processing until all items are complete or
the queue is manually stopped. Processing continues even if the browser
is closed.
Only one download can be active at a time. If a download is already
active or queue processing is running, an error is returned.
Requires authentication.
Returns:
dict: Status message confirming queue processing started
Raises:
HTTPException: 401 if not authenticated, 400 if queue is empty or
processing already active, 500 on service error
"""
try:
result = await download_service.start_queue_processing()
if result is None:
raise BadRequestError(
message="No pending downloads in queue"
)
return {
"status": "success",
"message": "Queue processing started",
}
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise ServerError(
message=f"Failed to start queue processing: {str(e)}"
)
@router.post("/stop", status_code=status.HTTP_200_OK)
async def stop_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Stop processing new downloads from queue.
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 processing has been stopped
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.stop_downloads()
return {
"status": "success",
"message": (
"Queue processing stopped (current download will continue)"
),
}
except Exception as e:
raise ServerError(
message=f"Failed to stop queue processing: {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 queue processing (alias for stop).
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 processing has been paused
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.stop_downloads()
return {
"status": "success",
"message": "Queue processing paused",
}
except Exception as e:
raise ServerError(
message=f"Failed to pause queue processing: {str(e)}"
)
@router.post("/reorder", status_code=status.HTTP_200_OK)
async def reorder_queue(
request: QueueOperationRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Reorder items in the pending queue.
Reorders the pending queue based on the provided list of item IDs.
Items will be placed in the order specified by the item_ids list.
Items not included in the list will remain at the end of the queue.
Requires authentication.
Args:
request: List of download item IDs in desired order
Returns:
dict: Status message
Raises:
HTTPException: 401 if not authenticated, 404 if no items match,
500 on service error
"""
try:
await download_service.reorder_queue(request.item_ids)
return {
"status": "success",
"message": f"Queue reordered with {len(request.item_ids)} items",
}
except Exception as e:
raise ServerError(
message=f"Failed to reorder queue: {str(e)}"
)
@router.post("/retry", status_code=status.HTTP_200_OK)
async def retry_failed(
request: QueueOperationRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Retry failed downloads.
Moves failed download items back to the pending queue for retry. Only items
that haven't exceeded the maximum retry count will be retried.
Requires authentication.
Args:
request: List of download item IDs to retry (empty list retries all)
Returns:
dict: Status message with count of retried items
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
# If no specific IDs provided, retry all failed items
item_ids = request.item_ids if request.item_ids else None
retried_ids = await download_service.retry_failed(item_ids)
return {
"status": "success",
"message": f"Retrying {len(retried_ids)} failed item(s)",
"retried_count": len(retried_ids),
"retried_ids": retried_ids,
}
except Exception as e:
raise ServerError(
message=f"Failed to retry downloads: {str(e)}"
)