Source code for animedex.backends.shikimori.models

"""Rich Shikimori dataclasses."""

from __future__ import annotations

from datetime import date
from typing import Any, Dict, List, Optional

from animedex.models.anime import Anime, AnimeRating, AnimeTitle, NextAiringEpisode
from animedex.models.character import Character, Staff, Studio
from animedex.models.common import BackendRichModel, PartialDate, SourceTag
from animedex.models.manga import Manga


[docs] class ShikimoriImage(BackendRichModel): """Image URL block used by Shikimori resources.""" original: Optional[str] = None preview: Optional[str] = None x96: Optional[str] = None x48: Optional[str] = None
[docs] class ShikimoriEntity(BackendRichModel): """Generic Shikimori entity reference.""" id: Optional[int] = None name: Optional[str] = None russian: Optional[str] = None image: Optional[ShikimoriImage] = None url: Optional[str] = None kind: Optional[str] = None entry_type: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class ShikimoriStudio(BackendRichModel): """Studio record.""" id: int name: Optional[str] = None filtered_name: Optional[str] = None real: Optional[bool] = None image: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> Studio: """Project the studio onto the common studio shape.""" return Studio( id=f"shikimori:studio:{self.id}", name=self.filtered_name or self.name or "", is_animation_studio=self.real, source=self.source_tag or _default_src(), )
[docs] class ShikimoriPublisher(BackendRichModel): """Publisher row from ``/api/publishers``.""" id: int name: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class ShikimoriVideo(BackendRichModel): """Video row from ``/api/animes/{id}/videos``.""" id: Optional[int] = None url: Optional[str] = None image_url: Optional[str] = None player_url: Optional[str] = None name: Optional[str] = None kind: Optional[str] = None hosting: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class ShikimoriScreenshot(BackendRichModel): """Screenshot row from ``/api/animes/{id}/screenshots``.""" original: Optional[str] = None preview: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class ShikimoriCharacter(BackendRichModel): """Character reference from anime roles.""" id: int name: Optional[str] = None russian: Optional[str] = None image: Optional[ShikimoriImage] = None url: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> Character: """Project this character onto the common character shape.""" return Character( id=f"shikimori:character:{self.id}", name=self.name or self.russian or "", name_native=self.russian, image_url=_absolute_url(self.image.original if self.image else None), source=self.source_tag or _default_src(), )
[docs] class ShikimoriPerson(BackendRichModel): """Person reference or top-level person record.""" id: int name: Optional[str] = None russian: Optional[str] = None image: Optional[ShikimoriImage] = None url: Optional[str] = None japanese: Optional[str] = None job_title: Optional[str] = None birth_on: Optional[Dict[str, Any]] = None deceased_on: Optional[Dict[str, Any]] = None website: Optional[str] = None groupped_roles: List[List[Any]] = [] roles: List[Dict[str, Any]] = [] works: List[Dict[str, Any]] = [] topic_id: Optional[int] = None person_favoured: Optional[bool] = None producer: Optional[bool] = None producer_favoured: Optional[bool] = None mangaka: Optional[bool] = None mangaka_favoured: Optional[bool] = None seyu: Optional[bool] = None seyu_favoured: Optional[bool] = None updated_at: Optional[str] = None thread_id: Optional[int] = None birthday: Optional[Dict[str, Any]] = None source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> Staff: """Project this person onto the common staff shape.""" occupations = [] for row in self.groupped_roles: if row and isinstance(row[0], str): occupations.append(row[0]) if not occupations and self.job_title: occupations.append(self.job_title) return Staff( id=f"shikimori:person:{self.id}", name=self.name or self.russian or "", name_native=self.japanese or self.russian, occupations=occupations, date_of_birth=_partial_date(self.birth_on), image_url=_absolute_url(self.image.original if self.image else None), description=self.job_title, source=self.source_tag or _default_src(), )
[docs] class ShikimoriManga(BackendRichModel): """Manga or ranobe record from ``/api/mangas`` and ``/api/ranobe``.""" id: int name: Optional[str] = None russian: Optional[str] = None image: Optional[ShikimoriImage] = None url: Optional[str] = None kind: Optional[str] = None score: Optional[str] = None status: Optional[str] = None volumes: Optional[int] = None chapters: Optional[int] = None aired_on: Optional[str] = None released_on: Optional[str] = None english: List[Optional[str]] = [] japanese: List[Optional[str]] = [] synonyms: List[str] = [] license_name_ru: Optional[str] = None description: Optional[str] = None description_html: Optional[str] = None description_source: Optional[str] = None franchise: Optional[str] = None favoured: Optional[bool] = None anons: Optional[bool] = None ongoing: Optional[bool] = None thread_id: Optional[int] = None topic_id: Optional[int] = None myanimelist_id: Optional[int] = None rates_scores_stats: List[Dict[str, Any]] = [] rates_statuses_stats: List[Dict[str, Any]] = [] licensors: List[str] = [] genres: List[ShikimoriEntity] = [] publishers: List[ShikimoriPublisher] = [] user_rate: Optional[Dict[str, Any]] = None source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> Manga: """Project this Shikimori manga or ranobe onto the common manga shape.""" title = self.name or _first_string(self.english) or self.russian or "" return Manga( id=f"shikimori:manga:{self.id}", title=title, cover_url=_absolute_url(self.image.original if self.image else None), chapters=[], languages=[], description=self.description, status=_normalise_manga_status(self.status), format=_normalise_manga_format(self.kind), genres=[genre.name for genre in self.genres if genre.name], tags=[publisher.name for publisher in self.publishers if publisher.name], ids={"shikimori": str(self.id), **({"mal": str(self.myanimelist_id)} if self.myanimelist_id else {})}, source=self.source_tag or _default_src(), )
[docs] class ShikimoriUserImage(BackendRichModel): """User avatar URL block used by club member rows.""" x160: Optional[str] = None x148: Optional[str] = None x80: Optional[str] = None x64: Optional[str] = None x48: Optional[str] = None x32: Optional[str] = None x16: Optional[str] = None
[docs] class ShikimoriUser(BackendRichModel): """Small public user row nested inside club responses.""" id: int nickname: Optional[str] = None avatar: Optional[str] = None image: Optional[ShikimoriUserImage] = None last_online_at: Optional[str] = None url: Optional[str] = None
[docs] class ShikimoriClub(BackendRichModel): """Club record from ``/api/clubs`` and ``/api/clubs/{id}``.""" id: int name: Optional[str] = None logo: Optional[ShikimoriClubLogo] = None is_censored: Optional[bool] = None join_policy: Optional[str] = None comment_policy: Optional[str] = None description: Optional[str] = None description_html: Optional[str] = None mangas: List[ShikimoriEntity] = [] characters: List[ShikimoriCharacter] = [] thread_id: Optional[int] = None topic_id: Optional[int] = None user_role: Optional[str] = None style_id: Optional[int] = None members: List[ShikimoriUser] = [] animes: List[ShikimoriAnime] = [] images: List[Dict[str, Any]] = [] source_tag: Optional[SourceTag] = None
[docs] class ShikimoriRole(BackendRichModel): """Role row from ``/api/animes/{id}/roles``.""" roles: List[str] = [] roles_russian: List[str] = [] character: Optional[ShikimoriCharacter] = None person: Optional[ShikimoriPerson] = None source_tag: Optional[SourceTag] = None
[docs] class ShikimoriAnime(BackendRichModel): """Anime record from ``/api/animes`` and ``/api/animes/{id}``.""" id: int name: Optional[str] = None russian: Optional[str] = None image: Optional[ShikimoriImage] = None url: Optional[str] = None kind: Optional[str] = None score: Optional[str] = None status: Optional[str] = None episodes: Optional[int] = None episodes_aired: Optional[int] = None aired_on: Optional[str] = None released_on: Optional[str] = None rating: Optional[str] = None english: List[Optional[str]] = [] japanese: List[Optional[str]] = [] synonyms: List[str] = [] duration: Optional[int] = None description: Optional[str] = None description_html: Optional[str] = None franchise: Optional[str] = None favoured: Optional[bool] = None anons: Optional[bool] = None ongoing: Optional[bool] = None myanimelist_id: Optional[int] = None rates_scores_stats: List[Dict[str, Any]] = [] rates_statuses_stats: List[Dict[str, Any]] = [] updated_at: Optional[str] = None next_episode_at: Optional[str] = None fansubbers: List[str] = [] fandubbers: List[str] = [] licensors: List[str] = [] genres: List[ShikimoriEntity] = [] studios: List[ShikimoriStudio] = [] videos: List[ShikimoriVideo] = [] screenshots: List[ShikimoriScreenshot] = [] source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> Anime: """Project this Shikimori anime onto the common anime shape.""" title = self.name or _first_string(self.english) or self.russian or "" score = _parse_float(self.score) return Anime( id=f"shikimori:{self.id}", title=AnimeTitle(romaji=title, english=_first_string(self.english), native=_first_string(self.japanese)), score=AnimeRating(score=score, scale=10.0, votes=_score_votes(self.rates_scores_stats)) if score is not None else None, episodes=self.episodes, studios=[ studio.filtered_name or studio.name or "" for studio in self.studios if studio.filtered_name or studio.name ], description=self.description, genres=[genre.name for genre in self.genres if genre.name], status=_normalise_status(self.status), format=_normalise_format(self.kind), aired_from=_parse_date(self.aired_on), aired_to=_parse_date(self.released_on), duration_minutes=self.duration, cover_image_url=_absolute_url(self.image.original if self.image else None), trailer_url=self.videos[0].url if self.videos else None, age_rating=self.rating, title_synonyms=[s for s in self.synonyms if s], ids={"shikimori": str(self.id), **({"mal": str(self.myanimelist_id)} if self.myanimelist_id else {})}, source=self.source_tag or _default_src(), )
[docs] class ShikimoriCalendarEntry(BackendRichModel): """Calendar row from ``/api/calendar``.""" next_episode: Optional[int] = None next_episode_at: Optional[str] = None duration: Optional[int] = None anime: Optional[ShikimoriAnime] = None source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> Anime: """Project the nested anime onto the common anime shape.""" anime = self.anime or ShikimoriAnime(id=0) common = anime.to_common() airing_at = _parse_datetime(self.next_episode_at) if airing_at is None or self.next_episode is None: return common return common.model_copy( update={ "next_airing_episode": NextAiringEpisode( airing_at=airing_at, time_until_airing_seconds=0, episode=self.next_episode, ) } )
[docs] class ShikimoriTopic(BackendRichModel): """Topic row from anime, manga, or global topic endpoints.""" id: Optional[int] = None topic_title: Optional[str] = None body: Optional[str] = None html_body: Optional[str] = None type: Optional[str] = None linked_id: Optional[int] = None linked_type: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class ShikimoriResource(BackendRichModel): """Generic Shikimori response row for heterogeneous endpoints.""" id: Optional[Any] = None source_tag: Optional[SourceTag] = None
def _default_src() -> SourceTag: """Construct a fallback source tag for direct model use.""" from datetime import datetime, timezone return SourceTag(backend="shikimori", fetched_at=datetime.now(timezone.utc)) def _absolute_url(path: Optional[str]) -> Optional[str]: if not path: return None if path.startswith("http://") or path.startswith("https://"): return path if not path.startswith("/"): path = "/" + path return "https://shikimori.io" + path def _first_string(values: List[Optional[str]]) -> Optional[str]: for value in values: if value: return value return None def _parse_float(value: Optional[str]) -> Optional[float]: if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _score_votes(rows: List[Dict[str, Any]]) -> Optional[int]: total = 0 seen = False for row in rows: try: total += int(row.get("value")) seen = True except (TypeError, ValueError): continue return total if seen else None def _parse_date(value: Optional[str]) -> Optional[date]: if not value: return None try: year, month, day = [int(part) for part in value.split("-", 2)] return date(year, month, day) except (TypeError, ValueError): return None def _parse_datetime(value: Optional[str]): if not value: return None from datetime import datetime try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except (TypeError, ValueError): return None def _partial_date(value: Optional[Dict[str, Any]]) -> Optional[PartialDate]: if not value: return None return PartialDate(year=value.get("year"), month=value.get("month"), day=value.get("day")) def _normalise_status(value: Optional[str]) -> Optional[str]: if value == "ongoing": return "airing" if value == "released": return "finished" if value == "anons": return "upcoming" if not value: return None return "unknown" def _normalise_format(value: Optional[str]) -> Optional[str]: if not value: return None mapping = { "tv": "TV", "tv_13": "TV", "tv_24": "TV", "tv_48": "TV", "tv_special": "TV_SHORT", "movie": "MOVIE", "ova": "OVA", "ona": "ONA", "special": "SPECIAL", "music": "MUSIC", } return mapping.get(value) def _normalise_manga_status(value: Optional[str]) -> Optional[str]: if value == "ongoing": return "ongoing" if value == "released": return "completed" if value == "paused": return "hiatus" if value == "discontinued": return "cancelled" if not value: return None return "unknown" def _normalise_manga_format(value: Optional[str]) -> Optional[str]: if not value: return None mapping = { "manga": "MANGA", "manhwa": "MANHWA", "manhua": "MANHUA", "one_shot": "ONE_SHOT", "doujin": "DOUJINSHI", "light_novel": "NOVEL", "novel": "NOVEL", } return mapping.get(value)
[docs] def selftest() -> bool: """Smoke-test the Shikimori rich models.""" from datetime import datetime, timezone src = SourceTag(backend="shikimori", fetched_at=datetime.now(timezone.utc)) anime = ShikimoriAnime( id=52991, name="Sousou no Frieren", english=["Frieren: Beyond Journey's End"], japanese=["葬送のフリーレン"], score="9.27", status="released", episodes=28, source_tag=src, ) common = anime.to_common() assert common.id == "shikimori:52991" assert common.status == "finished" assert common.source.backend == "shikimori" manga = ShikimoriManga(id=2, name="Berserk", kind="manga", status="ongoing", source_tag=src) assert manga.to_common().format == "MANGA" person = ShikimoriPerson(id=1870, name="Hayao Miyazaki", birth_on={"year": 1941, "month": 1, "day": 5}) assert person.to_common().date_of_birth.year == 1941 return True