"""Cursor-paginated gallery queries. Cursor format: opaque base64-encoded ":". Pagination key is (effective_date DESC, id DESC) where effective_date is COALESCE(post.post_date, image_record.created_at) so the gallery surfaces images by ORIGINAL publish date when known, falling back to FC's scan date. Important for migrated content: ~57k IR images scanned in a single week would otherwise all share the same created_at and pile up in one month bucket. The effective_date spreads them across the years they were originally published. Decoding rejects malformed cursors with a ValueError; the API layer translates that to HTTP 400. """ import base64 from dataclasses import dataclass from datetime import datetime from sqlalchemy import Select, and_, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models.tag import image_tag CURSOR_SEPARATOR = "|" def encode_cursor(effective_date: datetime, image_id: int) -> str: raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_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 _effective_date_col(): """SQL expression: COALESCE(post.post_date, image_record.created_at). Used as the canonical sort/group/filter key across the gallery so images backfilled with primary_post_id (e.g. via tag_apply phase 4) surface at their original publish date, not their FC import date. Images without a Post (or with Post.post_date NULL) fall back to image_record.created_at and still order coherently against post-attached ones. """ return func.coalesce(Post.post_date, ImageRecord.created_at) def _outer_join_primary_post(stmt: Select) -> Select: """LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE above sees Post.post_date when available. Images without a post survive the join as NULL on the Post side; COALESCE handles it.""" return stmt.outerjoin(Post, Post.id == ImageRecord.primary_post_id) @dataclass(frozen=True) class GalleryImage: id: int path: str sha256: str mime: str width: int | None height: int | None created_at: datetime # FC's row-insert time effective_date: datetime # COALESCE(post.post_date, created_at) posted_at: datetime | None # post.post_date if known, else None thumbnail_url: str artist: dict | None = None @dataclass(frozen=True) class GalleryPage: images: list[GalleryImage] next_cursor: str | None date_groups: list[tuple[int, int, list[int]]] # (year, month, [image_id...]) @dataclass(frozen=True) class TimelineBucket: year: int month: int count: int def thumbnail_url(sha256_hex: str, mime: str) -> str: # Quart serves /images/* via the frontend blueprint (FC-1); thumbnails go # under /images/thumbs/. The MIME determines the extension. ext = ".png" if mime in ("image/png", "image/gif") else ".jpg" bucket = sha256_hex[:3] return f"/images/thumbs/{bucket}/{sha256_hex}{ext}" def _require_single_filter(tag_id, post_id, artist_id) -> None: if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1: raise ValueError( "tag_id, post_id, artist_id are mutually exclusive" ) def _provenance_clause(post_id, artist_id): """Correlated EXISTS clause (NOT a join) so an image with multiple matching provenance rows is returned exactly once and the (effective_date DESC, id DESC) cursor ordering is unaffected.""" if post_id is not None: return exists().where( ImageProvenance.image_record_id == ImageRecord.id, ImageProvenance.post_id == post_id, ) if artist_id is not None: return exists().where( ImageProvenance.image_record_id == ImageRecord.id, ImageProvenance.source_id == Source.id, Source.artist_id == artist_id, ) return None async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]: """Map image_id -> {"name","slug"} via the canonical image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" if not image_ids: return {} stmt = ( select(ImageRecord.id, Artist.name, Artist.slug) .join(Artist, Artist.id == ImageRecord.artist_id) .where(ImageRecord.id.in_(image_ids)) ) return { img_id: {"name": name, "slug": slug} for img_id, name, slug in (await session.execute(stmt)).all() } class GalleryService: def __init__(self, session: AsyncSession): self.session = session async def scroll( self, cursor: str | None, limit: int = 50, tag_id: int | None = None, post_id: int | None = None, artist_id: int | None = None, ) -> GalleryPage: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") _require_single_filter(tag_id, post_id, artist_id) eff = _effective_date_col() stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = _outer_join_primary_post(stmt) if tag_id is not None: stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( image_tag.c.tag_id == tag_id ) prov = _provenance_clause(post_id, artist_id) if prov is not None: stmt = stmt.where(prov) if cursor: cur_ts, cur_id = decode_cursor(cursor) stmt = stmt.where( or_( eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id), ) ) stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1) rows = (await self.session.execute(stmt)).all() next_cursor = None if len(rows) > limit: last_record, _last_posted_at, last_eff = rows[limit - 1] next_cursor = encode_cursor(last_eff, last_record.id) rows = rows[:limit] artists = await _artists_for( self.session, [r[0].id for r in rows] ) images = [ GalleryImage( id=record.id, path=record.path, sha256=record.sha256, mime=record.mime, width=record.width, height=record.height, created_at=record.created_at, effective_date=eff_date, posted_at=posted_at, thumbnail_url=thumbnail_url(record.sha256, record.mime), artist=artists.get(record.id), ) for record, posted_at, eff_date in rows ] return GalleryPage( images=images, next_cursor=next_cursor, date_groups=_group_by_year_month(images), ) async def timeline( self, tag_id: int | None = None, post_id: int | None = None, artist_id: int | None = None, ) -> list[TimelineBucket]: eff = _effective_date_col() year_col = func.date_part("year", eff).label("yr") month_col = func.date_part("month", eff).label("mo") stmt = select( year_col, month_col, func.count(ImageRecord.id).label("cnt") ) stmt = _outer_join_primary_post(stmt) _require_single_filter(tag_id, post_id, artist_id) if tag_id is not None: stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( image_tag.c.tag_id == tag_id ) prov = _provenance_clause(post_id, artist_id) if prov is not None: stmt = stmt.where(prov) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc()) rows = (await self.session.execute(stmt)).all() return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows] async def jump_cursor( self, year: int, month: int, tag_id: int | None = None, post_id: int | None = None, artist_id: int | None = None, ) -> str | None: """Returns a cursor that, when passed to scroll(), positions at the first image of the given year-month (by effective_date, not created_at). None if the bucket is empty. """ from sqlalchemy import extract eff = _effective_date_col() stmt = select(ImageRecord, eff.label("eff")).where( extract("year", eff) == year, extract("month", eff) == month, ) stmt = _outer_join_primary_post(stmt) _require_single_filter(tag_id, post_id, artist_id) if tag_id is not None: stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( image_tag.c.tag_id == tag_id ) prov = _provenance_clause(post_id, artist_id) if prov is not None: stmt = stmt.where(prov) stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1) first = (await self.session.execute(stmt)).first() if first is None: return None record, eff_date = first # Cursor is exclusive; we encode a cursor with id+1 so the row itself # is the first result in the next scroll(). return encode_cursor(eff_date, record.id + 1) async def get_image_with_tags(self, image_id: int) -> dict | None: record = await self.session.get(ImageRecord, image_id) if record is None: return None tag_stmt = ( select(Tag) .join(image_tag, image_tag.c.tag_id == Tag.id) .where(image_tag.c.image_record_id == image_id) .order_by(Tag.kind.asc(), Tag.name.asc()) ) tags = (await self.session.execute(tag_stmt)).scalars().all() # Fetch the canonical post.post_date for this image (if any) so # the modal can show "Posted on " alongside import date. posted_at = None if record.primary_post_id is not None: posted_at = (await self.session.execute( select(Post.post_date).where(Post.id == record.primary_post_id) )).scalar_one_or_none() neighbors = await self._neighbors(record) # Direct artist FK — used by the modal's ProvenancePanel as a # fallback when ImageProvenance is empty (i.e., filesystem- # imported images without a post-track provenance row). The # source of truth for richer post-level data is still # ImageProvenance/Post; this is just the "we at least know who # made it" line. artist = None if record.artist_id is not None: artist = await self.session.get(Artist, record.artist_id) return { "id": record.id, "path": record.path, "sha256": record.sha256, "mime": record.mime, "width": record.width, "height": record.height, "size_bytes": record.size_bytes, "integrity_status": record.integrity_status, "created_at": record.created_at.isoformat(), "posted_at": posted_at.isoformat() if posted_at else None, "thumbnail_url": thumbnail_url(record.sha256, record.mime), "image_url": f"/images/{record.path.split('/images/', 1)[-1]}", "artist": ( {"id": artist.id, "name": artist.name, "slug": artist.slug} if artist is not None else None ), "tags": [ { "id": t.id, "name": t.name, "kind": t.kind.value if hasattr(t.kind, "value") else t.kind, "fandom_id": t.fandom_id, } for t in tags ], "neighbors": neighbors, } async def _neighbors(self, record: ImageRecord) -> dict: # Compute the boundary image's effective_date in Python (one query # below + the SELECT we already have on `record`) and use it for # the neighbor comparison. Cheaper than re-deriving in SQL via # correlated subquery. boundary_eff = record.created_at if record.primary_post_id is not None: post_date = (await self.session.execute( select(Post.post_date).where(Post.id == record.primary_post_id) )).scalar_one_or_none() if post_date is not None: boundary_eff = post_date eff = _effective_date_col() prev_stmt = _outer_join_primary_post( select(ImageRecord.id).where( or_( eff > boundary_eff, and_( eff == boundary_eff, ImageRecord.id > record.id, ), ) ) ).order_by(eff.asc(), ImageRecord.id.asc()).limit(1) next_stmt = _outer_join_primary_post( select(ImageRecord.id).where( or_( eff < boundary_eff, and_( eff == boundary_eff, ImageRecord.id < record.id, ), ) ) ).order_by(eff.desc(), ImageRecord.id.desc()).limit(1) prev_id = (await self.session.execute(prev_stmt)).scalar_one_or_none() next_id = (await self.session.execute(next_stmt)).scalar_one_or_none() return {"prev_id": prev_id, "next_id": next_id} def _group_by_year_month( images: list[GalleryImage], ) -> list[tuple[int, int, list[int]]]: """Group by effective_date's year/month so migrated content surfaces in the publish-date buckets, not the FC-scan-date bucket.""" groups: list[tuple[int, int, list[int]]] = [] for img in images: y, m = img.effective_date.year, img.effective_date.month if groups and groups[-1][0] == y and groups[-1][1] == m: groups[-1][2].append(img.id) else: groups.append((y, m, [img.id])) return groups