"""Rich nekos.best dataclasses (one per response shape).
nekos.best v2 has a tiny surface — three JSON-emitting endpoints —
but each carries enough metadata to make the SFW image collection
useful both as a CLI grab-bag and as a Python library lookup.
The two rich shapes pinned here are deliberately per-record, not
per-endpoint:
* :class:`NekosImage` — one image / GIF record. Comes back from
``/<category>?amount=N`` (each row in ``results``) and from
``/search`` (each row in ``results``).
* :class:`NekosCategoryFormat` — one ``{format, min, max}`` entry
from ``/endpoints``. The upstream emits a flat
``{<category>: {format, min, max}, ...}`` map, so the per-record
type is what makes the lossless round-trip contract well-defined.
The :class:`NekosImage` ``to_common()`` projects an upstream record
onto the cross-source :class:`~animedex.models.art.ArtPost`. The
projection is deterministic:
* ``rating`` is always ``"g"`` (nekos.best v2 has no NSFW tier).
* ``id`` is composed as ``"nekos:" + filename`` — nekos.best has no
numeric ID column, but every URL ends in a stable filename, so the
filename is the canonical identifier.
* ``tags`` carries the upstream's ``anime_name`` (when present) so a
cross-source pipeline can match against
:class:`~animedex.models.anime.Anime` by show.
"""
from __future__ import annotations
from typing import List, Optional
from animedex.models.art import ArtPost
from animedex.models.common import BackendRichModel, SourceTag
[docs]
class NekosImageDimensions(BackendRichModel):
"""``dimensions`` sub-block on a :class:`NekosImage` record.
:ivar width: Image width in pixels.
:vartype width: int or None
:ivar height: Image height in pixels.
:vartype height: int or None
"""
width: Optional[int] = None
height: Optional[int] = None
[docs]
class NekosImage(BackendRichModel):
"""A single image / GIF record from nekos.best v2.
Every record exposes ``url`` and ``dimensions``; the rest of the
fields are best-effort attribution. ``anime_name`` is set on
anime-derived assets; ``artist_name`` / ``artist_href`` /
``source_url`` are set on fan-art assets where the upstream
knows the provenance.
:ivar url: Direct asset URL.
:vartype url: str
:ivar dimensions: ``{width, height}`` in pixels.
:vartype dimensions: NekosImageDimensions or None
:ivar anime_name: Show name when the asset is anime-derived.
:vartype anime_name: str or None
:ivar artist_name: Artist attribution when the asset is fan art.
:vartype artist_name: str or None
:ivar artist_href: Artist's profile / portfolio URL.
:vartype artist_href: str or None
:ivar source_url: Original source URL (pixiv / twitter / official
site / etc.).
:vartype source_url: str or None
:ivar source_tag: Provenance tag stamped by the high-level
fetch helper.
:vartype source_tag: SourceTag or None
"""
url: str
dimensions: Optional[NekosImageDimensions] = None
anime_name: Optional[str] = None
artist_name: Optional[str] = None
artist_href: Optional[str] = None
source_url: Optional[str] = None
source_tag: Optional[SourceTag] = None
[docs]
def to_common(self) -> ArtPost:
"""Project this image record onto the cross-source
:class:`~animedex.models.art.ArtPost` shape.
``rating`` is always ``"g"`` (nekos.best v2 is SFW-only).
``id`` derives from the URL's filename, the only stable
per-asset identifier nekos.best exposes. The upstream's
``anime_name`` is propagated into ``tags`` so cross-source
consumers can match on show without an extra round-trip.
:return: Cross-source projection.
:rtype: animedex.models.art.ArtPost
"""
filename = self.url.rsplit("/", 1)[-1] if self.url else "unknown"
tags: List[str] = []
if self.anime_name:
tags.append(self.anime_name)
width = self.dimensions.width if self.dimensions else None
height = self.dimensions.height if self.dimensions else None
return ArtPost(
id=f"nekos:{filename}",
url=self.url,
rating="g",
tags=tags,
artist=self.artist_name,
source_url=self.source_url,
width=width,
height=height,
source=self.source_tag or _default_src(),
)
def _default_src() -> SourceTag:
"""Construct a fallback :class:`SourceTag` when one isn't already
attached. Used by :meth:`NekosImage.to_common` for direct-from-
JSON construction paths that bypass the high-level fetch helper.
"""
from datetime import datetime, timezone
return SourceTag(backend="nekos", fetched_at=datetime.now(timezone.utc))
[docs]
def selftest() -> bool:
"""Smoke-test the nekos models.
Validates a synthetic :class:`NekosImage` round-trips through
``model_dump_json`` / ``model_validate_json`` and projects to a
well-formed :class:`~animedex.models.art.ArtPost` — pinning the
SFW-only ``rating='g'`` invariant. Also validates a synthetic
:class:`NekosCategoryFormat`.
: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))
img = NekosImage(
url="https://nekos.best/api/v2/husbando/0001.png",
anime_name="Sample",
artist_name="An Artist",
source_url="https://x.invalid/gallery",
source_tag=src,
)
NekosImage.model_validate_json(img.model_dump_json())
common = img.to_common()
assert common.rating == "g"
assert common.id == "nekos:0001.png"
assert common.tags == ["Sample"]
fmt = NekosCategoryFormat.model_validate({"format": "png", "min": "0001", "max": "9999"})
assert fmt.format == "png"
return True