- Add field validators to parse season/episode/title from JSON strings - Add episodes list parser handling string-encoded dicts - Return 200 with message instead of 400 when queue empty - Remove completed tasks 8+9 from Docs
348 lines
11 KiB
Python
348 lines
11 KiB
Python
"""Download queue Pydantic models for the Aniworld web application.
|
|
|
|
This module defines request/response models used by the download queue API
|
|
and the download service. Models are intentionally lightweight and focused
|
|
on serialization, validation, and OpenAPI documentation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import List, Optional
|
|
|
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
|
|
|
|
|
class DownloadStatus(str, Enum):
|
|
"""Status of a download item in the queue."""
|
|
|
|
PENDING = "pending"
|
|
DOWNLOADING = "downloading"
|
|
PAUSED = "paused"
|
|
COMPLETED = "completed"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
PERMANENTLY_FAILED = "permanently_failed"
|
|
|
|
|
|
class DownloadPriority(str, Enum):
|
|
"""Priority level for download queue items."""
|
|
|
|
LOW = "LOW"
|
|
NORMAL = "NORMAL"
|
|
HIGH = "HIGH"
|
|
|
|
|
|
class EpisodeIdentifier(BaseModel):
|
|
"""Episode identification information for a download item."""
|
|
|
|
season: int = Field(..., ge=1, description="Season number (1-based)")
|
|
episode: int = Field(
|
|
..., ge=1, description="Episode number within season (1-based)"
|
|
)
|
|
title: Optional[str] = Field(None, description="Episode title if known")
|
|
|
|
@field_validator("season", mode="before")
|
|
@classmethod
|
|
def parse_season(cls, v):
|
|
"""Parse season from string JSON if needed."""
|
|
if isinstance(v, str):
|
|
try:
|
|
return int(v)
|
|
except ValueError:
|
|
try:
|
|
parsed = json.loads(v)
|
|
return parsed.get("season") if isinstance(parsed, dict) else v
|
|
except (json.JSONDecodeError, TypeError):
|
|
return v
|
|
return v
|
|
|
|
@field_validator("episode", mode="before")
|
|
@classmethod
|
|
def parse_episode(cls, v):
|
|
"""Parse episode from string JSON if needed."""
|
|
if isinstance(v, str):
|
|
try:
|
|
return int(v)
|
|
except ValueError:
|
|
try:
|
|
parsed = json.loads(v)
|
|
return parsed.get("episode") if isinstance(parsed, dict) else v
|
|
except (json.JSONDecodeError, TypeError):
|
|
return v
|
|
return v
|
|
|
|
@field_validator("title", mode="before")
|
|
@classmethod
|
|
def parse_title(cls, v):
|
|
"""Parse title from string JSON if needed."""
|
|
if isinstance(v, str):
|
|
try:
|
|
parsed = json.loads(v)
|
|
return parsed.get("title") if isinstance(parsed, dict) else v
|
|
except (json.JSONDecodeError, TypeError):
|
|
return v
|
|
return v
|
|
|
|
|
|
class DownloadProgress(BaseModel):
|
|
"""Real-time progress information for an active download."""
|
|
|
|
percent: float = Field(
|
|
0.0, ge=0.0, le=100.0, description="Download progress percentage"
|
|
)
|
|
downloaded_mb: float = Field(
|
|
0.0, ge=0.0, description="Downloaded size in megabytes"
|
|
)
|
|
total_mb: Optional[float] = Field(
|
|
None, ge=0.0, description="Total size in megabytes if known"
|
|
)
|
|
speed_mbps: Optional[float] = Field(
|
|
None, ge=0.0, description="Download speed in MB/s"
|
|
)
|
|
eta_seconds: Optional[int] = Field(
|
|
None, ge=0, description="Estimated time remaining in seconds"
|
|
)
|
|
|
|
|
|
class DownloadItem(BaseModel):
|
|
"""Represents a single download item in the queue.
|
|
|
|
Note on identifiers:
|
|
- serie_id: The provider-assigned key (e.g., 'attack-on-titan') used for
|
|
all lookups and identification. This is the primary identifier.
|
|
- serie_folder: The filesystem folder name (e.g., 'Attack on Titan (2013)')
|
|
used only for filesystem operations. This is metadata, not an identifier.
|
|
"""
|
|
|
|
id: str = Field(..., description="Unique download item identifier")
|
|
serie_id: str = Field(
|
|
...,
|
|
description=(
|
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
|
)
|
|
)
|
|
serie_folder: str = Field(
|
|
...,
|
|
description=(
|
|
"Series folder name on disk (metadata only) "
|
|
"(e.g., 'Attack on Titan (2013)'). For filesystem ops only."
|
|
)
|
|
)
|
|
serie_name: str = Field(
|
|
..., min_length=1, description="Series display name"
|
|
)
|
|
episode: EpisodeIdentifier = Field(
|
|
..., description="Episode identification"
|
|
)
|
|
status: DownloadStatus = Field(
|
|
DownloadStatus.PENDING, description="Current download status"
|
|
)
|
|
priority: DownloadPriority = Field(
|
|
DownloadPriority.NORMAL, description="Queue priority"
|
|
)
|
|
|
|
# Timestamps
|
|
added_at: datetime = Field(
|
|
default_factory=lambda: datetime.now(timezone.utc),
|
|
description="When item was added to queue",
|
|
)
|
|
started_at: Optional[datetime] = Field(
|
|
None, description="When download started"
|
|
)
|
|
completed_at: Optional[datetime] = Field(
|
|
None, description="When download completed/failed"
|
|
)
|
|
|
|
# Progress tracking
|
|
progress: Optional[DownloadProgress] = Field(
|
|
None, description="Current progress if downloading"
|
|
)
|
|
|
|
# Error handling
|
|
error: Optional[str] = Field(None, description="Error message if failed")
|
|
retry_count: int = Field(0, ge=0, description="Number of retry attempts")
|
|
|
|
# Download source
|
|
source_url: Optional[HttpUrl] = Field(
|
|
None, description="Source URL for download"
|
|
)
|
|
|
|
@field_validator('serie_id', mode='before')
|
|
@classmethod
|
|
def normalize_serie_id(cls, v: str) -> str:
|
|
"""Normalize serie_id (key) to lowercase and stripped."""
|
|
if isinstance(v, str):
|
|
return v.lower().strip()
|
|
return v
|
|
|
|
|
|
class QueueStatus(BaseModel):
|
|
"""Overall status of the download queue system."""
|
|
|
|
is_running: bool = Field(
|
|
False, description="Whether the queue processor is running"
|
|
)
|
|
is_paused: bool = Field(False, description="Whether downloads are paused")
|
|
active_downloads: List[DownloadItem] = Field(
|
|
default_factory=list, description="Currently downloading items"
|
|
)
|
|
pending_queue: List[DownloadItem] = Field(
|
|
default_factory=list, description="Items waiting to be downloaded"
|
|
)
|
|
completed_downloads: List[DownloadItem] = Field(
|
|
default_factory=list, description="Recently completed downloads"
|
|
)
|
|
failed_downloads: List[DownloadItem] = Field(
|
|
default_factory=list, description="Failed download items"
|
|
)
|
|
|
|
|
|
class QueueStats(BaseModel):
|
|
"""Statistics about the download queue."""
|
|
|
|
total_items: int = Field(
|
|
0, ge=0, description="Total number of items in all queues"
|
|
)
|
|
pending_count: int = Field(0, ge=0, description="Number of pending items")
|
|
active_count: int = Field(
|
|
0, ge=0, description="Number of active downloads"
|
|
)
|
|
completed_count: int = Field(
|
|
0, ge=0, description="Number of completed downloads"
|
|
)
|
|
failed_count: int = Field(
|
|
0, ge=0, description="Number of failed downloads"
|
|
)
|
|
|
|
total_downloaded_mb: float = Field(
|
|
0.0, ge=0.0, description="Total megabytes downloaded"
|
|
)
|
|
average_speed_mbps: Optional[float] = Field(
|
|
None, ge=0.0, description="Average download speed in MB/s"
|
|
)
|
|
estimated_time_remaining: Optional[int] = Field(
|
|
None, ge=0, description="Estimated time to complete queue in seconds"
|
|
)
|
|
|
|
|
|
class DownloadRequest(BaseModel):
|
|
"""Request to add episode(s) to the download queue.
|
|
|
|
Note on identifiers:
|
|
- serie_id: The provider-assigned key (e.g., 'attack-on-titan') used as
|
|
the primary identifier for all operations. This is the unique key.
|
|
- serie_folder: The filesystem folder name (e.g., 'Attack on Titan (2013)')
|
|
used only for storing downloaded files. This is metadata.
|
|
"""
|
|
|
|
serie_id: str = Field(
|
|
...,
|
|
description=(
|
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
|
)
|
|
)
|
|
serie_folder: str = Field(
|
|
...,
|
|
description=(
|
|
"Series folder name on disk (metadata only) "
|
|
"(e.g., 'Attack on Titan (2013)'). For filesystem ops only."
|
|
)
|
|
)
|
|
serie_name: str = Field(
|
|
..., min_length=1, description="Series display name"
|
|
)
|
|
episodes: List[EpisodeIdentifier] = Field(
|
|
..., description="List of episodes to download"
|
|
)
|
|
priority: DownloadPriority = Field(
|
|
DownloadPriority.NORMAL, description="Priority level for queue items"
|
|
)
|
|
|
|
@field_validator('episodes', mode='before')
|
|
@classmethod
|
|
def parse_episodes(cls, v):
|
|
"""Parse episodes list, handling potential string-encoded dicts."""
|
|
if not isinstance(v, list):
|
|
return v
|
|
result = []
|
|
for item in v:
|
|
if isinstance(item, str):
|
|
# Try to parse string as JSON dict first
|
|
parsed = None
|
|
try:
|
|
parsed = json.loads(item)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
# If JSON failed, try Python dict string syntax
|
|
if parsed is None:
|
|
try:
|
|
parsed = ast.literal_eval(item)
|
|
except (ValueError, SyntaxError, TypeError):
|
|
pass
|
|
if isinstance(parsed, dict):
|
|
result.append(parsed)
|
|
else:
|
|
result.append(item)
|
|
else:
|
|
result.append(item)
|
|
return result
|
|
|
|
@field_validator('priority', mode='before')
|
|
@classmethod
|
|
def normalize_priority(cls, v):
|
|
"""Normalize priority to uppercase for case-insensitive matching."""
|
|
if isinstance(v, str):
|
|
return v.upper()
|
|
return v
|
|
|
|
@field_validator('serie_id', mode='before')
|
|
@classmethod
|
|
def normalize_serie_id(cls, v: str) -> str:
|
|
"""Normalize serie_id (key) to lowercase and stripped."""
|
|
if isinstance(v, str):
|
|
return v.lower().strip()
|
|
return v
|
|
|
|
|
|
class DownloadResponse(BaseModel):
|
|
"""Response after adding items to the download queue."""
|
|
|
|
status: str = Field(..., description="Status of the request")
|
|
message: str = Field(..., description="Human-readable status message")
|
|
added_items: List[str] = Field(
|
|
default_factory=list,
|
|
description="IDs of successfully added download items"
|
|
)
|
|
failed_items: List[str] = Field(
|
|
default_factory=list, description="Episodes that failed to be added"
|
|
)
|
|
|
|
|
|
class QueueOperationRequest(BaseModel):
|
|
"""Request to perform operations on queue items."""
|
|
|
|
item_ids: List[str] = Field(
|
|
..., description="List of download item IDs"
|
|
)
|
|
|
|
|
|
class QueueReorderRequest(BaseModel):
|
|
"""Request to reorder items in the pending queue."""
|
|
|
|
item_id: str = Field(..., description="Download item ID to move")
|
|
new_position: int = Field(
|
|
..., ge=0, description="New position in queue (0-based)"
|
|
)
|
|
|
|
|
|
class QueueStatusResponse(BaseModel):
|
|
"""Complete response for queue status endpoint."""
|
|
|
|
status: QueueStatus = Field(..., description="Current queue status")
|
|
statistics: QueueStats = Field(..., description="Queue statistics")
|