"""Rich Studio Ghibli API dataclasses.
The high-level Ghibli backend reads a vendored JSON snapshot from
``animedex/data/ghibli.json``. The snapshot mirrors the public Studio
Ghibli API's five record families: films, people, locations, species,
and vehicles. Every rich type inherits from
:class:`~animedex.models.common.BackendRichModel` so a snapshot record
round-trips with every upstream key preserved.
Films project naturally onto :class:`~animedex.models.anime.Anime` and
people project onto :class:`~animedex.models.character.Character`.
Locations, vehicles, and species currently have no cross-source common
type and render through the generic source-attributed rich-model path.
"""
from __future__ import annotations
from datetime import date
from typing import List, Optional
from animedex.models.anime import Anime, AnimeRating, AnimeTitle
from animedex.models.character import Character
from animedex.models.common import BackendRichModel, SourceTag
[docs]
class GhibliFilm(BackendRichModel):
"""One film record from the Studio Ghibli API snapshot."""
id: str
title: str
original_title: Optional[str] = None
original_title_romanised: Optional[str] = None
image: Optional[str] = None
movie_banner: Optional[str] = None
description: Optional[str] = None
director: Optional[str] = None
producer: Optional[str] = None
release_date: Optional[str] = None
running_time: Optional[str] = None
rt_score: Optional[str] = None
people: List[str] = []
species: List[str] = []
locations: List[str] = []
vehicles: List[str] = []
url: Optional[str] = None
source_tag: Optional[SourceTag] = None
[docs]
def to_common(self) -> Anime:
"""Project this film onto the cross-source anime shape.
:return: Cross-source projection.
:rtype: animedex.models.anime.Anime
"""
score = None
try:
if self.rt_score is not None:
score = AnimeRating(score=float(self.rt_score), scale=100.0)
except (TypeError, ValueError): # pragma: no cover - defensive
score = None
year = _to_int(self.release_date)
aired = date(year, 1, 1) if year else None
return Anime(
id=f"ghibli:{self.id}",
title=AnimeTitle(
romaji=self.original_title_romanised or self.title,
english=self.title,
native=self.original_title,
),
ids={"ghibli": self.id},
score=score,
studios=["Studio Ghibli"],
description=self.description,
status="finished",
format="MOVIE",
season_year=year,
aired_from=aired,
duration_minutes=_to_int(self.running_time),
cover_image_url=self.image,
banner_image_url=self.movie_banner,
source_material="original",
source=self.source_tag or _default_src(),
)
[docs]
class GhibliPerson(BackendRichModel):
"""One person / character record from the snapshot."""
id: str
name: str
gender: Optional[str] = None
age: Optional[str] = None
eye_color: Optional[str] = None
hair_color: Optional[str] = None
films: List[str] = []
species: Optional[str] = None
url: Optional[str] = None
source_tag: Optional[SourceTag] = None
[docs]
def to_common(self) -> Character:
"""Project this person onto the cross-source character shape.
:return: Cross-source projection.
:rtype: animedex.models.character.Character
"""
profile = []
if self.eye_color:
profile.append(f"Eye color: {self.eye_color}")
if self.hair_color:
profile.append(f"Hair color: {self.hair_color}")
return Character(
id=f"ghibli:char:{self.id}",
name=self.name,
gender=self.gender,
age=self.age,
description="; ".join(profile) if profile else None,
source=self.source_tag or _default_src(),
)
[docs]
class GhibliLocation(BackendRichModel):
"""One location record from the snapshot."""
id: str
name: str
climate: Optional[str] = None
terrain: Optional[str] = None
surface_water: Optional[str] = None
residents: List[str] = []
films: List[str] = []
url: Optional[str] = None
source_tag: Optional[SourceTag] = None
[docs]
class GhibliVehicle(BackendRichModel):
"""One vehicle record from the snapshot."""
id: str
name: str
description: Optional[str] = None
vehicle_class: Optional[str] = None
length: Optional[str] = None
pilot: Optional[str] = None
films: List[str] = []
url: Optional[str] = None
source_tag: Optional[SourceTag] = None
[docs]
class GhibliSpecies(BackendRichModel):
"""One species record from the snapshot."""
id: str
name: str
classification: Optional[str] = None
eye_colors: Optional[str] = None
hair_colors: Optional[str] = None
people: List[str] = []
films: List[str] = []
url: Optional[str] = None
source_tag: Optional[SourceTag] = None
def _to_int(value: Optional[str]) -> Optional[int]:
"""Parse an integer from a Studio Ghibli string field."""
if value is None:
return None
try:
return int(str(value).replace(",", ""))
except (TypeError, ValueError):
return None
def _default_src() -> SourceTag:
"""Construct a fallback source tag for direct model usage."""
from datetime import datetime, timezone
return SourceTag(backend="ghibli", fetched_at=datetime.now(timezone.utc))
[docs]
def selftest() -> bool:
"""Smoke-test the Ghibli rich models.
Validates representative film, person, location, vehicle, and
species records; confirms film and person common projections carry
Ghibli source attribution.
:return: ``True`` on success; raises on schema drift.
:rtype: bool
"""
from datetime import datetime, timezone
src = SourceTag(backend="ghibli", fetched_at=datetime.now(timezone.utc))
film = GhibliFilm.model_validate(
{
"id": "film-1",
"title": "Sample Film",
"original_title": "Sample Native",
"original_title_romanised": "Sample Romanised",
"release_date": "1986",
"running_time": "124",
"rt_score": "95",
"source_tag": src.model_dump(),
}
)
assert film.to_common().source.backend == "ghibli"
person = GhibliPerson.model_validate(
{"id": "person-1", "name": "Sample Person", "eye_color": "Black", "source_tag": src.model_dump()}
)
assert person.to_common().source.backend == "ghibli"
GhibliLocation.model_validate({"id": "loc-1", "name": "Irontown"})
GhibliVehicle.model_validate({"id": "veh-1", "name": "Goliath"})
GhibliSpecies.model_validate({"id": "sp-1", "name": "Human"})
return True