feat: Implement download queue API endpoints
- Add comprehensive REST API for download queue management
- Implement GET /api/queue/status endpoint with queue status and statistics
- Implement POST /api/queue/add for adding episodes to queue with priority support
- Implement DELETE /api/queue/{id} and DELETE /api/queue/ for removing items
- Implement POST /api/queue/start and /api/queue/stop for queue control
- Implement POST /api/queue/pause and /api/queue/resume for pause/resume
- Implement POST /api/queue/reorder for queue item reordering
- Implement DELETE /api/queue/completed for clearing completed items
- Implement POST /api/queue/retry for retrying failed downloads
- Add get_download_service and get_anime_service dependencies
- Register download router in FastAPI application
- Add comprehensive test suite for all endpoints
- All endpoints require JWT authentication
- Update infrastructure documentation
- Remove completed task from instructions.md
Follows REST conventions with proper error handling and status codes.
Tests cover success cases, error conditions, and authentication requirements.
This commit is contained in:
parent
028d91283e
commit
577c55f32a
@ -146,9 +146,17 @@ initialization.
|
||||
|
||||
### Download Management
|
||||
|
||||
- `GET /api/downloads` - Get download queue status
|
||||
- `DELETE /api/downloads/{id}` - Remove from queue
|
||||
- `POST /api/downloads/priority` - Change download priority
|
||||
- `GET /api/queue/status` - Get download queue status and statistics
|
||||
- `POST /api/queue/add` - Add episodes to download queue
|
||||
- `DELETE /api/queue/{id}` - Remove item from queue
|
||||
- `DELETE /api/queue/` - Remove multiple items from queue
|
||||
- `POST /api/queue/start` - Start download queue processing
|
||||
- `POST /api/queue/stop` - Stop download queue processing
|
||||
- `POST /api/queue/pause` - Pause queue processing
|
||||
- `POST /api/queue/resume` - Resume queue processing
|
||||
- `POST /api/queue/reorder` - Reorder pending queue items
|
||||
- `DELETE /api/queue/completed` - Clear completed downloads
|
||||
- `POST /api/queue/retry` - Retry failed downloads
|
||||
|
||||
### Search
|
||||
|
||||
@ -378,3 +386,96 @@ Notes:
|
||||
- Singleton instance pattern used via `get_download_service()` factory
|
||||
- Testing: Comprehensive unit tests in `tests/unit/test_download_service.py`
|
||||
cover queue operations, persistence, retry logic, and error handling
|
||||
|
||||
### Download Queue API Endpoints (October 2025)
|
||||
|
||||
Implemented comprehensive REST API endpoints for download queue management:
|
||||
|
||||
- **File**: `src/server/api/download.py`
|
||||
- **Router Prefix**: `/api/queue`
|
||||
- **Authentication**: All endpoints require JWT authentication via `require_auth` dependency
|
||||
|
||||
#### Implemented Endpoints
|
||||
|
||||
1. **GET /api/queue/status** - Retrieve complete queue status
|
||||
|
||||
- Returns: `QueueStatusResponse` with status and statistics
|
||||
- Includes: active downloads, pending items, completed/failed items, queue stats
|
||||
|
||||
2. **POST /api/queue/add** - Add episodes to download queue
|
||||
|
||||
- Request: `DownloadRequest` with serie info, episodes, and priority
|
||||
- Returns: `DownloadResponse` with added item IDs
|
||||
- Validates episode list is non-empty
|
||||
- Supports HIGH, NORMAL, and LOW priority levels
|
||||
|
||||
3. **DELETE /api/queue/{item_id}** - Remove single item from queue
|
||||
|
||||
- Returns: 204 No Content on success, 404 if item not found
|
||||
- Cancels active downloads if necessary
|
||||
|
||||
4. **DELETE /api/queue/** - Remove multiple items (batch operation)
|
||||
|
||||
- Request: `QueueOperationRequest` with list of item IDs
|
||||
- Returns: 204 No Content (partial success acceptable)
|
||||
|
||||
5. **POST /api/queue/start** - Start queue processor
|
||||
|
||||
- Idempotent operation (safe to call multiple times)
|
||||
|
||||
6. **POST /api/queue/stop** - Stop queue processor
|
||||
|
||||
- Waits for active downloads to complete (with timeout)
|
||||
|
||||
7. **POST /api/queue/pause** - Pause queue processing
|
||||
|
||||
- Active downloads continue, no new downloads start
|
||||
|
||||
8. **POST /api/queue/resume** - Resume queue processing
|
||||
|
||||
9. **POST /api/queue/reorder** - Reorder pending queue item
|
||||
|
||||
- Request: `QueueReorderRequest` with item_id and new_position
|
||||
- Returns: 404 if item not in pending queue
|
||||
|
||||
10. **DELETE /api/queue/completed** - Clear completed items from history
|
||||
|
||||
- Returns count of cleared items
|
||||
|
||||
11. **POST /api/queue/retry** - Retry failed downloads
|
||||
- Request: `QueueOperationRequest` with item IDs (empty for all)
|
||||
- Only retries items under max retry limit
|
||||
|
||||
#### Dependencies
|
||||
|
||||
- **get_download_service**: Factory function providing singleton DownloadService instance
|
||||
- Automatically initializes AnimeService as dependency
|
||||
- Raises 503 if anime directory not configured
|
||||
- **get_anime_service**: Factory function providing singleton AnimeService instance
|
||||
- Required by DownloadService for anime operations
|
||||
- Both dependencies added to `src/server/utils/dependencies.py`
|
||||
|
||||
#### Error Handling
|
||||
|
||||
- All endpoints return structured JSON error responses
|
||||
- HTTP status codes follow REST conventions (200, 201, 204, 400, 401, 404, 500, 503)
|
||||
- Service-level exceptions (DownloadServiceError) mapped to 400 Bad Request
|
||||
- Generic exceptions mapped to 500 Internal Server Error
|
||||
- Authentication errors return 401 Unauthorized
|
||||
|
||||
#### Testing
|
||||
|
||||
- Comprehensive test suite in `tests/api/test_download_endpoints.py`
|
||||
- Tests cover:
|
||||
- Successful operations for all endpoints
|
||||
- Authentication requirements
|
||||
- Error conditions (empty lists, not found, service errors)
|
||||
- Priority handling
|
||||
- Batch operations
|
||||
- Uses pytest fixtures for authenticated client and mocked download service
|
||||
|
||||
#### Integration
|
||||
|
||||
- Router registered in `src/server/fastapi_app.py` via `app.include_router(download_router)`
|
||||
- Follows same patterns as other API routers (auth, anime, config)
|
||||
- Full OpenAPI documentation available at `/api/docs`
|
||||
|
||||
@ -43,17 +43,6 @@ The tasks should be completed in the following order to ensure proper dependenci
|
||||
|
||||
## Core Tasks
|
||||
|
||||
### 5. Download Queue Management
|
||||
|
||||
#### [] Implement download API endpoints
|
||||
|
||||
- []Create `src/server/api/download.py`
|
||||
- []Add GET `/api/queue/status` - get queue status
|
||||
- []Add POST `/api/queue/add` - add to queue
|
||||
- []Add DELETE `/api/queue/{id}` - remove from queue
|
||||
- []Add POST `/api/queue/start` - start downloads
|
||||
- []Add POST `/api/queue/stop` - stop downloads
|
||||
|
||||
### 6. WebSocket Real-time Updates
|
||||
|
||||
#### [] Implement WebSocket manager
|
||||
|
||||
474
src/server/api/download.py
Normal file
474
src/server/api/download.py
Normal file
@ -0,0 +1,474 @@
|
||||
"""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, HTTPException, Path, status
|
||||
|
||||
from src.server.models.download import (
|
||||
DownloadRequest,
|
||||
DownloadResponse,
|
||||
QueueOperationRequest,
|
||||
QueueReorderRequest,
|
||||
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(
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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()
|
||||
|
||||
return QueueStatusResponse(status=queue_status, statistics=queue_stats)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve queue status: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add",
|
||||
response_model=DownloadResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_to_queue(
|
||||
request: DownloadRequest,
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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 with serie info, episodes, and priority
|
||||
|
||||
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 HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one episode must be specified",
|
||||
)
|
||||
|
||||
# Add to queue
|
||||
added_ids = await download_service.add_to_queue(
|
||||
serie_id=request.serie_id,
|
||||
serie_name=request.serie_name,
|
||||
episodes=request.episodes,
|
||||
priority=request.priority,
|
||||
)
|
||||
|
||||
return DownloadResponse(
|
||||
status="success",
|
||||
message=f"Added {len(added_ids)} episode(s) to download queue",
|
||||
added_items=added_ids,
|
||||
failed_items=[],
|
||||
)
|
||||
|
||||
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 add episodes to queue: {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"),
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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 HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Download item {item_id} not found in queue",
|
||||
)
|
||||
|
||||
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 remove item from queue: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_multiple_from_queue(
|
||||
request: QueueOperationRequest,
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""Remove multiple items from the download queue.
|
||||
|
||||
Batch removal of multiple download items. Each item is processed
|
||||
individually, and the operation continues even if some items are not
|
||||
found.
|
||||
|
||||
Requires authentication.
|
||||
|
||||
Args:
|
||||
request: List of download item IDs to remove
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 400 for invalid request,
|
||||
500 on service error
|
||||
"""
|
||||
try:
|
||||
if not request.item_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one item ID must be specified",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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 remove items from queue: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start", status_code=status.HTTP_200_OK)
|
||||
async def start_queue(
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop", status_code=status.HTTP_200_OK)
|
||||
async def stop_queue(
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""Stop the download queue processor.
|
||||
|
||||
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.
|
||||
|
||||
Requires authentication.
|
||||
|
||||
Returns:
|
||||
dict: Status message indicating queue has been stopped
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 500 on service error
|
||||
"""
|
||||
try:
|
||||
await download_service.stop()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Download queue processing stopped",
|
||||
}
|
||||
|
||||
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(
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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(
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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: QueueReorderRequest,
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""Reorder an item in the pending queue.
|
||||
|
||||
Changes the position of a pending download item in the queue. This only
|
||||
affects items that haven't started downloading yet. The position is
|
||||
0-based.
|
||||
|
||||
Requires authentication.
|
||||
|
||||
Args:
|
||||
request: Item ID and new position in queue
|
||||
|
||||
Returns:
|
||||
dict: Status message indicating item has been reordered
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 404 if item not found,
|
||||
400 for invalid request, 500 on service error
|
||||
"""
|
||||
try:
|
||||
success = await download_service.reorder_queue(
|
||||
item_id=request.item_id,
|
||||
new_position=request.new_position,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item {request.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)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/completed", status_code=status.HTTP_200_OK)
|
||||
async def clear_completed(
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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 HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to clear completed items: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/retry", status_code=status.HTTP_200_OK)
|
||||
async def retry_failed(
|
||||
request: QueueOperationRequest,
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
_: dict = Depends(require_auth),
|
||||
):
|
||||
"""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_ids": retried_ids,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retry downloads: {str(e)}",
|
||||
)
|
||||
@ -18,6 +18,7 @@ from src.config.settings import settings
|
||||
# Import core functionality
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.api.auth import router as auth_router
|
||||
from src.server.api.download import router as download_router
|
||||
from src.server.controllers.error_controller import (
|
||||
not_found_handler,
|
||||
server_error_handler,
|
||||
@ -57,6 +58,7 @@ app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
||||
app.include_router(health_router)
|
||||
app.include_router(page_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(download_router)
|
||||
|
||||
# Global variables for application state
|
||||
series_app: Optional[SeriesApp] = None
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
Dependency injection utilities for FastAPI.
|
||||
|
||||
This module provides dependency injection functions for the FastAPI
|
||||
application, including SeriesApp instances, database sessions, and
|
||||
authentication dependencies.
|
||||
application, including SeriesApp instances, AnimeService, DownloadService,
|
||||
database sessions, and authentication dependencies.
|
||||
"""
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
@ -26,6 +26,10 @@ security = HTTPBearer()
|
||||
# Global SeriesApp instance
|
||||
_series_app: Optional[SeriesApp] = None
|
||||
|
||||
# Global service instances
|
||||
_anime_service: Optional[object] = None
|
||||
_download_service: Optional[object] = None
|
||||
|
||||
|
||||
def get_series_app() -> SeriesApp:
|
||||
"""
|
||||
@ -193,3 +197,79 @@ async def log_request_dependency():
|
||||
TODO: Implement request logging logic
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def get_anime_service() -> object:
|
||||
"""
|
||||
Dependency to get AnimeService instance.
|
||||
|
||||
Returns:
|
||||
AnimeService: The anime service for async operations
|
||||
|
||||
Raises:
|
||||
HTTPException: If anime directory is not configured or
|
||||
AnimeService initialization fails
|
||||
"""
|
||||
global _anime_service
|
||||
|
||||
if not settings.anime_directory:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Anime directory not configured. Please complete setup.",
|
||||
)
|
||||
|
||||
if _anime_service is None:
|
||||
try:
|
||||
from src.server.services.anime_service import AnimeService
|
||||
_anime_service = AnimeService(settings.anime_directory)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to initialize AnimeService: {str(e)}",
|
||||
) from e
|
||||
|
||||
return _anime_service
|
||||
|
||||
|
||||
def get_download_service() -> object:
|
||||
"""
|
||||
Dependency to get DownloadService instance.
|
||||
|
||||
Returns:
|
||||
DownloadService: The download queue service
|
||||
|
||||
Raises:
|
||||
HTTPException: If DownloadService initialization fails
|
||||
"""
|
||||
global _download_service
|
||||
|
||||
if _download_service is None:
|
||||
try:
|
||||
from src.server.services.download_service import DownloadService
|
||||
|
||||
# Get anime service first (required dependency)
|
||||
anime_service = get_anime_service()
|
||||
|
||||
# Initialize download service with anime service
|
||||
_download_service = DownloadService(anime_service)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to initialize DownloadService: {str(e)}",
|
||||
) from e
|
||||
|
||||
return _download_service
|
||||
|
||||
|
||||
def reset_anime_service() -> None:
|
||||
"""Reset global AnimeService instance (for testing/config changes)."""
|
||||
global _anime_service
|
||||
_anime_service = None
|
||||
|
||||
|
||||
def reset_download_service() -> None:
|
||||
"""Reset global DownloadService instance (for testing/config changes)."""
|
||||
global _download_service
|
||||
_download_service = None
|
||||
|
||||
443
tests/api/test_download_endpoints.py
Normal file
443
tests/api/test_download_endpoints.py
Normal file
@ -0,0 +1,443 @@
|
||||
"""Tests for download queue API endpoints."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.models.download import DownloadPriority, QueueStats, QueueStatus
|
||||
from src.server.services.auth_service import auth_service
|
||||
from src.server.services.download_service import DownloadServiceError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client():
|
||||
"""Create authenticated async client."""
|
||||
# Ensure auth is configured for test
|
||||
if not auth_service.is_configured():
|
||||
auth_service.setup_master_password("TestPass123!")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
# Login to get token
|
||||
r = await client.post(
|
||||
"/api/auth/login", json={"password": "TestPass123!"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
token = r.json()["access_token"]
|
||||
|
||||
# Set authorization header for all requests
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_download_service():
|
||||
"""Mock DownloadService for testing."""
|
||||
with patch(
|
||||
"src.server.utils.dependencies.get_download_service"
|
||||
) as mock:
|
||||
service = MagicMock()
|
||||
|
||||
# Mock queue status
|
||||
service.get_queue_status = AsyncMock(
|
||||
return_value=QueueStatus(
|
||||
is_running=True,
|
||||
is_paused=False,
|
||||
active_downloads=[],
|
||||
pending_queue=[],
|
||||
completed_downloads=[],
|
||||
failed_downloads=[],
|
||||
)
|
||||
)
|
||||
|
||||
# Mock queue stats
|
||||
service.get_queue_stats = AsyncMock(
|
||||
return_value=QueueStats(
|
||||
total_items=0,
|
||||
pending_count=0,
|
||||
active_count=0,
|
||||
completed_count=0,
|
||||
failed_count=0,
|
||||
total_downloaded_mb=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Mock add_to_queue
|
||||
service.add_to_queue = AsyncMock(
|
||||
return_value=["item-id-1", "item-id-2"]
|
||||
)
|
||||
|
||||
# Mock remove_from_queue
|
||||
service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
|
||||
|
||||
# Mock reorder_queue
|
||||
service.reorder_queue = AsyncMock(return_value=True)
|
||||
|
||||
# Mock start/stop/pause/resume
|
||||
service.start = AsyncMock()
|
||||
service.stop = AsyncMock()
|
||||
service.pause_queue = AsyncMock()
|
||||
service.resume_queue = AsyncMock()
|
||||
|
||||
# Mock clear_completed and retry_failed
|
||||
service.clear_completed = AsyncMock(return_value=5)
|
||||
service.retry_failed = AsyncMock(return_value=["item-id-3"])
|
||||
|
||||
mock.return_value = service
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_queue_status(authenticated_client, mock_download_service):
|
||||
"""Test GET /api/queue/status endpoint."""
|
||||
response = await authenticated_client.get("/api/queue/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "statistics" in data
|
||||
assert data["status"]["is_running"] is True
|
||||
assert data["status"]["is_paused"] is False
|
||||
|
||||
mock_download_service.get_queue_status.assert_called_once()
|
||||
mock_download_service.get_queue_stats.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_queue_status_unauthorized():
|
||||
"""Test GET /api/queue/status without authentication."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get("/api/queue/status")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/add endpoint."""
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [
|
||||
{"season": 1, "episode": 1},
|
||||
{"season": 1, "episode": 2},
|
||||
],
|
||||
"priority": "normal",
|
||||
}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert len(data["added_items"]) == 2
|
||||
assert data["added_items"] == ["item-id-1", "item-id-2"]
|
||||
|
||||
mock_download_service.add_to_queue.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_queue_with_high_priority(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test adding items with HIGH priority."""
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "high",
|
||||
}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify priority was passed correctly
|
||||
call_args = mock_download_service.add_to_queue.call_args
|
||||
assert call_args[1]["priority"] == DownloadPriority.HIGH
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_queue_empty_episodes(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test adding empty episodes list returns 400."""
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [],
|
||||
"priority": "normal",
|
||||
}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_queue_service_error(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test adding to queue when service raises error."""
|
||||
mock_download_service.add_to_queue.side_effect = DownloadServiceError(
|
||||
"Queue full"
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal",
|
||||
}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Queue full" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remove_from_queue_single(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test DELETE /api/queue/{item_id} endpoint."""
|
||||
response = await authenticated_client.delete("/api/queue/item-id-1")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
mock_download_service.remove_from_queue.assert_called_once_with(
|
||||
["item-id-1"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remove_from_queue_not_found(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test removing non-existent item returns 404."""
|
||||
mock_download_service.remove_from_queue.return_value = []
|
||||
|
||||
response = await authenticated_client.delete(
|
||||
"/api/queue/non-existent-id"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remove_multiple_from_queue(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test DELETE /api/queue/ with multiple items."""
|
||||
request_data = {"item_ids": ["item-id-1", "item-id-2"]}
|
||||
|
||||
response = await authenticated_client.request(
|
||||
"DELETE", "/api/queue/", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
mock_download_service.remove_from_queue.assert_called_once_with(
|
||||
["item-id-1", "item-id-2"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remove_multiple_empty_list(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test removing with empty item list returns 400."""
|
||||
request_data = {"item_ids": []}
|
||||
|
||||
response = await authenticated_client.request(
|
||||
"DELETE", "/api/queue/", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/start endpoint."""
|
||||
response = await authenticated_client.post("/api/queue/start")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert "started" in data["message"].lower()
|
||||
|
||||
mock_download_service.start.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/stop endpoint."""
|
||||
response = await authenticated_client.post("/api/queue/stop")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert "stopped" in data["message"].lower()
|
||||
|
||||
mock_download_service.stop.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pause_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/pause endpoint."""
|
||||
response = await authenticated_client.post("/api/queue/pause")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert "paused" in data["message"].lower()
|
||||
|
||||
mock_download_service.pause_queue.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resume_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/resume endpoint."""
|
||||
response = await authenticated_client.post("/api/queue/resume")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert "resumed" in data["message"].lower()
|
||||
|
||||
mock_download_service.resume_queue.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reorder_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/reorder endpoint."""
|
||||
request_data = {"item_id": "item-id-1", "new_position": 0}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/reorder", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
|
||||
mock_download_service.reorder_queue.assert_called_once_with(
|
||||
item_id="item-id-1", new_position=0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reorder_queue_not_found(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test reordering non-existent item returns 404."""
|
||||
mock_download_service.reorder_queue.return_value = False
|
||||
|
||||
request_data = {"item_id": "non-existent", "new_position": 0}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/reorder", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_clear_completed(authenticated_client, mock_download_service):
|
||||
"""Test DELETE /api/queue/completed endpoint."""
|
||||
response = await authenticated_client.delete("/api/queue/completed")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert data["count"] == 5
|
||||
|
||||
mock_download_service.clear_completed.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_failed(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/retry endpoint."""
|
||||
request_data = {"item_ids": ["item-id-3"]}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/retry", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert len(data["retried_ids"]) == 1
|
||||
|
||||
mock_download_service.retry_failed.assert_called_once_with(
|
||||
["item-id-3"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_all_failed(authenticated_client, mock_download_service):
|
||||
"""Test retrying all failed items with empty list."""
|
||||
request_data = {"item_ids": []}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/retry", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Should call retry_failed with None to retry all
|
||||
mock_download_service.retry_failed.assert_called_once_with(None)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_queue_endpoints_require_auth():
|
||||
"""Test that all queue endpoints require authentication."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
# Test all endpoints without auth
|
||||
endpoints = [
|
||||
("GET", "/api/queue/status"),
|
||||
("POST", "/api/queue/add"),
|
||||
("DELETE", "/api/queue/item-1"),
|
||||
("POST", "/api/queue/start"),
|
||||
("POST", "/api/queue/stop"),
|
||||
("POST", "/api/queue/pause"),
|
||||
("POST", "/api/queue/resume"),
|
||||
]
|
||||
|
||||
for method, url in endpoints:
|
||||
if method == "GET":
|
||||
response = await client.get(url)
|
||||
elif method == "POST":
|
||||
response = await client.post(url, json={})
|
||||
elif method == "DELETE":
|
||||
response = await client.delete(url)
|
||||
|
||||
assert response.status_code == 401, (
|
||||
f"{method} {url} should require auth"
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user