Aniworld/src/server/models/download.py
Lukas 6d2a791a9d Task 4.5: Update Pydantic models to use key as primary identifier
- Updated AnimeSeriesResponse and SearchResult models in anime.py:
  - Changed 'id' field to 'key' as the primary series identifier
  - Added 'folder' as optional metadata field
  - Added field validator to normalize key to lowercase and strip whitespace
  - Added comprehensive docstrings explaining identifier usage

- Updated DownloadItem and DownloadRequest models in download.py:
  - Added field validator for serie_id normalization (lowercase, stripped)
  - Improved documentation for serie_id (primary identifier) vs serie_folder (metadata)

- Updated test_anime_models.py with comprehensive tests:
  - Tests for key normalization and whitespace stripping
  - Tests for folder as optional metadata
  - Reorganized tests into proper class structure

- Updated test_download_models.py with validator tests:
  - Tests for serie_id normalization in DownloadItem
  - Tests for serie_id normalization in DownloadRequest

All 885 tests pass.
2025-11-27 20:01:33 +01:00

274 lines
8.8 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
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"
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")
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('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")