From 1ba43362911b40d431f1672d4053540782f26958 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 17 Oct 2025 09:55:55 +0200 Subject: [PATCH] feat: implement download queue Pydantic models - Add comprehensive download queue models in src/server/models/download.py - DownloadStatus and DownloadPriority enums for type safety - EpisodeIdentifier for episode references - DownloadProgress for real-time progress tracking - DownloadItem for queue item representation with timestamps and error handling - QueueStatus for overall queue state management - QueueStats for aggregated queue statistics - DownloadRequest/DownloadResponse for API contracts - QueueOperationRequest and QueueReorderRequest for queue management - QueueStatusResponse for complete status endpoint responses - Add comprehensive unit tests (47 tests, all passing) - Test validation constraints (positive numbers, ranges, etc.) - Test default values and optional fields - Test serialization/deserialization - Test model relationships and nested structures - Update documentation - Add download models section to infrastructure.md - Remove completed task from instructions.md - Update models package __init__.py All models follow PEP 8 style guide with proper type hints and validation. --- infrastructure.md | 17 + instructions.md | 7 - src/server/models/__init__.py | 2 +- src/server/models/download.py | 207 +++++++++++ tests/unit/test_download_models.py | 550 +++++++++++++++++++++++++++++ 5 files changed, 775 insertions(+), 8 deletions(-) create mode 100644 src/server/models/download.py create mode 100644 tests/unit/test_download_models.py diff --git a/infrastructure.md b/infrastructure.md index ee2ff6a..a5ac585 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -326,3 +326,20 @@ Notes: validation errors are logged to the centralized logging system. For high-throughput routes, consider response model caching at the application or reverse-proxy layer. + +### Download Queue Models + +- Download queue models in `src/server/models/download.py` define the data + structures for the download queue system. +- Key models include: + - `DownloadItem`: Represents a single queued download with metadata, + progress tracking, and error information + - `QueueStatus`: Overall queue state with active, pending, completed, + and failed downloads + - `QueueStats`: Aggregated statistics for monitoring queue performance + - `DownloadProgress`: Real-time progress information (percent, speed, + ETA) + - `DownloadRequest`/`DownloadResponse`: API request/response contracts +- Models enforce validation constraints (e.g., positive episode numbers, + progress percentage 0-100, non-negative retry counts) and provide + clean JSON serialization for API endpoints and WebSocket updates. diff --git a/instructions.md b/instructions.md index 6c0579a..a206f58 100644 --- a/instructions.md +++ b/instructions.md @@ -45,13 +45,6 @@ The tasks should be completed in the following order to ensure proper dependenci ### 5. Download Queue Management -#### [] Implement download queue models - -- []Create `src/server/models/download.py` -- []Define DownloadItem, QueueStatus models -- []Add DownloadProgress, QueueStats models -- []Include DownloadRequest model - #### [] Create download queue service - []Create `src/server/services/download_service.py` diff --git a/src/server/models/__init__.py b/src/server/models/__init__.py index 4dc9ff0..cacc6a2 100644 --- a/src/server/models/__init__.py +++ b/src/server/models/__init__.py @@ -1,3 +1,3 @@ """Models package for server-side Pydantic models.""" -__all__ = ["auth"] +__all__ = ["auth", "anime", "config", "download"] diff --git a/src/server/models/download.py b/src/server/models/download.py new file mode 100644 index 0000000..e1f5294 --- /dev/null +++ b/src/server/models/download.py @@ -0,0 +1,207 @@ +"""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 +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +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.""" + + id: str = Field(..., description="Unique download item identifier") + serie_id: str = Field(..., description="Series identifier") + serie_name: str = Field(..., min_length=1, description="Series 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=datetime.utcnow, + 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" + ) + + +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.""" + + serie_id: str = Field(..., description="Series identifier") + serie_name: str = Field( + ..., min_length=1, description="Series name for display" + ) + episodes: List[EpisodeIdentifier] = Field( + ..., min_length=1, description="List of episodes to download" + ) + priority: DownloadPriority = Field( + DownloadPriority.NORMAL, description="Priority level for queue items" + ) + + +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( + ..., min_length=1, 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") diff --git a/tests/unit/test_download_models.py b/tests/unit/test_download_models.py new file mode 100644 index 0000000..8e4255f --- /dev/null +++ b/tests/unit/test_download_models.py @@ -0,0 +1,550 @@ +"""Unit tests for download queue Pydantic models. + +This module tests all download-related models including validation, +serialization, and field constraints. +""" +from datetime import datetime, timedelta + +import pytest +from pydantic import ValidationError + +from src.server.models.download import ( + DownloadItem, + DownloadPriority, + DownloadProgress, + DownloadRequest, + DownloadResponse, + DownloadStatus, + EpisodeIdentifier, + QueueOperationRequest, + QueueReorderRequest, + QueueStats, + QueueStatus, + QueueStatusResponse, +) + + +class TestDownloadStatus: + """Test DownloadStatus enum.""" + + def test_all_statuses_exist(self): + """Test that all expected statuses are defined.""" + assert DownloadStatus.PENDING == "pending" + assert DownloadStatus.DOWNLOADING == "downloading" + assert DownloadStatus.PAUSED == "paused" + assert DownloadStatus.COMPLETED == "completed" + assert DownloadStatus.FAILED == "failed" + assert DownloadStatus.CANCELLED == "cancelled" + + def test_status_values(self): + """Test that status values are lowercase strings.""" + for status in DownloadStatus: + assert isinstance(status.value, str) + assert status.value.islower() + + +class TestDownloadPriority: + """Test DownloadPriority enum.""" + + def test_all_priorities_exist(self): + """Test that all expected priorities are defined.""" + assert DownloadPriority.LOW == "low" + assert DownloadPriority.NORMAL == "normal" + assert DownloadPriority.HIGH == "high" + + def test_priority_values(self): + """Test that priority values are lowercase strings.""" + for priority in DownloadPriority: + assert isinstance(priority.value, str) + assert priority.value.islower() + + +class TestEpisodeIdentifier: + """Test EpisodeIdentifier model.""" + + def test_valid_episode_identifier(self): + """Test creating a valid episode identifier.""" + episode = EpisodeIdentifier( + season=1, + episode=5, + title="Test Episode" + ) + assert episode.season == 1 + assert episode.episode == 5 + assert episode.title == "Test Episode" + + def test_episode_identifier_without_title(self): + """Test creating episode identifier without title.""" + episode = EpisodeIdentifier(season=2, episode=10) + assert episode.season == 2 + assert episode.episode == 10 + assert episode.title is None + + def test_invalid_season_number(self): + """Test that season must be positive.""" + with pytest.raises(ValidationError) as exc_info: + EpisodeIdentifier(season=0, episode=1) + errors = exc_info.value.errors() + assert any("season" in str(e["loc"]) for e in errors) + + def test_invalid_episode_number(self): + """Test that episode must be positive.""" + with pytest.raises(ValidationError) as exc_info: + EpisodeIdentifier(season=1, episode=0) + errors = exc_info.value.errors() + assert any("episode" in str(e["loc"]) for e in errors) + + def test_negative_season_rejected(self): + """Test that negative season is rejected.""" + with pytest.raises(ValidationError): + EpisodeIdentifier(season=-1, episode=1) + + def test_negative_episode_rejected(self): + """Test that negative episode is rejected.""" + with pytest.raises(ValidationError): + EpisodeIdentifier(season=1, episode=-1) + + +class TestDownloadProgress: + """Test DownloadProgress model.""" + + def test_valid_progress(self): + """Test creating valid progress information.""" + progress = DownloadProgress( + percent=45.5, + downloaded_mb=100.0, + total_mb=220.0, + speed_mbps=5.5, + eta_seconds=120 + ) + assert progress.percent == 45.5 + assert progress.downloaded_mb == 100.0 + assert progress.total_mb == 220.0 + assert progress.speed_mbps == 5.5 + assert progress.eta_seconds == 120 + + def test_progress_defaults(self): + """Test default values for progress.""" + progress = DownloadProgress() + assert progress.percent == 0.0 + assert progress.downloaded_mb == 0.0 + assert progress.total_mb is None + assert progress.speed_mbps is None + assert progress.eta_seconds is None + + def test_percent_range_validation(self): + """Test that percent must be between 0 and 100.""" + # Valid boundary values + DownloadProgress(percent=0.0) + DownloadProgress(percent=100.0) + + # Invalid values + with pytest.raises(ValidationError): + DownloadProgress(percent=-0.1) + with pytest.raises(ValidationError): + DownloadProgress(percent=100.1) + + def test_negative_downloaded_mb_rejected(self): + """Test that negative downloaded_mb is rejected.""" + with pytest.raises(ValidationError): + DownloadProgress(downloaded_mb=-1.0) + + def test_negative_total_mb_rejected(self): + """Test that negative total_mb is rejected.""" + with pytest.raises(ValidationError): + DownloadProgress(total_mb=-1.0) + + def test_negative_speed_rejected(self): + """Test that negative speed is rejected.""" + with pytest.raises(ValidationError): + DownloadProgress(speed_mbps=-1.0) + + def test_negative_eta_rejected(self): + """Test that negative ETA is rejected.""" + with pytest.raises(ValidationError): + DownloadProgress(eta_seconds=-1) + + +class TestDownloadItem: + """Test DownloadItem model.""" + + def test_valid_download_item(self): + """Test creating a valid download item.""" + episode = EpisodeIdentifier(season=1, episode=5) + item = DownloadItem( + id="download_123", + serie_id="serie_456", + serie_name="Test Series", + episode=episode, + status=DownloadStatus.PENDING, + priority=DownloadPriority.HIGH + ) + assert item.id == "download_123" + assert item.serie_id == "serie_456" + assert item.serie_name == "Test Series" + assert item.episode == episode + assert item.status == DownloadStatus.PENDING + assert item.priority == DownloadPriority.HIGH + + def test_download_item_defaults(self): + """Test default values for download item.""" + episode = EpisodeIdentifier(season=1, episode=1) + item = DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test", + episode=episode + ) + assert item.status == DownloadStatus.PENDING + assert item.priority == DownloadPriority.NORMAL + assert item.started_at is None + assert item.completed_at is None + assert item.progress is None + assert item.error is None + assert item.retry_count == 0 + assert item.source_url is None + + def test_download_item_with_progress(self): + """Test download item with progress information.""" + episode = EpisodeIdentifier(season=1, episode=1) + progress = DownloadProgress(percent=50.0, downloaded_mb=100.0) + item = DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test", + episode=episode, + progress=progress + ) + assert item.progress is not None + assert item.progress.percent == 50.0 + + def test_download_item_with_timestamps(self): + """Test download item with timestamp fields.""" + episode = EpisodeIdentifier(season=1, episode=1) + now = datetime.utcnow() + item = DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test", + episode=episode, + started_at=now, + completed_at=now + timedelta(minutes=5) + ) + assert item.started_at == now + assert item.completed_at == now + timedelta(minutes=5) + + def test_empty_serie_name_rejected(self): + """Test that empty serie name is rejected.""" + episode = EpisodeIdentifier(season=1, episode=1) + with pytest.raises(ValidationError): + DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="", + episode=episode + ) + + def test_negative_retry_count_rejected(self): + """Test that negative retry count is rejected.""" + episode = EpisodeIdentifier(season=1, episode=1) + with pytest.raises(ValidationError): + DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test", + episode=episode, + retry_count=-1 + ) + + def test_added_at_auto_generated(self): + """Test that added_at is automatically set.""" + episode = EpisodeIdentifier(season=1, episode=1) + before = datetime.utcnow() + item = DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test", + episode=episode + ) + after = datetime.utcnow() + assert before <= item.added_at <= after + + +class TestQueueStatus: + """Test QueueStatus model.""" + + def test_valid_queue_status(self): + """Test creating valid queue status.""" + episode = EpisodeIdentifier(season=1, episode=1) + item = DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test", + episode=episode + ) + status = QueueStatus( + is_running=True, + is_paused=False, + active_downloads=[item], + pending_queue=[item], + completed_downloads=[], + failed_downloads=[] + ) + assert status.is_running is True + assert status.is_paused is False + assert len(status.active_downloads) == 1 + assert len(status.pending_queue) == 1 + + def test_queue_status_defaults(self): + """Test default values for queue status.""" + status = QueueStatus() + assert status.is_running is False + assert status.is_paused is False + assert status.active_downloads == [] + assert status.pending_queue == [] + assert status.completed_downloads == [] + assert status.failed_downloads == [] + + +class TestQueueStats: + """Test QueueStats model.""" + + def test_valid_queue_stats(self): + """Test creating valid queue statistics.""" + stats = QueueStats( + total_items=10, + pending_count=3, + active_count=2, + completed_count=4, + failed_count=1, + total_downloaded_mb=500.5, + average_speed_mbps=5.0, + estimated_time_remaining=120 + ) + assert stats.total_items == 10 + assert stats.pending_count == 3 + assert stats.active_count == 2 + assert stats.completed_count == 4 + assert stats.failed_count == 1 + assert stats.total_downloaded_mb == 500.5 + assert stats.average_speed_mbps == 5.0 + assert stats.estimated_time_remaining == 120 + + def test_queue_stats_defaults(self): + """Test default values for queue stats.""" + stats = QueueStats() + assert stats.total_items == 0 + assert stats.pending_count == 0 + assert stats.active_count == 0 + assert stats.completed_count == 0 + assert stats.failed_count == 0 + assert stats.total_downloaded_mb == 0.0 + assert stats.average_speed_mbps is None + assert stats.estimated_time_remaining is None + + def test_negative_counts_rejected(self): + """Test that negative counts are rejected.""" + with pytest.raises(ValidationError): + QueueStats(total_items=-1) + with pytest.raises(ValidationError): + QueueStats(pending_count=-1) + with pytest.raises(ValidationError): + QueueStats(active_count=-1) + with pytest.raises(ValidationError): + QueueStats(completed_count=-1) + with pytest.raises(ValidationError): + QueueStats(failed_count=-1) + + def test_negative_speed_rejected(self): + """Test that negative speed is rejected.""" + with pytest.raises(ValidationError): + QueueStats(average_speed_mbps=-1.0) + + def test_negative_eta_rejected(self): + """Test that negative ETA is rejected.""" + with pytest.raises(ValidationError): + QueueStats(estimated_time_remaining=-1) + + +class TestDownloadRequest: + """Test DownloadRequest model.""" + + def test_valid_download_request(self): + """Test creating a valid download request.""" + episode1 = EpisodeIdentifier(season=1, episode=1) + episode2 = EpisodeIdentifier(season=1, episode=2) + request = DownloadRequest( + serie_id="serie_123", + serie_name="Test Series", + episodes=[episode1, episode2], + priority=DownloadPriority.HIGH + ) + assert request.serie_id == "serie_123" + assert request.serie_name == "Test Series" + assert len(request.episodes) == 2 + assert request.priority == DownloadPriority.HIGH + + def test_download_request_default_priority(self): + """Test default priority for download request.""" + episode = EpisodeIdentifier(season=1, episode=1) + request = DownloadRequest( + serie_id="serie_123", + serie_name="Test Series", + episodes=[episode] + ) + assert request.priority == DownloadPriority.NORMAL + + def test_empty_episodes_list_rejected(self): + """Test that empty episodes list is rejected.""" + with pytest.raises(ValidationError): + DownloadRequest( + serie_id="serie_123", + serie_name="Test Series", + episodes=[] + ) + + def test_empty_serie_name_rejected(self): + """Test that empty serie name is rejected.""" + episode = EpisodeIdentifier(season=1, episode=1) + with pytest.raises(ValidationError): + DownloadRequest( + serie_id="serie_123", + serie_name="", + episodes=[episode] + ) + + +class TestDownloadResponse: + """Test DownloadResponse model.""" + + def test_valid_download_response(self): + """Test creating a valid download response.""" + response = DownloadResponse( + status="success", + message="Added 2 episodes to queue", + added_items=["item1", "item2"], + failed_items=[] + ) + assert response.status == "success" + assert response.message == "Added 2 episodes to queue" + assert len(response.added_items) == 2 + assert response.failed_items == [] + + def test_download_response_defaults(self): + """Test default values for download response.""" + response = DownloadResponse( + status="success", + message="Test message" + ) + assert response.added_items == [] + assert response.failed_items == [] + + +class TestQueueOperationRequest: + """Test QueueOperationRequest model.""" + + def test_valid_operation_request(self): + """Test creating a valid operation request.""" + request = QueueOperationRequest( + item_ids=["item1", "item2", "item3"] + ) + assert len(request.item_ids) == 3 + assert "item1" in request.item_ids + + def test_empty_item_ids_rejected(self): + """Test that empty item_ids list is rejected.""" + with pytest.raises(ValidationError): + QueueOperationRequest(item_ids=[]) + + +class TestQueueReorderRequest: + """Test QueueReorderRequest model.""" + + def test_valid_reorder_request(self): + """Test creating a valid reorder request.""" + request = QueueReorderRequest( + item_id="item_123", + new_position=5 + ) + assert request.item_id == "item_123" + assert request.new_position == 5 + + def test_zero_position_allowed(self): + """Test that position zero is allowed.""" + request = QueueReorderRequest( + item_id="item_123", + new_position=0 + ) + assert request.new_position == 0 + + def test_negative_position_rejected(self): + """Test that negative position is rejected.""" + with pytest.raises(ValidationError): + QueueReorderRequest( + item_id="item_123", + new_position=-1 + ) + + +class TestQueueStatusResponse: + """Test QueueStatusResponse model.""" + + def test_valid_status_response(self): + """Test creating a valid status response.""" + status = QueueStatus() + stats = QueueStats() + response = QueueStatusResponse( + status=status, + statistics=stats + ) + assert response.status is not None + assert response.statistics is not None + + +class TestModelSerialization: + """Test model serialization and deserialization.""" + + def test_download_item_to_dict(self): + """Test serializing download item to dict.""" + episode = EpisodeIdentifier(season=1, episode=5, title="Test") + item = DownloadItem( + id="test_id", + serie_id="serie_id", + serie_name="Test Series", + episode=episode + ) + data = item.model_dump() + assert data["id"] == "test_id" + assert data["serie_name"] == "Test Series" + assert data["episode"]["season"] == 1 + assert data["episode"]["episode"] == 5 + + def test_download_item_from_dict(self): + """Test deserializing download item from dict.""" + data = { + "id": "test_id", + "serie_id": "serie_id", + "serie_name": "Test Series", + "episode": { + "season": 1, + "episode": 5, + "title": "Test Episode" + } + } + item = DownloadItem(**data) + assert item.id == "test_id" + assert item.serie_name == "Test Series" + assert item.episode.season == 1 + + def test_queue_status_to_json(self): + """Test serializing queue status to JSON.""" + status = QueueStatus(is_running=True) + json_str = status.model_dump_json() + assert '"is_running":true' in json_str.lower() + + def test_queue_stats_from_json(self): + """Test deserializing queue stats from JSON.""" + json_str = '{"total_items": 5, "pending_count": 3}' + stats = QueueStats.model_validate_json(json_str) + assert stats.total_items == 5 + assert stats.pending_count == 3