"""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_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ImageRecord, 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 @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}" 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, ) -> GalleryPage: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") 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 ) 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] 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), ) 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) -> 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") ) 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 ) 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 ) -> 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, ) 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 ) 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) return { "id": record.id, "path": record.path, "sha256": record.sha256, "mime": record.mime, "width": record.width, "height": record.height, "size_bytes": record.size_bytes, "created_at": record.created_at.isoformat(), "thumbnail_url": thumbnail_url(record.sha256, record.mime), "image_url": f"/images/{record.path.split('/images/', 1)[-1]}", "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