Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.
Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
update_series_nfo_status(); now uses update_nfo_status() and an
explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
GET /settings -> AnimeSettingsResponse
PUT /settings -> AnimeSettingsResponse
(body: name/folder/tmdb_id/tvdb_id/site,
options: apply_to_nfo, rename_disk)
POST /regenerate-nfo -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it
Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
'anime-settings' (label 'Anime Settings'), navigates to
/anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'
Bug fix
- context-menu click handler was calling hide() BEFORE building the
navigation URL, which cleared currentSeriesKey to null and produced
/anime/settings?key=null. Captures the key into a local const first.
Regression-locked by tests/frontend/unit/context_menu.test.js.
Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
(GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)
Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix
Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""Anime Pydantic models for the Aniworld web application.
|
|
|
|
This module defines request/response models used by the anime API
|
|
and services. Models are focused on serialization, validation,
|
|
and OpenAPI documentation.
|
|
|
|
Note on identifiers:
|
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
|
|
|
|
|
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.
|
|
|
|
Note on identifiers:
|
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
|
This is the unique key used for all lookups and operations.
|
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
|
Used only for display and filesystem operations.
|
|
"""
|
|
|
|
key: str = Field(
|
|
...,
|
|
description=(
|
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
|
)
|
|
)
|
|
title: str = Field(..., description="Series title")
|
|
folder: Optional[str] = Field(
|
|
None,
|
|
description=(
|
|
"Series folder name on disk (metadata only) "
|
|
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
|
)
|
|
)
|
|
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
|
|
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")
|
|
|
|
@field_validator('key', mode='before')
|
|
@classmethod
|
|
def normalize_key(cls, v: str) -> str:
|
|
"""Normalize key to lowercase."""
|
|
if isinstance(v, str):
|
|
return v.lower().strip()
|
|
return v
|
|
|
|
|
|
class SearchRequest(BaseModel):
|
|
"""Request payload for searching series."""
|
|
|
|
query: str = Field(..., min_length=1, description="Search query string")
|
|
limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
|
|
include_adult: bool = Field(False, description="Include adult content in results")
|
|
|
|
|
|
class SearchResult(BaseModel):
|
|
"""Search result item for a series discovery endpoint.
|
|
|
|
Note on identifiers:
|
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
|
This is the unique key used for all lookups and operations.
|
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
|
Used only for display and filesystem operations.
|
|
"""
|
|
|
|
key: str = Field(
|
|
...,
|
|
description=(
|
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
|
)
|
|
)
|
|
title: str = Field(..., description="Series title")
|
|
folder: Optional[str] = Field(
|
|
None,
|
|
description=(
|
|
"Series folder name on disk (metadata only) "
|
|
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
|
)
|
|
)
|
|
snippet: Optional[str] = Field(
|
|
None,
|
|
description="Search result snippet or description"
|
|
)
|
|
score: Optional[float] = Field(
|
|
None,
|
|
description="Search relevance score (0.0 to 1.0)"
|
|
)
|
|
|
|
@field_validator('key', mode='before')
|
|
@classmethod
|
|
def normalize_key(cls, v: str) -> str:
|
|
"""Normalize key to lowercase."""
|
|
if isinstance(v, str):
|
|
return v.lower().strip()
|
|
return v
|
|
|
|
|
|
class AnimeDetailsResponse(BaseModel):
|
|
"""Detailed response model for a single anime series with all metadata.
|
|
|
|
Used by the edit modal to pre-fill form fields with existing data.
|
|
|
|
Attributes:
|
|
key: Unique series identifier
|
|
name: Display name
|
|
folder: Filesystem folder name
|
|
year: Release year
|
|
status: Show status (Continuing, Ended)
|
|
plot: Plot description
|
|
genres: List of genres
|
|
studio: List of studios
|
|
premiered: Premiere date
|
|
rating: Rating value (0-10)
|
|
rating_votes: Number of votes
|
|
tmdb_id: TMDB ID
|
|
tvdb_id: TVDB ID
|
|
has_nfo: Whether NFO file exists
|
|
nfo_created_at: NFO creation timestamp
|
|
nfo_updated_at: NFO update timestamp
|
|
"""
|
|
|
|
key: str = Field(..., description="Unique series identifier")
|
|
name: str = Field(..., description="Display name")
|
|
folder: Optional[str] = Field(None, description="Filesystem folder name")
|
|
year: Optional[int] = Field(None, ge=1900, le=2100, description="Release year")
|
|
status: Optional[str] = Field(None, description="Show status (Continuing, Ended)")
|
|
plot: Optional[str] = Field(None, description="Plot description")
|
|
genres: List[str] = Field(default_factory=list, description="List of genres")
|
|
studio: List[str] = Field(default_factory=list, description="List of studios")
|
|
premiered: Optional[str] = Field(None, description="Premiere date (YYYY-MM-DD)")
|
|
rating: Optional[float] = Field(None, ge=0, le=10, description="Rating value (0-10)")
|
|
rating_votes: Optional[int] = Field(None, ge=0, description="Number of votes")
|
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
|
has_nfo: bool = Field(False, description="Whether NFO file exists")
|
|
nfo_created_at: Optional[str] = Field(None, description="NFO creation timestamp")
|
|
nfo_updated_at: Optional[str] = Field(None, description="NFO update timestamp")
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"key": "attack-on-titan",
|
|
"name": "Attack on Titan",
|
|
"folder": "Attack on Titan (2013)",
|
|
"year": 2013,
|
|
"status": "Ended",
|
|
"plot": "Humans fight against giant humanoid Titans.",
|
|
"genres": ["Animation", "Action", "Drama"],
|
|
"studio": ["Wit Studio", "MAPPA"],
|
|
"premiered": "2013-04-07",
|
|
"rating": 9.0,
|
|
"rating_votes": 500000,
|
|
"tmdb_id": 1429,
|
|
"tvdb_id": 267440,
|
|
"has_nfo": True,
|
|
"nfo_created_at": "2025-01-15T10:30:00Z",
|
|
"nfo_updated_at": "2025-01-15T10:30:00Z",
|
|
}
|
|
}
|
|
|
|
|
|
class TMDBSearchResult(BaseModel):
|
|
"""TMDB search result for auto-lookup functionality.
|
|
|
|
Attributes:
|
|
tmdb_id: TMDB ID of the matched series
|
|
title: Title from TMDB
|
|
year: Release year
|
|
overview: Short description
|
|
vote_average: TMDB rating
|
|
"""
|
|
|
|
tmdb_id: int = Field(..., description="TMDB ID")
|
|
title: str = Field(..., description="Title from TMDB")
|
|
year: Optional[int] = Field(None, description="Release year")
|
|
overview: Optional[str] = Field(None, description="Short description")
|
|
vote_average: Optional[float] = Field(None, description="TMDB rating")
|
|
|
|
|
|
class AnimeSettingsResponse(BaseModel):
|
|
"""Response payload for the Anime Settings page.
|
|
|
|
Surfaces every anime_series field that can be viewed or edited
|
|
by the user. Used by GET /api/anime/{key}/settings and the
|
|
PUT response.
|
|
"""
|
|
|
|
key: str = Field(..., description="Series unique key (primary identifier)")
|
|
name: str = Field(..., description="Series display name")
|
|
site: str = Field(..., description="Provider site URL")
|
|
folder: str = Field(..., description="Filesystem folder name")
|
|
year: Optional[int] = Field(None, description="Release year")
|
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
|
has_nfo: bool = Field(False, description="Whether tvshow.nfo exists")
|
|
nfo_path: Optional[str] = Field(None, description="Path to tvshow.nfo file")
|
|
nfo_created_at: Optional[str] = Field(None, description="ISO timestamp when NFO created")
|
|
nfo_updated_at: Optional[str] = Field(None, description="ISO timestamp when NFO updated")
|
|
loading_status: Optional[str] = Field(
|
|
None, description="Current loading status of the series"
|
|
)
|
|
episode_count: int = Field(0, description="Total number of episodes tracked")
|
|
missing_episode_count: int = Field(0, description="Number of missing episodes")
|
|
|
|
|
|
class AnimeSettingsUpdateRequest(BaseModel):
|
|
"""Request payload for PUT /api/anime/{key}/settings.
|
|
|
|
All fields are optional. Only the fields that are provided will
|
|
be updated. Field-level validation happens in the API endpoint
|
|
(e.g. folder sanitization, TMDB ID format).
|
|
"""
|
|
|
|
name: Optional[str] = Field(
|
|
None,
|
|
min_length=1,
|
|
max_length=500,
|
|
description="Series display name",
|
|
)
|
|
folder: Optional[str] = Field(
|
|
None,
|
|
min_length=1,
|
|
max_length=1000,
|
|
description="Filesystem folder name",
|
|
)
|
|
tmdb_id: Optional[int] = Field(
|
|
None,
|
|
ge=1,
|
|
le=9999999999,
|
|
description="TMDB ID (positive integer, max 10 digits)",
|
|
)
|
|
tvdb_id: Optional[int] = Field(
|
|
None,
|
|
ge=1,
|
|
le=9999999999,
|
|
description="TVDB ID (positive integer, max 10 digits)",
|
|
)
|
|
site: Optional[str] = Field(
|
|
None,
|
|
max_length=500,
|
|
description="Provider site URL",
|
|
)
|
|
apply_to_nfo: bool = Field(
|
|
False,
|
|
description="If true, regenerate tvshow.nfo with the new values",
|
|
)
|
|
rename_disk: bool = Field(
|
|
False,
|
|
description="If true and folder changed, rename the folder on disk",
|
|
)
|
|
|
|
|
|
class AnimeSettingsRegenerateNfoResponse(BaseModel):
|
|
"""Response payload for POST /api/anime/{key}/regenerate-nfo."""
|
|
|
|
success: bool = Field(..., description="Whether regeneration succeeded")
|
|
message: str = Field(..., description="Human-readable result message")
|
|
nfo_path: Optional[str] = Field(None, description="Path to regenerated NFO file")
|
|
repaired_tags: List[str] = Field(
|
|
default_factory=list,
|
|
description="Tags that were missing before regeneration",
|
|
)
|