Source code for xanax.sources.reddit.models

"""
Pydantic models for Reddit API responses.

Reddit returns post data in a nested ``t3_`` listing wrapper. The models
here present a flat, typed interface over that structure.

:class:`RedditPost` is the primary media object. Factory method
:meth:`~RedditPost.from_reddit_data` parses raw post dicts from the API.
Gallery posts (multiple images in one submission) are first detected here
and then expanded by the client into individual :class:`RedditPost` objects
via :meth:`~xanax.sources.reddit.client.Reddit._expand_gallery`.
"""

from datetime import UTC, datetime
from typing import Any

from pydantic import BaseModel, Field

from xanax._internal.media_type import MediaType


[docs] class RedditPost(BaseModel): """ A single media post from Reddit. Represents one image, video, or GIF found in a subreddit listing. Gallery posts are not returned directly — the client expands them into one :class:`RedditPost` per image, populating :attr:`gallery_index` and :attr:`gallery_id`. Example: .. code-block:: python post = reddit.post("abc123") if post: data = reddit.download(post) """ id: str = Field(description="Base-36 post ID (or '<post_id>_<media_id>' for gallery items)") fullname: str = Field(description="Reddit fullname, e.g. 't3_abc123'") title: str = Field(description="Post title") subreddit: str = Field(description="Subreddit name (without r/)") author: str = Field(description="Username of the post author") score: int = Field(description="Net upvote count") url: str = Field(description="Direct URL to the media file") media_type: MediaType = Field(description="Type of media: IMAGE, VIDEO, or GIF") width: int | None = Field(default=None, description="Media width in pixels") height: int | None = Field(default=None, description="Media height in pixels") duration: int | None = Field( default=None, description="Duration in seconds (VIDEO and GIF only)" ) video_url: str | None = Field( default=None, description="Video-only stream URL for v.redd.it posts (no audio track)", ) is_nsfw: bool = Field(description="Whether the post is marked NSFW") permalink: str = Field(description="Relative permalink, e.g. '/r/EarthPorn/comments/...'") created_utc: datetime = Field(description="Post creation timestamp (UTC)") is_gallery: bool = Field(description="Whether this post is a Reddit gallery") gallery_index: int | None = Field( default=None, description="0-based index within the parent gallery (set when expanded from gallery)", ) gallery_id: str | None = Field( default=None, description="Parent post ID when this item was expanded from a gallery", ) thumbnail_url: str | None = Field( default=None, description="Thumbnail URL provided by Reddit (may be 'self', 'default', etc.)", )
[docs] @classmethod def from_reddit_data(cls, data: dict[str, Any]) -> "RedditPost | None": """ Build a :class:`RedditPost` from a raw Reddit API post dict. Detects the media type and extracts the appropriate URL and dimension fields. Returns ``None`` for post types that carry no downloadable media (text posts, external links without supported image domains, polls, etc.). Media type detection order: 1. ``is_self=True`` — text post, skip (return ``None``). 2. ``is_video=True`` and ``domain='v.redd.it'`` — VIDEO (or GIF when ``is_gif=True`` on the reddit_video object). 3. ``is_gallery=True`` — IMAGE; ``url`` is left empty because the client calls :meth:`~xanax.sources.reddit.client.Reddit._expand_gallery` to produce per-image posts. 4. ``post_hint='image'`` or ``domain`` in ``('i.redd.it', 'i.imgur.com')`` — IMAGE. 5. Anything else — skip (return ``None``). Args: data: Raw post data dict from ``data.children[n].data``. Returns: Parsed :class:`RedditPost`, or ``None`` if the post has no supported media. """ # Text / self posts have no media if data.get("is_self", False): return None is_video = data.get("is_video", False) is_gallery = data.get("is_gallery", False) post_hint = data.get("post_hint", "") domain = data.get("domain", "") url = "" video_url: str | None = None width: int | None = None height: int | None = None duration: int | None = None media_type: MediaType if is_video and domain == "v.redd.it": # Reddit-hosted video — video-only stream lives in secure_media or media reddit_video = ( (data.get("secure_media") or {}).get("reddit_video") or (data.get("media") or {}).get("reddit_video") or {} ) is_gif = reddit_video.get("is_gif", False) media_type = MediaType.GIF if is_gif else MediaType.VIDEO fallback_url = reddit_video.get("fallback_url", "") url = fallback_url video_url = fallback_url or None width = reddit_video.get("width") height = reddit_video.get("height") duration = reddit_video.get("duration") elif is_gallery: # Gallery: no single direct URL at the post level; caller expands media_type = MediaType.IMAGE url = "" elif post_hint == "image" or domain in ("i.redd.it", "i.imgur.com"): media_type = MediaType.IMAGE url = data.get("url_overridden_by_dest") or data.get("url", "") # Attempt to extract dimensions from preview preview_images = (data.get("preview") or {}).get("images") or [] if preview_images: source = preview_images[0].get("source", {}) width = source.get("width") height = source.get("height") else: # Unsupported post type (external link, etc.) return None created_utc = datetime.fromtimestamp(data.get("created_utc", 0), tz=UTC) thumbnail = data.get("thumbnail") thumbnail_url = thumbnail if thumbnail and thumbnail.startswith("http") else None return cls( id=data["id"], fullname=data.get("name", f"t3_{data['id']}"), title=data.get("title", ""), subreddit=data.get("subreddit", ""), author=data.get("author", "[deleted]"), score=data.get("score", 0), url=url, media_type=media_type, width=width, height=height, duration=duration, video_url=video_url, is_nsfw=data.get("over_18", False), permalink=data.get("permalink", ""), created_utc=created_utc, is_gallery=is_gallery, gallery_index=None, gallery_id=None, thumbnail_url=thumbnail_url, )
[docs] class RedditGalleryItem(BaseModel): """ A single image extracted from a Reddit gallery post. Reddit galleries store image metadata in ``media_metadata`` keyed by ``media_id``. :class:`RedditGalleryItem` holds the parsed fields for one such entry. """ media_id: str = Field(description="Media ID as used in media_metadata") url: str = Field(description="Direct URL to the full-size image") width: int | None = Field(default=None, description="Image width in pixels") height: int | None = Field(default=None, description="Image height in pixels") mime_type: str | None = Field(default=None, description="MIME type, e.g. 'image/jpg'") caption: str | None = Field(default=None, description="Optional caption for this gallery item")
[docs] class RedditListing(BaseModel): """ A paginated page of posts from a subreddit listing endpoint. Example: .. code-block:: python listing = reddit.listing(RedditParams(subreddit="EarthPorn")) for post in listing.posts: print(post.id, post.url) if listing.after: next_listing = reddit.listing(params.with_after(listing.after)) """ posts: list[RedditPost] = Field(description="Media posts on this page (non-media filtered out)") after: str | None = Field( default=None, description="Cursor for the next page (fullname like 't3_abc123')", ) before: str | None = Field( default=None, description="Cursor for the previous page", ) dist: int = Field(description="Number of items returned by the API before client filtering")