"""Cursor-paginated gallery queries. Cursor format: opaque base64-encoded ":". Pagination key is (created_at DESC, id DESC) so we don't drift when new imports arrive between page loads. 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 and_, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, ImageProvenance, ImageRecord, Source, Tag from ..models.tag import image_tag CURSOR_SEPARATOR = "|" def encode_cursor(created_at: datetime, image_id: int) -> str: raw = f"{created_at.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 @dataclass(frozen=True) class GalleryImage: id: int path: str sha256: str mime: str width: int | None height: int | None created_at: datetime 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 (created_at 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) stmt = select(ImageRecord) 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_( ImageRecord.created_at < cur_ts, and_(ImageRecord.created_at == cur_ts, ImageRecord.id < cur_id), ) ) stmt = stmt.order_by(ImageRecord.created_at.desc(), ImageRecord.id.desc()).limit(limit + 1) rows = (await self.session.execute(stmt)).scalars().all() next_cursor = None if len(rows) > limit: last = rows[limit - 1] next_cursor = encode_cursor(last.created_at, last.id) rows = rows[:limit] artists = await _artists_for(self.session, [r.id for r in rows]) images = [ GalleryImage( id=r.id, path=r.path, sha256=r.sha256, mime=r.mime, width=r.width, height=r.height, created_at=r.created_at, thumbnail_url=thumbnail_url(r.sha256, r.mime), artist=artists.get(r.id), ) for r 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]: year_col = func.date_part("year", ImageRecord.created_at).label("yr") month_col = func.date_part("month", ImageRecord.created_at).label("mo") stmt = select( year_col, month_col, func.count(ImageRecord.id).label("cnt") ) _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. None if the bucket is empty. """ from sqlalchemy import extract stmt = select(ImageRecord).where( extract("year", ImageRecord.created_at) == year, extract("month", ImageRecord.created_at) == month, ) _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(ImageRecord.created_at.desc(), ImageRecord.id.desc()).limit(1) first = (await self.session.execute(stmt)).scalar_one_or_none() if first is None: return None # 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(first.created_at, first.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() 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(), "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: prev_stmt = ( select(ImageRecord.id) .where( or_( ImageRecord.created_at > record.created_at, and_( ImageRecord.created_at == record.created_at, ImageRecord.id > record.id, ), ) ) .order_by(ImageRecord.created_at.asc(), ImageRecord.id.asc()) .limit(1) ) next_stmt = ( select(ImageRecord.id) .where( or_( ImageRecord.created_at < record.created_at, and_( ImageRecord.created_at == record.created_at, ImageRecord.id < record.id, ), ) ) .order_by(ImageRecord.created_at.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]]]: groups: list[tuple[int, int, list[int]]] = [] for img in images: y, m = img.created_at.year, img.created_at.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