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