feat(server): add anime Pydantic models, unit tests, and infra notes

This commit is contained in:
Lukas 2025-10-14 21:53:41 +02:00
parent 6b979eb57a
commit 5b80824f3a
4 changed files with 246 additions and 16 deletions

View File

@ -294,3 +294,18 @@ Notes:
deployments. For production use across multiple workers or hosts, deployments. For production use across multiple workers or hosts,
replace the in-memory limiter with a distributed store (e.g. Redis) replace the in-memory limiter with a distributed store (e.g. Redis)
and add a persistent token revocation list if needed. 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.

View File

@ -43,24 +43,8 @@ The tasks should be completed in the following order to ensure proper dependenci
## Core Tasks ## 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 ### 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 anime service wrapper
- []Create `src/server/services/anime_service.py` - []Create `src/server/services/anime_service.py`

122
src/server/models/anime.py Normal file
View File

@ -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

View File

@ -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