Source code for animedex.backends.danbooru.models

"""Rich Danbooru dataclasses (one per resource type).

Danbooru's REST surface is conventional: every record returns as a
flat JSON object whose keys map directly onto the rich type. The
high-level helpers project image-post records onto the cross-source
:class:`~animedex.models.art.ArtPost` shape; artists / tags / pools
have no cross-source common type today and surface as their rich
shape only.

Per the project's lossless rich-model contract every class inherits
from :class:`BackendRichModel` (``extra='allow'``,
``populate_by_name=True``, ``frozen=True``). Only the fields the
high-level API touches are spelled out as typed attributes; upstream
may add more (Danbooru is actively maintained), and they round-trip
through ``model_dump`` losslessly via ``extra='allow'``.
"""

from __future__ import annotations

from typing import Any, List, Optional

from animedex.models.art import ArtPost
from animedex.models.common import BackendRichModel, SourceTag


# ---------- top-level resource shapes ----------


[docs] class DanbooruPost(BackendRichModel): """A single post (image + metadata) on Danbooru. :ivar id: Numeric post ID. :vartype id: int :ivar rating: Content rating, one of ``g`` / ``s`` / ``q`` / ``e`` (general / sensitive / questionable / explicit). :vartype rating: str or None :ivar score: Net upvote score. :vartype score: int or None :ivar md5: MD5 hash of the image file. :vartype md5: str or None :ivar file_url: Full-resolution image URL. :vartype file_url: str or None :ivar large_file_url: Reduced-resolution preview URL (1280-wide). :vartype large_file_url: str or None :ivar preview_file_url: Thumbnail URL. :vartype preview_file_url: str or None :ivar tag_string: Space-separated tag list (the canonical form). :vartype tag_string: str or None :ivar tag_string_artist: Space-separated artist tags. :vartype tag_string_artist: str or None :ivar source: External provenance URL. :vartype source: str or None :ivar image_width: Image width in pixels. :vartype image_width: int or None :ivar image_height: Image height in pixels. :vartype image_height: int or None :ivar fav_count: Favourite count. :vartype fav_count: int or None :ivar source_tag: Provenance tag stamped by the high-level fetch helper. :vartype source_tag: SourceTag or None """ id: int rating: Optional[str] = None score: Optional[int] = None md5: Optional[str] = None file_url: Optional[str] = None large_file_url: Optional[str] = None preview_file_url: Optional[str] = None tag_string: Optional[str] = None tag_string_artist: Optional[str] = None source: Optional[str] = None image_width: Optional[int] = None image_height: Optional[int] = None fav_count: Optional[int] = None source_tag: Optional[SourceTag] = None
[docs] def to_common(self) -> ArtPost: """Project this post onto the cross-source :class:`~animedex.models.art.ArtPost` shape. ``rating`` round-trips directly (Danbooru's vocabulary is the project's normalised one). ``tags`` come from ``tag_string`` (whitespace-split). ``artist`` is the first non-empty entry in ``tag_string_artist``. The lossless rich shape carries the rest. """ rating = self.rating if self.rating in ("g", "s", "q", "e") else None tags = (self.tag_string or "").split() artist = None if self.tag_string_artist: artist_tags = self.tag_string_artist.split() artist = artist_tags[0] if artist_tags else None return ArtPost( id=f"danbooru:{self.id}", url=self.file_url or self.large_file_url or "", preview_url=self.preview_file_url, rating=rating, tags=tags, score=self.score, artist=artist, source_url=self.source, width=self.image_width, height=self.image_height, source=self.source_tag or _default_src(), )
[docs] class DanbooruArtist(BackendRichModel): """A single artist record.""" id: int name: Optional[str] = None group_name: Optional[str] = None other_names: Optional[List[str]] = None is_deleted: Optional[bool] = None is_banned: Optional[bool] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruTag(BackendRichModel): """A single tag record (with usage statistics).""" id: int name: Optional[str] = None post_count: Optional[int] = None category: Optional[int] = None is_deprecated: Optional[bool] = None words: Optional[List[str]] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruPool(BackendRichModel): """A single pool record (an ordered collection of posts).""" id: int name: Optional[str] = None description: Optional[str] = None post_ids: Optional[List[int]] = None post_count: Optional[int] = None category: Optional[str] = None is_active: Optional[bool] = None is_deleted: Optional[bool] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruProfile(BackendRichModel): """The authenticated user's own profile envelope from ``/profile.json``. Carries account-scoped fields the anonymous ``/users/{id}.json`` view omits (``last_logged_in_at``, ``blacklisted_tags``, ``favorite_tags``, ``comment_threshold``, etc.).""" id: int name: Optional[str] = None level: Optional[int] = None inviter_id: Optional[int] = None created_at: Optional[str] = None updated_at: Optional[str] = None last_logged_in_at: Optional[str] = None last_forum_read_at: Optional[str] = None post_upload_count: Optional[int] = None post_update_count: Optional[int] = None note_update_count: Optional[int] = None is_deleted: Optional[bool] = None favorite_tags: Optional[str] = None blacklisted_tags: Optional[str] = None comment_threshold: Optional[int] = None default_image_size: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruSavedSearch(BackendRichModel): """One ``/saved_searches.json`` row: a user-saved tag-DSL query plus the labels the user has organised it under.""" id: int user_id: Optional[int] = None query: Optional[str] = None labels: Optional[List[Any]] = None created_at: Optional[str] = None updated_at: Optional[str] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruRecord(BackendRichModel): """Catch-all Danbooru record shape used by the long-tail of anonymous-readable endpoints (versions / votes / events / operational metrics / etc.) where typing every field per endpoint would multiply the model count without much benefit to a downstream caller. ``id`` is the only universal field and is typed as :class:`~typing.Any` because the upstream's primary keys vary by resource family (numeric for the typical rows; UUID string for ``/jobs``, ``/metrics``, and similar operational queues). Everything else round-trips via ``extra='allow'``. """ id: Optional[Any] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruRelatedTag(BackendRichModel): """Response shape for ``/related_tag.json``: a query echo + a ranked list of related tags. The shape is upstream-specific and not a uniform list-of-records.""" query: Optional[str] = None category: Optional[str] = None related_tags: Optional[List[Any]] = None tag: Optional[Any] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruIQDBQuery(BackendRichModel): """Response shape for ``/iqdb_queries.json``: a list of similarity-scored matches against a candidate image URL or upload.""" post: Optional[Any] = None post_id: Optional[int] = None score: Optional[float] = None source_tag: Optional[SourceTag] = None
[docs] class DanbooruCount(BackendRichModel): """The ``/counts/posts.json?tags=...`` envelope: ``{counts: {posts: N}}``. Wraps the upstream's already-wrapped count so callers get a typed ``counts.posts`` access. """ counts: Optional[dict] = None source_tag: Optional[SourceTag] = None
[docs] def total(self) -> Optional[int]: """Return the post count, when present. :return: Number of posts matching the tag query, or ``None`` if the upstream omitted the count. :rtype: int or None """ if not self.counts: return None v = self.counts.get("posts") try: return int(v) if v is not None else None except (TypeError, ValueError): # pragma: no cover - defensive return 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="danbooru", fetched_at=datetime.now(timezone.utc))
[docs] def selftest() -> bool: """Smoke-test the Danbooru rich models. Validates a synthetic :class:`DanbooruPost` round-trips through ``model_dump_json`` / ``model_validate_json`` and projects to a well-formed :class:`~animedex.models.art.ArtPost`. :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)) post = DanbooruPost( id=1, rating="g", score=42, file_url="https://danbooru.donmai.us/data/abc.jpg", preview_file_url="https://danbooru.donmai.us/data/preview/abc.jpg", tag_string="touhou marisa rating:g", tag_string_artist="zun", source="https://example.invalid/", image_width=1024, image_height=768, source_tag=src, ) DanbooruPost.model_validate_json(post.model_dump_json()) common = post.to_common() assert common.id == "danbooru:1" assert common.rating == "g" assert "touhou" in common.tags assert common.artist == "zun" count = DanbooruCount.model_validate({"counts": {"posts": "12345"}}) assert count.total() == 12345 return True