diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py new file mode 100644 index 0000000..1aef465 --- /dev/null +++ b/backend/app/services/gallery_service.py @@ -0,0 +1,244 @@ +"""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 diff --git a/tests/test_gallery_service.py b/tests/test_gallery_service.py new file mode 100644 index 0000000..3d6d263 --- /dev/null +++ b/tests/test_gallery_service.py @@ -0,0 +1,122 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from backend.app.models import ImageRecord, Tag, TagKind +from backend.app.models.tag import image_tag +from backend.app.services.gallery_service import ( + GalleryService, + decode_cursor, + encode_cursor, +) + + +def _now(): + return datetime.now(timezone.utc) + + +async def _seed_images(db, count: int, sha_prefix: str = "0") -> list[ImageRecord]: + base = _now() + records = [] + for i in range(count): + r = ImageRecord( + path=f"/images/test/{i}.jpg", + sha256=f"{sha_prefix}{i:063d}", + size_bytes=1000, + mime="image/jpeg", + width=100, + height=100, + origin="imported_filesystem", + integrity_status="unknown", + ) + r.created_at = base - timedelta(minutes=i) + db.add(r) + records.append(r) + await db.flush() + return records + + +def test_cursor_roundtrip(): + ts = datetime(2026, 5, 14, 12, 30, 0, tzinfo=timezone.utc) + encoded = encode_cursor(ts, 42) + back_ts, back_id = decode_cursor(encoded) + assert back_ts == ts + assert back_id == 42 + + +def test_decode_invalid_cursor_raises(): + with pytest.raises(ValueError): + decode_cursor("not-base64!!!") + + +@pytest.mark.asyncio +async def test_scroll_returns_newest_first(db): + await _seed_images(db, 5) + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10) + assert len(page.images) == 5 + assert page.images[0].created_at > page.images[-1].created_at + + +@pytest.mark.asyncio +async def test_scroll_pagination(db): + await _seed_images(db, 5) + svc = GalleryService(db) + first = await svc.scroll(cursor=None, limit=2) + assert len(first.images) == 2 + assert first.next_cursor is not None + second = await svc.scroll(cursor=first.next_cursor, limit=2) + assert len(second.images) == 2 + assert {i.id for i in first.images}.isdisjoint({i.id for i in second.images}) + + +@pytest.mark.asyncio +async def test_scroll_with_tag_filter(db): + images = await _seed_images(db, 5) + tag = Tag(name="filterme", kind=TagKind.general) + db.add(tag) + await db.flush() + await db.execute( + image_tag.insert().values( + image_record_id=images[0].id, tag_id=tag.id, source="manual" + ) + ) + + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, tag_id=tag.id) + assert len(page.images) == 1 + assert page.images[0].id == images[0].id + + +@pytest.mark.asyncio +async def test_timeline_groups_by_month(db): + await _seed_images(db, 3) + svc = GalleryService(db) + buckets = await svc.timeline() + assert sum(b.count for b in buckets) == 3 + + +@pytest.mark.asyncio +async def test_date_groups_in_page(db): + await _seed_images(db, 3) + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10) + assert len(page.date_groups) == 1 + y, m, ids = page.date_groups[0] + assert len(ids) == 3 + + +@pytest.mark.asyncio +async def test_neighbors(db): + images = await _seed_images(db, 3) + svc = GalleryService(db) + middle = images[1] + payload = await svc.get_image_with_tags(middle.id) + assert payload["neighbors"]["prev_id"] == images[0].id + assert payload["neighbors"]["next_id"] == images[2].id + + +@pytest.mark.asyncio +async def test_get_image_with_tags_returns_none_for_missing(db): + svc = GalleryService(db) + assert await svc.get_image_with_tags(99999) is None