From 5b80824f3a69893931c8277e89adbc84fa4811e2 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 14 Oct 2025 21:53:41 +0200 Subject: [PATCH] feat(server): add anime Pydantic models, unit tests, and infra notes --- infrastructure.md | 15 ++++ instructions.md | 16 ----- src/server/models/anime.py | 122 ++++++++++++++++++++++++++++++++ tests/unit/test_anime_models.py | 109 ++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 src/server/models/anime.py create mode 100644 tests/unit/test_anime_models.py diff --git a/infrastructure.md b/infrastructure.md index 12fba67..fc4690a 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -294,3 +294,18 @@ Notes: deployments. For production use across multiple workers or hosts, replace the in-memory limiter with a distributed store (e.g. Redis) and add a persistent token revocation list if needed. + +### API Models and Contracts + +- Pydantic models living in `src/server/models/` define the canonical + API contracts used by FastAPI endpoints. These models are intentionally + lightweight and focused on serialization, validation, and OpenAPI + documentation generation. +- Keep models stable: changes to model shapes are breaking changes for + clients. Bump API versioning or provide migration layers when altering + public response fields. +- Infrastructure considerations: ensure the deployment environment has + required libraries (e.g., `pydantic`) installed and that schema + validation errors are logged to the centralized logging system. For + high-throughput routes, consider response model caching at the + application or reverse-proxy layer. diff --git a/instructions.md b/instructions.md index 6868870..8cb9b8c 100644 --- a/instructions.md +++ b/instructions.md @@ -43,24 +43,8 @@ The tasks should be completed in the following order to ensure proper dependenci ## Core Tasks -### 3. Configuration Management - -#### [] Implement configuration API endpoints - -- []Create `src/server/api/config.py` -- []Add GET `/api/config` - get configuration -- []Add PUT `/api/config` - update configuration -- []Add POST `/api/config/validate` - validate config - ### 4. Anime Management Integration -#### [] Implement anime models - -- []Create `src/server/models/anime.py` -- []Define AnimeSeriesResponse, EpisodeInfo models -- []Add SearchRequest, SearchResult models -- []Include MissingEpisodeInfo model - #### [] Create anime service wrapper - []Create `src/server/services/anime_service.py` diff --git a/src/server/models/anime.py b/src/server/models/anime.py new file mode 100644 index 0000000..7e75195 --- /dev/null +++ b/src/server/models/anime.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +class EpisodeInfo(BaseModel): + """Information about a single episode.""" + + episode_number: int = Field(..., ge=1, description="Episode index (1-based)") + title: Optional[str] = Field(None, description="Optional episode title") + aired_at: Optional[datetime] = Field(None, description="Air date/time if known") + duration_seconds: Optional[int] = Field(None, ge=0, description="Duration in seconds") + available: bool = Field(True, description="Whether the episode is available for download") + sources: List[HttpUrl] = Field(default_factory=list, description="List of known streaming/download source URLs") + + +class MissingEpisodeInfo(BaseModel): + """Represents a gap in the episode list for a series.""" + + from_episode: int = Field(..., ge=1, description="Starting missing episode number") + to_episode: int = Field(..., ge=1, description="Ending missing episode number (inclusive)") + reason: Optional[str] = Field(None, description="Optional explanation why episodes are missing") + + @property + def count(self) -> int: + """Number of missing episodes in the range.""" + return max(0, self.to_episode - self.from_episode + 1) + + +class AnimeSeriesResponse(BaseModel): + """Response model for a series with metadata and episodes.""" + + id: str = Field(..., description="Unique series identifier") + title: str = Field(..., description="Series title") + alt_titles: List[str] = Field(default_factory=list, description="Alternative titles") + description: Optional[str] = Field(None, description="Short series description") + total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known") + episodes: List[EpisodeInfo] = Field(default_factory=list, description="Known episodes information") + missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges") + thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL") + + +class SearchRequest(BaseModel): + """Request payload for searching series.""" + + query: str = Field(..., min_length=1) + limit: int = Field(10, ge=1, le=100) + include_adult: bool = Field(False) + + +class SearchResult(BaseModel): + """Search result item for a series discovery endpoint.""" + + id: str + title: str + snippet: Optional[str] = None + thumbnail: Optional[HttpUrl] = None + score: Optional[float] = None +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +class EpisodeInfo(BaseModel): + """Information about a single episode.""" + + episode_number: int = Field(..., ge=1, description="Episode index (1-based)") + title: Optional[str] = Field(None, description="Optional episode title") + aired_at: Optional[datetime] = Field(None, description="Air date/time if known") + duration_seconds: Optional[int] = Field(None, ge=0, description="Duration in seconds") + available: bool = Field(True, description="Whether the episode is available for download") + sources: List[HttpUrl] = Field(default_factory=list, description="List of known streaming/download source URLs") + + +class MissingEpisodeInfo(BaseModel): + """Represents a gap in the episode list for a series.""" + + from_episode: int = Field(..., ge=1, description="Starting missing episode number") + to_episode: int = Field(..., ge=1, description="Ending missing episode number (inclusive)") + reason: Optional[str] = Field(None, description="Optional explanation why episodes are missing") + + @property + def count(self) -> int: + """Number of missing episodes in the range.""" + return max(0, self.to_episode - self.from_episode + 1) + + +class AnimeSeriesResponse(BaseModel): + """Response model for a series with metadata and episodes.""" + + id: str = Field(..., description="Unique series identifier") + title: str = Field(..., description="Series title") + alt_titles: List[str] = Field(default_factory=list, description="Alternative titles") + description: Optional[str] = Field(None, description="Short series description") + total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known") + episodes: List[EpisodeInfo] = Field(default_factory=list, description="Known episodes information") + missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges") + thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL") + + +class SearchRequest(BaseModel): + """Request payload for searching series.""" + + query: str = Field(..., min_length=1) + limit: int = Field(10, ge=1, le=100) + include_adult: bool = Field(False) + + +class SearchResult(BaseModel): + """Search result item for a series discovery endpoint.""" + + id: str + title: str + snippet: Optional[str] = None + thumbnail: Optional[HttpUrl] = None + score: Optional[float] = None diff --git a/tests/unit/test_anime_models.py b/tests/unit/test_anime_models.py new file mode 100644 index 0000000..e3007c0 --- /dev/null +++ b/tests/unit/test_anime_models.py @@ -0,0 +1,109 @@ +from pydantic import ValidationError + +from src.server.models.anime import ( + AnimeSeriesResponse, + EpisodeInfo, + MissingEpisodeInfo, + SearchRequest, + SearchResult, +) + + +def test_episode_info_basic(): + ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500) + assert ep.episode_number == 1 + assert ep.title == "Pilot" + assert ep.duration_seconds == 1500 + assert ep.available is True + + +def test_missing_episode_count(): + m = MissingEpisodeInfo(from_episode=5, to_episode=7) + assert m.count == 3 + + +def test_anime_series_response(): + ep = EpisodeInfo(episode_number=1, title="Ep1") + series = AnimeSeriesResponse( + id="series-123", + title="My Anime", + episodes=[ep], + total_episodes=12, + ) + + assert series.id == "series-123" + assert series.episodes[0].title == "Ep1" + + +def test_search_request_validation(): + # valid + req = SearchRequest(query="naruto", limit=5) + assert req.query == "naruto" + + # invalid: empty query + try: + SearchRequest(query="", limit=5) + raised = False + except ValidationError: + raised = True + assert raised + + +def test_search_result_optional_fields(): + res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9) + assert res.score == 0.9 + +from pydantic import ValidationError + +from src.server.models.anime import ( + AnimeSeriesResponse, + EpisodeInfo, + MissingEpisodeInfo, + SearchRequest, + SearchResult, +) + + +def test_episode_info_basic(): + ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500) + assert ep.episode_number == 1 + assert ep.title == "Pilot" + assert ep.duration_seconds == 1500 + assert ep.available is True + + +def test_missing_episode_count(): + m = MissingEpisodeInfo(from_episode=5, to_episode=7) + assert m.count == 3 + + +def test_anime_series_response(): + ep = EpisodeInfo(episode_number=1, title="Ep1") + series = AnimeSeriesResponse( + id="series-123", + title="My Anime", + episodes=[ep], + total_episodes=12, + ) + + assert series.id == "series-123" + assert series.episodes[0].title == "Ep1" + + +def test_search_request_validation(): + # valid + req = SearchRequest(query="naruto", limit=5) + assert req.query == "naruto" + + # invalid: empty query + try: + SearchRequest(query="", limit=5) + raised = False + except ValidationError: + raised = True + assert raised + + +def test_search_result_optional_fields(): + res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9) + assert res.score == 0.9