feat(server): add anime Pydantic models, unit tests, and infra notes
This commit is contained in:
parent
6b979eb57a
commit
5b80824f3a
@ -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.
|
||||
|
||||
@ -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`
|
||||
|
||||
122
src/server/models/anime.py
Normal file
122
src/server/models/anime.py
Normal 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
|
||||
109
tests/unit/test_anime_models.py
Normal file
109
tests/unit/test_anime_models.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user