"""FC-3e: cursor-paginated read service for the Posts stream. Mirrors GalleryService.scroll's cursor encoding so the frontend pattern is identical: base64 of "|". Sort key is COALESCE(Post.post_date, Post.downloaded_at) so posts without a publish date sort by when we captured them. Pure read-surface; no writes. The service composes the post dict (including thumbnails from ImageRecord.primary_post_id and non-media attachments from PostAttachment) so the API layer can jsonify directly. """ from __future__ import annotations import base64 from datetime import datetime from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, ImageRecord, Post, PostAttachment, Source from ..utils.text import html_to_plain, truncate_at_word from .gallery_service import thumbnail_url CURSOR_SEPARATOR = "|" DESCRIPTION_LIMIT = 280 THUMBNAIL_LIMIT = 6 def encode_cursor(sort_key: datetime, post_id: int) -> str: raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{post_id}" return base64.urlsafe_b64encode(raw.encode()).decode() def decode_cursor(cursor: str) -> tuple[datetime, int]: try: raw = base64.urlsafe_b64decode(cursor.encode()).decode() ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1) return datetime.fromisoformat(ts_part), int(id_part) except Exception as exc: raise ValueError(f"invalid cursor: {cursor!r}") from exc def _sort_key(): """Postgres COALESCE expression used in ORDER BY and WHERE clauses.""" return func.coalesce(Post.post_date, Post.downloaded_at) class PostFeedService: def __init__(self, session: AsyncSession): self.session = session async def scroll( self, *, cursor: str | None = None, artist_id: int | None = None, platform: str | None = None, limit: int = 24, ) -> dict: if limit < 1 or limit > 100: raise ValueError("limit must be between 1 and 100") sort_key = _sort_key() stmt = ( select(Post, Artist, Source) .join(Source, Post.source_id == Source.id) .join(Artist, Source.artist_id == Artist.id) ) if artist_id is not None: stmt = stmt.where(Source.artist_id == artist_id) if platform is not None: stmt = stmt.where(Source.platform == platform) if cursor: cur_ts, cur_id = decode_cursor(cursor) stmt = stmt.where( or_( sort_key < cur_ts, and_(sort_key == cur_ts, Post.id < cur_id), ) ) stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1) rows = (await self.session.execute(stmt)).all() next_cursor: str | None = None if len(rows) > limit: last_post, _, _ = rows[limit - 1] last_key = last_post.post_date or last_post.downloaded_at next_cursor = encode_cursor(last_key, last_post.id) rows = rows[:limit] post_ids = [p.id for p, _, _ in rows] thumbs_map = await self._thumbnails_for(post_ids) atts_map = await self._attachments_for(post_ids) items = [ self._to_dict(post, artist, source, thumbs_map, atts_map) for post, artist, source in rows ] return {"items": items, "next_cursor": next_cursor} async def get_post(self, post_id: int) -> dict | None: row = (await self.session.execute( select(Post, Artist, Source) .join(Source, Post.source_id == Source.id) .join(Artist, Source.artist_id == Artist.id) .where(Post.id == post_id) )).one_or_none() if row is None: return None post, artist, source = row thumbs_map = await self._thumbnails_for([post.id]) atts_map = await self._attachments_for([post.id]) item = self._to_dict(post, artist, source, thumbs_map, atts_map) item["description_full"] = html_to_plain(post.description) return item # --- composition helpers --------------------------------------------- async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]: """post_id -> {"thumbs": [...up to 6], "more": int}. Selects THUMBNAIL_LIMIT+1 images per post via window function so we can detect overflow in a single query. """ if not post_ids: return {} # Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1. ranked = ( select( ImageRecord.id, ImageRecord.primary_post_id, ImageRecord.sha256, ImageRecord.mime, func.row_number().over( partition_by=ImageRecord.primary_post_id, order_by=ImageRecord.id.asc(), ).label("rn"), func.count(ImageRecord.id).over( partition_by=ImageRecord.primary_post_id, ).label("total"), ) .where(ImageRecord.primary_post_id.in_(post_ids)) .subquery() ) rows = (await self.session.execute( select( ranked.c.id, ranked.c.primary_post_id, ranked.c.sha256, ranked.c.mime, ranked.c.total, ).where(ranked.c.rn <= THUMBNAIL_LIMIT) )).all() out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids} for img_id, pid, sha, mime, total in rows: entry = out.setdefault(pid, {"thumbs": [], "more": 0}) entry["thumbs"].append({ "image_id": img_id, "thumbnail_url": thumbnail_url(sha, mime), "mime": mime, }) # `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT. entry["more"] = max(0, total - THUMBNAIL_LIMIT) return out async def _attachments_for(self, post_ids: list[int]) -> dict[int, list[dict]]: if not post_ids: return {} rows = (await self.session.execute( select(PostAttachment) .where(PostAttachment.post_id.in_(post_ids)) .order_by(PostAttachment.id.asc()) )).scalars().all() out: dict[int, list[dict]] = {pid: [] for pid in post_ids} for att in rows: out.setdefault(att.post_id, []).append({ "id": att.id, "original_filename": att.original_filename, "ext": att.ext, "mime": att.mime, "size_bytes": att.size_bytes, "download_url": f"/api/attachments/{att.id}/download", }) return out def _to_dict( self, post: Post, artist: Artist, source: Source, thumbs_map: dict, atts_map: dict, ) -> dict: plain_full = html_to_plain(post.description) if post.description else None if plain_full is None: description_plain, truncated = None, False else: description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT) thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0}) return { "id": post.id, "external_post_id": post.external_post_id, "post_url": post.post_url, "post_title": post.post_title, "post_date": post.post_date.isoformat() if post.post_date else None, "downloaded_at": post.downloaded_at.isoformat(), "description_plain": description_plain, "description_truncated": truncated, "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug}, "source": {"id": source.id, "platform": source.platform}, "thumbnails": thumbs_entry["thumbs"], "thumbnails_more": thumbs_entry["more"], "attachments": atts_map.get(post.id, []), }