Source code for nyora.models

"""Typed data models for the Nyora SDK.

Lightweight, slotted dataclasses that mirror the JSON returned by the Nyora
parser runtime and helper REST API. Every model exposes a tolerant
``from_json`` classmethod that accepts the raw camelCase payloads and coerces
field types defensively, so missing or malformed fields fall back to sensible
defaults rather than raising. These types are returned throughout
:class:`nyora.direct.Nyora`, :class:`nyora.client.Nyora`, and the service
objects.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, TypeVar

JsonDict = dict[str, Any]
T = TypeVar("T")


def _list(value: Any) -> list[Any]:
    """Return ``value`` if it is a list, else an empty list."""
    return value if isinstance(value, list) else []


def _dict(value: Any) -> JsonDict:
    """Return ``value`` if it is a dict, else an empty dict."""
    return value if isinstance(value, dict) else {}


def _float(value: Any, default: float = 0.0) -> float:
    """Coerce ``value`` to ``float``, returning ``default`` on failure."""
    try:
        return float(value)
    except (TypeError, ValueError):
        return default


def _int(value: Any, default: int = 0) -> int:
    """Coerce ``value`` to ``int``, returning ``default`` on failure."""
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


[docs] @dataclass(slots=True) class MangaPage: """A single readable image page of a chapter. Attributes: url: The image URL. headers: Request headers required to fetch the image (e.g. ``Referer``). """ url: str headers: dict[str, str] = field(default_factory=dict)
[docs] @classmethod def from_json(cls, data: Any) -> MangaPage: """Build a :class:`MangaPage` from a raw payload. Args: data: A page object, or a bare string treated as the URL. Returns: The parsed page. """ if isinstance(data, str): return cls(url=data) obj = _dict(data) headers = {str(k): str(v) for k, v in _dict(obj.get("headers")).items()} return cls(url=str(obj.get("url", "")), headers=headers)
[docs] @dataclass(slots=True) class MangaChapter: """A chapter belonging to a manga. Attributes: id: Stable chapter identifier. title: Display title. number: Chapter number (may be fractional). volume: Volume number, or ``0`` if unknown. url: Source-relative or absolute chapter URL. scanlator: Scanlation group, if known. upload_date: Upload timestamp in epoch milliseconds. branch: Scanlation branch/translation name, if any. pages: Resolved pages, when already loaded. index: Position within the chapter list. """ id: str title: str number: float = 0.0 volume: int = 0 url: str = "" scanlator: str | None = None upload_date: int = 0 branch: str | None = None pages: list[MangaPage] = field(default_factory=list) index: int = 0
[docs] @classmethod def from_json(cls, data: Any) -> MangaChapter: """Build a :class:`MangaChapter` from a raw payload. Args: data: A chapter object from the parser or helper. Returns: The parsed chapter. """ obj = _dict(data) return cls( id=str(obj.get("id", "")), title=str(obj.get("title", "")), number=_float(obj.get("number")), volume=_int(obj.get("volume")), url=str(obj.get("url", "")), scanlator=obj.get("scanlator"), upload_date=_int(obj.get("uploadDate")), branch=obj.get("branch"), pages=[MangaPage.from_json(item) for item in _list(obj.get("pages"))], index=_int(obj.get("index")), )
[docs] @dataclass(slots=True) class Manga: """A manga entry as returned in listings and details. Attributes: id: Stable manga identifier. title: Primary title. alt_titles: Alternative titles. url: Source-relative or absolute manga URL. public_url: Public web URL for the manga, if distinct. rating: Normalized rating, or ``-1.0`` when unknown. is_nsfw: Whether the entry is flagged adult/NSFW. content_rating: Source-provided content rating, if any. cover_url: Cover thumbnail URL. large_cover_url: High-resolution cover URL, if available. state: Publication state (e.g. ongoing/finished), if known. authors: Author names. source: Raw source metadata as a dict. source_id: Identifier of the owning source. description: Synopsis text. tags: Genre/tag dicts. chapters: Chapters, when already loaded. unread: Unread chapter count, for library entries. progress: Read progress fraction, for library entries. """ id: str title: str alt_titles: list[str] = field(default_factory=list) url: str = "" public_url: str = "" rating: float = -1.0 is_nsfw: bool = False content_rating: str | None = None cover_url: str = "" large_cover_url: str | None = None state: str | None = None authors: list[str] = field(default_factory=list) source: JsonDict = field(default_factory=dict) source_id: str = "" description: str = "" tags: list[JsonDict] = field(default_factory=list) chapters: list[MangaChapter] = field(default_factory=list) unread: int = 0 progress: float = 0.0
[docs] @classmethod def from_json(cls, data: Any) -> Manga: """Build a :class:`Manga` from a raw payload. Args: data: A manga object from the parser or helper. Returns: The parsed manga. """ obj = _dict(data) return cls( id=str(obj.get("id", "")), title=str(obj.get("title", "")), alt_titles=[str(item) for item in _list(obj.get("altTitles"))], url=str(obj.get("url", "")), public_url=str(obj.get("publicUrl", "")), rating=_float(obj.get("rating"), -1.0), is_nsfw=bool(obj.get("isNsfw", False)), content_rating=obj.get("contentRating"), cover_url=str(obj.get("coverUrl", "")), large_cover_url=obj.get("largeCoverUrl"), state=obj.get("state"), authors=[str(item) for item in _list(obj.get("authors"))], source=_dict(obj.get("source")), source_id=str(obj.get("sourceId", "")), description=str(obj.get("description", "")), tags=[_dict(item) for item in _list(obj.get("tags"))], chapters=[MangaChapter.from_json(item) for item in _list(obj.get("chapters"))], unread=_int(obj.get("unread")), progress=_float(obj.get("progress")), )
[docs] @dataclass(slots=True) class Source: """A content source (site) the SDK can read from. Attributes: id: Stable source identifier. name: Human-readable source name. lang: Primary content language/locale code. base_url: The source's base site URL. engine: Parser engine (e.g. ``"JavaScript"``). content_type: Content type (e.g. ``"Manga"``). is_installed: Whether the source is installed/available. is_pinned: Whether the user pinned the source. is_nsfw: Whether the source is flagged adult/NSFW. is_obsolete: Whether the source is deprecated. icon_url: Source icon URL. version: Source/parser version string. notes: Free-form notes. can_uninstall: Whether the source may be uninstalled. """ id: str name: str lang: str = "" base_url: str = "" engine: str = "" content_type: str = "" is_installed: bool = False is_pinned: bool = False is_nsfw: bool = False is_obsolete: bool = False icon_url: str = "" version: str = "" notes: str = "" can_uninstall: bool = True
[docs] @classmethod def from_json(cls, data: Any) -> Source: """Build a :class:`Source` from a raw payload. Accepts both ``name``/``title`` and ``lang``/``locale`` and ``baseUrl``/``site`` aliases. Args: data: A source object from the parser or helper. Returns: The parsed source. """ obj = _dict(data) return cls( id=str(obj.get("id", "")), name=str(obj.get("name") or obj.get("title") or ""), lang=str(obj.get("lang") or obj.get("locale") or ""), base_url=str(obj.get("baseUrl") or obj.get("site") or ""), engine=str(obj.get("engine", "")), content_type=str(obj.get("contentType", "")), is_installed=bool(obj.get("isInstalled", False)), is_pinned=bool(obj.get("isPinned", False)), is_nsfw=bool(obj.get("isNsfw", False)), is_obsolete=bool(obj.get("isObsolete", False)), icon_url=str(obj.get("iconUrl", "")), version=str(obj.get("version", "")), notes=str(obj.get("notes", "")), can_uninstall=bool(obj.get("canUninstall", True)), )
[docs] @dataclass(slots=True) class SourceFilter: """A search filter advertised by a source. Attributes: name: Filter name. type_name: Filter widget/type (e.g. select, toggle). values: Allowed values for the filter. """ name: str type_name: str values: list[str] = field(default_factory=list)
[docs] @classmethod def from_json(cls, data: Any) -> SourceFilter: """Build a :class:`SourceFilter` from a raw payload. Args: data: A filter object from the helper. Returns: The parsed filter. """ obj = _dict(data) return cls( name=str(obj.get("name", "")), type_name=str(obj.get("typeName", "")), values=[str(item) for item in _list(obj.get("values"))], )
[docs] @dataclass(slots=True) class SearchPage: """One page of manga results from browse or search. Attributes: entries: The manga on this page. has_next_page: Whether a further page is likely available. """ entries: list[Manga] has_next_page: bool = False
[docs] @classmethod def from_json(cls, data: Any) -> SearchPage: """Build a :class:`SearchPage` from a raw payload. Args: data: A page object with ``entries`` and ``hasNextPage``. Returns: The parsed page. """ obj = _dict(data) return cls( entries=[Manga.from_json(item) for item in _list(obj.get("entries"))], has_next_page=bool(obj.get("hasNextPage", False)), )
[docs] @dataclass(slots=True) class MangaDetails: """Full metadata for one manga together with its chapter list. Attributes: manga: The manga metadata. chapters: The manga's chapters. """ manga: Manga chapters: list[MangaChapter]
[docs] @classmethod def from_json(cls, data: Any) -> MangaDetails: """Build a :class:`MangaDetails` from a raw payload. Args: data: An object with ``manga`` and ``chapters``. Returns: The parsed details. """ obj = _dict(data) return cls( manga=Manga.from_json(obj.get("manga")), chapters=[MangaChapter.from_json(item) for item in _list(obj.get("chapters"))], )
[docs] @dataclass(slots=True) class HistoryEntry: """A reading-history record for a manga. Attributes: manga: The manga that was read. chapter_id: The last-read chapter identifier. page: The last-read page index. percent: Read progress fraction within the chapter. updated_at: Last-update timestamp in epoch milliseconds. """ manga: Manga chapter_id: str = "" page: int = 0 percent: float = 0.0 updated_at: int = 0
[docs] @classmethod def from_json(cls, data: Any) -> HistoryEntry: """Build a :class:`HistoryEntry` from a raw payload. Args: data: A history object from the helper. Returns: The parsed entry. """ obj = _dict(data) return cls( manga=Manga.from_json(obj.get("manga")), chapter_id=str(obj.get("chapterId", "")), page=_int(obj.get("page")), percent=_float(obj.get("percent")), updated_at=_int(obj.get("updatedAt")), )
[docs] @dataclass(slots=True) class Category: """A user-defined library category. Attributes: id: Category identifier. title: Display title. manga_count: Number of manga in the category. """ id: int title: str manga_count: int = 0
[docs] @classmethod def from_json(cls, data: Any) -> Category: """Build a :class:`Category` from a raw payload. Args: data: A category object from the helper. Returns: The parsed category. """ obj = _dict(data) return cls( id=_int(obj.get("id")), title=str(obj.get("title", "")), manga_count=_int(obj.get("mangaCount")), )
[docs] @dataclass(slots=True) class Download: """A chapter download task and its progress. Attributes: id: Download task identifier. source_id: Identifier of the owning source. manga_title: Title of the manga being downloaded. chapter_title: Title of the chapter being downloaded. chapter_url: URL of the chapter being downloaded. status: Task status string. total_pages: Total number of pages to download. completed_pages: Pages downloaded so far. failed_pages: Pages that failed to download. file_path: Output path once complete, if available. error: Error message when the task failed, if any. """ id: str source_id: str manga_title: str chapter_title: str chapter_url: str status: str total_pages: int = 0 completed_pages: int = 0 failed_pages: int = 0 file_path: str | None = None error: str | None = None
[docs] @classmethod def from_json(cls, data: Any) -> Download: """Build a :class:`Download` from a raw payload. Args: data: A download object from the helper. Returns: The parsed download. """ obj = _dict(data) return cls( id=str(obj.get("id", "")), source_id=str(obj.get("sourceId", "")), manga_title=str(obj.get("mangaTitle", "")), chapter_title=str(obj.get("chapterTitle", "")), chapter_url=str(obj.get("chapterUrl", "")), status=str(obj.get("status", "")), total_pages=_int(obj.get("totalPages")), completed_pages=_int(obj.get("completedPages")), failed_pages=_int(obj.get("failedPages")), file_path=obj.get("filePath"), error=obj.get("error"), )
[docs] @dataclass(slots=True) class DownloadSettings: """Download subsystem settings. Attributes: max_concurrent_downloads: Maximum simultaneous downloads. format: Output format (e.g. ``"AUTO"``). """ max_concurrent_downloads: int = 3 format: str = "AUTO"
[docs] @classmethod def from_json(cls, data: Any) -> DownloadSettings: """Build :class:`DownloadSettings` from a raw payload. Accepts either a bare settings object or one nested under ``settings``. Args: data: A settings object from the helper. Returns: The parsed settings. """ obj = _dict(data) settings = _dict(obj.get("settings", obj)) return cls( max_concurrent_downloads=_int(settings.get("maxConcurrentDownloads"), 3), format=str(settings.get("format", "AUTO")), )
[docs] @dataclass(slots=True) class MangaPrefs: """Per-manga reader preferences. Attributes: manga_id: Identifier of the manga these preferences apply to. reader_mode: Reader layout/mode. brightness: Brightness adjustment. contrast: Contrast multiplier. saturation: Saturation multiplier. hue: Hue rotation. palette: Named color palette. present: Whether stored preferences exist for this manga. """ manga_id: str reader_mode: str = "" brightness: float = 0.0 contrast: float = 1.0 saturation: float = 1.0 hue: float = 0.0 palette: str = "" present: bool = False
[docs] @classmethod def from_json(cls, data: Any) -> MangaPrefs: """Build :class:`MangaPrefs` from a raw payload. Args: data: A preferences object from the helper. Returns: The parsed preferences. """ obj = _dict(data) return cls( manga_id=str(obj.get("mangaId", "")), reader_mode=str(obj.get("readerMode", "")), brightness=_float(obj.get("brightness")), contrast=_float(obj.get("contrast"), 1.0), saturation=_float(obj.get("saturation"), 1.0), hue=_float(obj.get("hue")), palette=str(obj.get("palette", "")), present=bool(obj.get("present", False)), )
[docs] @dataclass(slots=True) class GlobalSearchGroup: """Results from one source within a cross-source global search. Attributes: source_id: Identifier of the source that produced these results. source_name: Display name of the source. entries: Matching manga from this source. error: Error message if this source's search failed, else ``None``. """ source_id: str source_name: str entries: list[Manga] error: str | None = None
[docs] @classmethod def from_json(cls, data: Any) -> GlobalSearchGroup: """Build a :class:`GlobalSearchGroup` from a raw payload. Args: data: A group object from the helper. Returns: The parsed group. """ obj = _dict(data) return cls( source_id=str(obj.get("sourceId", "")), source_name=str(obj.get("sourceName", "")), entries=[Manga.from_json(item) for item in _list(obj.get("entries"))], error=obj.get("error"), )
[docs] @dataclass(slots=True) class Stats: """Aggregate reading statistics. Attributes: total_chapters: Total chapters read. distinct_manga: Number of distinct manga read. favourites_count: Number of favourited manga. longest_streak_days: Longest consecutive reading streak in days. top_sources: Per-source usage breakdown dicts. """ total_chapters: int = 0 distinct_manga: int = 0 favourites_count: int = 0 longest_streak_days: int = 0 top_sources: list[JsonDict] = field(default_factory=list)
[docs] @classmethod def from_json(cls, data: Any) -> Stats: """Build :class:`Stats` from a raw payload. Args: data: A stats object from the helper. Returns: The parsed statistics. """ obj = _dict(data) return cls( total_chapters=_int(obj.get("totalChapters")), distinct_manga=_int(obj.get("distinctManga")), favourites_count=_int(obj.get("favouritesCount")), longest_streak_days=_int(obj.get("longestStreakDays")), top_sources=[_dict(item) for item in _list(obj.get("topSources"))], )
[docs] @dataclass(slots=True) class BackupImportResult: """Outcome of importing a backup archive. Attributes: ok: Whether the import succeeded. imported_favourites: Number of favourites imported. imported_history: Number of history records imported. """ ok: bool imported_favourites: int = 0 imported_history: int = 0
[docs] @classmethod def from_json(cls, data: Any) -> BackupImportResult: """Build a :class:`BackupImportResult` from a raw payload. Args: data: A result object from the helper. Returns: The parsed result. """ obj = _dict(data) return cls( ok=bool(obj.get("ok", False)), imported_favourites=_int(obj.get("importedFavourites")), imported_history=_int(obj.get("importedHistory")), )