feat(fc2a): add GalleryService — cursor scroll, timeline, image detail with neighbors
Cursor format: base64(iso8601_created_at|image_id). Pagination key is (created_at DESC, id DESC) so we don't drift when new imports land between page loads. Timeline groups by date_part(year, month) so the sidebar can render year-month jump buckets. get_image_with_tags returns full image detail plus prev/next ids so the modal viewer can navigate without an extra round-trip per arrow press. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""Cursor-paginated gallery queries.
|
||||
|
||||
Cursor format: opaque base64-encoded "<iso8601_created_at>:<image_id>".
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user