"""Rich MangaDex dataclasses (one per resource type).
MangaDex serves data in a JSON:API-flavoured shape: every resource
is wrapped as ``{id, type, attributes, relationships}`` and listings
come back as
``{result, response, data: [...], limit, offset, total}``.
Every class below inherits from :class:`BackendRichModel` so
MangaDex payloads round-trip losslessly: declared fields are typed,
undeclared upstream fields are kept on the instance via
``extra='allow'``, aliases are accepted via ``populate_by_name=True``,
and the same data is re-emitted by ``model_dump``. The ``attributes``
sub-classes only spell out the fields the high-level API touches;
upstream may add more without breaking callers.
The :meth:`MangaDexManga.to_common` and
:meth:`MangaDexChapter.to_common` projections map onto
:class:`~animedex.models.manga.Manga` and
:class:`~animedex.models.manga.Chapter` so a downstream pipeline can
diff MangaDex output against any other manga upstream without
needing to know JSON:API.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from animedex.models.common import BackendRichModel, SourceTag
from animedex.models.manga import Chapter, Manga
# ---------- attribute sub-blocks ----------
[docs]
class MangaDexMangaAttributes(BackendRichModel):
"""The ``attributes`` block on a ``/manga/{id}`` resource."""
# MangaDex usually returns description / title / links as
# ``{lang: text}`` maps but occasionally returns an empty list
# ``[]`` for description on bare-bones records. ``Any`` keeps the
# lossless round-trip valid across both shapes.
title: Optional[Any] = None
altTitles: Optional[List[Any]] = None
description: Optional[Any] = None
isLocked: Optional[bool] = None
links: Optional[Any] = None
originalLanguage: Optional[str] = None
lastVolume: Optional[str] = None
lastChapter: Optional[str] = None
publicationDemographic: Optional[str] = None
status: Optional[str] = None
year: Optional[int] = None
contentRating: Optional[str] = None
tags: Optional[List[Dict[str, Any]]] = None
state: Optional[str] = None
chapterNumbersResetOnNewVolume: Optional[bool] = None
[docs]
class MangaDexChapterAttributes(BackendRichModel):
"""The ``attributes`` block on a ``/chapter/{id}`` resource."""
volume: Optional[str] = None
chapter: Optional[str] = None
title: Optional[str] = None
translatedLanguage: Optional[str] = None
externalUrl: Optional[str] = None
isUnavailable: Optional[bool] = None
publishAt: Optional[str] = None
readableAt: Optional[str] = None
pages: Optional[int] = None
uploader: Optional[str] = None
[docs]
class MangaDexCoverAttributes(BackendRichModel):
"""The ``attributes`` block on a ``/cover/{id}`` resource."""
description: Optional[str] = None
volume: Optional[str] = None
fileName: Optional[str] = None
locale: Optional[str] = None
# ---------- top-level resource shapes ----------
[docs]
class MangaDexManga(BackendRichModel):
"""JSON:API manga resource from ``/manga/{id}`` or
``/manga?title=...``.
:ivar id: MangaDex UUID.
:vartype id: str
:ivar type: JSON:API type tag — always ``"manga"``.
:vartype type: str
:ivar attributes: Typed manga attributes.
:vartype attributes: MangaDexMangaAttributes or None
:ivar relationships: List of ``{id, type, ...}`` relationship
descriptors (authors, artists, cover_art,
tags, etc.).
:vartype relationships: list[dict] or None
:ivar source_tag: Provenance tag.
:vartype source_tag: SourceTag or None
"""
id: str
type: str = "manga"
attributes: Optional[MangaDexMangaAttributes] = None
relationships: Optional[List[Dict[str, Any]]] = None
source_tag: Optional[SourceTag] = None
[docs]
def to_common(self) -> Manga:
"""Project this resource onto the cross-source
:class:`~animedex.models.manga.Manga` shape.
Notes:
* MangaDex's ``title`` and ``description`` are language-keyed
maps; we pick ``en`` first, ``ja-ro`` next, then any value.
* The cross-source ``Manga.chapters`` field models a *list*
of :class:`~animedex.models.manga.Chapter`, not a count;
MangaDex does not return the chapter list on
``/manga/{id}`` (the ``/manga/{id}/feed`` endpoint does),
so the projection sets ``chapters=[]``.
* ``status`` and ``contentRating`` map into the constrained
common literal sets via ``_normalise_status`` and
``_normalise_format``.
"""
attrs = self.attributes or MangaDexMangaAttributes()
return Manga(
id=f"mangadex:{self.id}",
title=_pick_localised(attrs.title) or "",
ids={"mangadex": self.id},
chapters=[],
status=_normalise_mangadex_status(attrs.status),
format=_format_from_demographic(attrs.publicationDemographic),
description=_pick_localised(attrs.description),
source=self.source_tag or _default_src(),
)
[docs]
class MangaDexChapter(BackendRichModel):
"""JSON:API chapter resource from ``/chapter/{id}`` or
``/manga/{id}/feed``."""
id: str
type: str = "chapter"
attributes: Optional[MangaDexChapterAttributes] = None
relationships: Optional[List[Dict[str, Any]]] = None
source_tag: Optional[SourceTag] = None
[docs]
def to_common(self) -> Chapter:
"""Project this resource onto the cross-source
:class:`~animedex.models.manga.Chapter` shape.
The cross-source :class:`Chapter` carries ``number`` and
``language`` as strings (MangaDex's ``"1"`` / ``"1.5"`` /
``"1.5a"`` shapes round-trip directly). MangaDex's
``publishAt`` / ``readableAt`` timestamps and ``volume`` /
``externalUrl`` are preserved on the rich shape but the
common :class:`Chapter` does not carry them — reach for the
rich model when those fields matter.
"""
attrs = self.attributes or MangaDexChapterAttributes()
return Chapter(
id=f"mangadex:{self.id}",
number=attrs.chapter or "",
title=attrs.title,
language=attrs.translatedLanguage or "",
pages=attrs.pages,
source=self.source_tag or _default_src(),
)
[docs]
class MangaDexCover(BackendRichModel):
"""JSON:API cover resource from ``/cover/{id}``.
The ``fileName`` attribute is the path component for the
upstream cover URL (resolved against
``https://uploads.mangadex.org/covers/<manga-id>/<fileName>``).
"""
id: str
type: str = "cover_art"
attributes: Optional[MangaDexCoverAttributes] = None
relationships: Optional[List[Dict[str, Any]]] = None
source_tag: Optional[SourceTag] = None
[docs]
class MangaDexUserAttributes(BackendRichModel):
"""The ``attributes`` block on a ``/user/me`` (or
``/user/{id}``-when-authenticated) resource."""
username: Optional[str] = None
roles: Optional[List[str]] = None
avatarFileName: Optional[str] = None
bannerFileName: Optional[str] = None
version: Optional[int] = None
[docs]
class MangaDexUser(BackendRichModel):
"""JSON:API user resource from ``/user/me`` and
``/user/{id}``."""
id: str
type: str = "user"
attributes: Optional[MangaDexUserAttributes] = None
relationships: Optional[List[Dict[str, Any]]] = None
source_tag: Optional[SourceTag] = None
[docs]
class MangaDexResource(BackendRichModel):
"""Catch-all JSON:API resource for endpoints we wrap but have not
typed individually.
Used for ``/author/{id}`` / ``/group/{id}`` / ``/list/{id}`` /
``/user/{id}`` / ``/manga/tag`` / ``/manga/{id}/recommendation``
/ ``/statistics/manga/{id}`` / ``/statistics/chapter/{id}`` /
``/statistics/group/{id}`` / ``/report/reasons/{category}`` /
``/manga/{id}/aggregate``.
The shape is the same JSON:API resource envelope; ``attributes``
is left as a ``dict`` because the typed-attribute story for these
endpoints would multiply the model count without much downstream
benefit. ``extra='allow'`` round-trips every upstream key.
"""
id: Optional[str] = None
type: Optional[str] = None
attributes: Optional[Dict[str, Any]] = None
relationships: Optional[List[Dict[str, Any]]] = None
source_tag: Optional[SourceTag] = None
# ---------- helpers ----------
def _default_src() -> SourceTag:
"""Construct a fallback :class:`SourceTag` when one isn't
already attached. Used by ``to_common()`` for direct-from-JSON
construction paths that bypass the high-level fetch helper."""
from datetime import datetime, timezone
return SourceTag(backend="mangadex", fetched_at=datetime.now(timezone.utc))
def _pick_localised(d: Any) -> Optional[str]:
"""Pick the most-readable string from a MangaDex
language-keyed attribute. Order: ``en``, ``ja-ro``, ``ja``,
then any non-empty value.
Tolerates non-dict inputs (the upstream occasionally returns an
empty list ``[]`` for missing description / links blocks).
"""
if not d or not isinstance(d, dict):
return None
for key in ("en", "ja-ro", "ja"):
v = d.get(key)
if v:
return v
for v in d.values():
if v:
return v
return None
def _normalise_mangadex_status(s: Optional[str]) -> Optional[str]:
"""Map MangaDex's ``status`` to the constrained
:data:`~animedex.models.manga.MangaStatus` literal set.
MangaDex uses ``"ongoing"`` / ``"completed"`` / ``"hiatus"`` /
``"cancelled"`` — already exactly the common set — so the
mapping is identity, with anything unrecognised falling through
to ``"unknown"``.
"""
if not s:
return None
norm = s.lower().strip()
if norm in {"ongoing", "completed", "hiatus", "cancelled"}:
return norm
return "unknown"
def _format_from_demographic(s: Optional[str]) -> Optional[str]:
"""MangaDex doesn't expose a top-level ``format`` field; the
closest signal on ``/manga/{id}`` is ``publicationDemographic``
(``"shounen"`` / ``"shoujo"`` / ``"josei"`` / ``"seinen"``),
which doesn't map cleanly onto the common
:data:`~animedex.models.manga.MangaFormat` set. Default to
``"MANGA"`` when the upstream gave us anything; ``None`` when
truly empty.
"""
if not s:
return None
return "MANGA"
[docs]
def selftest() -> bool:
"""Smoke-test the MangaDex rich models.
Validates a synthetic :class:`MangaDexManga` round-trips through
``model_dump_json`` / ``model_validate_json`` and projects to a
well-formed :class:`~animedex.models.manga.Manga`.
:return: ``True`` on success; raises on schema drift.
:rtype: bool
"""
from datetime import datetime, timezone
src = SourceTag(backend="_selftest", fetched_at=datetime.now(timezone.utc))
manga = MangaDexManga.model_validate(
{
"id": "801513ba-a712-498c-8f57-cae55b38cc92",
"type": "manga",
"attributes": {
"title": {"en": "Berserk"},
"description": {"en": "..."},
"status": "ongoing",
"publicationDemographic": "seinen",
"year": 1989,
},
"relationships": [{"id": "x", "type": "author"}],
"source_tag": src.model_dump(),
}
)
MangaDexManga.model_validate_json(manga.model_dump_json())
common = manga.to_common()
assert common.id == "mangadex:801513ba-a712-498c-8f57-cae55b38cc92"
assert common.title == "Berserk"
assert common.status == "ongoing"
chapter = MangaDexChapter.model_validate(
{"id": "abc", "type": "chapter", "attributes": {"chapter": "1.5", "translatedLanguage": "en"}}
)
cc = chapter.to_common()
assert cc.number == "1.5"
assert cc.language == "en"
return True