c361032554
Operator hit this 2026-05-25 after the IR tag_apply landed: ~57k images
all scanned into FC in the same week share image_record.created_at, so
the gallery timeline collapses them into a single month bucket and
scroll orders them all together at the top. Their actual publish dates
(spread over years) were already available in Post.post_date but the
gallery never read it.
Backend wire-up:
- tag_apply phase 4 now sets ImageRecord.primary_post_id when creating
ImageProvenance (only if currently NULL — preserves the canonical
download-time linkage set by the importer for new FC ingests).
- gallery_service.py introduces _effective_date_col() =
COALESCE(post.post_date, image_record.created_at), used in:
* scroll() ORDER BY + cursor WHERE clauses
* timeline() year/month group-by
* jump_cursor() year/month filter
* _neighbors() prev/next ordering
- Each method LEFT OUTER JOIN Post on primary_post_id so the COALESCE
works for images without a post (NULL on the Post side, fall back
to created_at).
- GalleryImage gains posted_at + effective_date fields; API /gallery
/scroll exposes both alongside the existing created_at so the UI
can render 'Posted on X (imported Y)' if desired.
- get_image_with_tags() returns posted_at for the modal.
Cursor format unchanged — the encoded datetime is now the effective_
date (whichever column won the COALESCE) and pagination remains
consistent.
To pick up new behavior for an already-migrated IR set: re-run
/api/migrate/tag_apply on the existing manifest (phase 4 is
idempotent; the new primary_post_id assignment backfills).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""Gallery API: cursor scroll, timeline, jump, image detail."""
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from ..extensions import get_session
|
|
from ..services.gallery_service import GalleryService
|
|
|
|
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
|
|
|
|
|
@gallery_bp.route("/scroll", methods=["GET"])
|
|
async def scroll():
|
|
cursor = request.args.get("cursor") or None
|
|
try:
|
|
limit = int(request.args.get("limit", "50"))
|
|
except ValueError:
|
|
return jsonify({"error": "limit must be an integer"}), 400
|
|
tag_id_raw = request.args.get("tag_id")
|
|
tag_id = int(tag_id_raw) if tag_id_raw else None
|
|
post_id_raw = request.args.get("post_id")
|
|
post_id = int(post_id_raw) if post_id_raw else None
|
|
artist_id_raw = request.args.get("artist_id")
|
|
artist_id = int(artist_id_raw) if artist_id_raw else None
|
|
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
page = await svc.scroll(
|
|
cursor=cursor, limit=limit, tag_id=tag_id,
|
|
post_id=post_id, artist_id=artist_id,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
return jsonify(
|
|
{
|
|
"images": [
|
|
{
|
|
"id": i.id,
|
|
"sha256": i.sha256,
|
|
"mime": i.mime,
|
|
"width": i.width,
|
|
"height": i.height,
|
|
"created_at": i.created_at.isoformat(),
|
|
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
|
"effective_date": i.effective_date.isoformat(),
|
|
"thumbnail_url": i.thumbnail_url,
|
|
"artist": i.artist,
|
|
}
|
|
for i in page.images
|
|
],
|
|
"next_cursor": page.next_cursor,
|
|
"date_groups": [
|
|
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
@gallery_bp.route("/timeline", methods=["GET"])
|
|
async def timeline():
|
|
tag_id_raw = request.args.get("tag_id")
|
|
tag_id = int(tag_id_raw) if tag_id_raw else None
|
|
post_id_raw = request.args.get("post_id")
|
|
post_id = int(post_id_raw) if post_id_raw else None
|
|
artist_id_raw = request.args.get("artist_id")
|
|
artist_id = int(artist_id_raw) if artist_id_raw else None
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
buckets = await svc.timeline(
|
|
tag_id=tag_id, post_id=post_id, artist_id=artist_id
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify(
|
|
[{"year": b.year, "month": b.month, "count": b.count} for b in buckets]
|
|
)
|
|
|
|
|
|
@gallery_bp.route("/jump", methods=["GET"])
|
|
async def jump():
|
|
try:
|
|
year = int(request.args["year"])
|
|
month = int(request.args["month"])
|
|
except (KeyError, ValueError):
|
|
return jsonify({"error": "year and month query params required"}), 400
|
|
tag_id_raw = request.args.get("tag_id")
|
|
tag_id = int(tag_id_raw) if tag_id_raw else None
|
|
post_id_raw = request.args.get("post_id")
|
|
post_id = int(post_id_raw) if post_id_raw else None
|
|
artist_id_raw = request.args.get("artist_id")
|
|
artist_id = int(artist_id_raw) if artist_id_raw else None
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
cursor = await svc.jump_cursor(
|
|
year=year, month=month, tag_id=tag_id,
|
|
post_id=post_id, artist_id=artist_id,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify({"cursor": cursor})
|
|
|
|
|
|
@gallery_bp.route("/image/<int:image_id>", methods=["GET"])
|
|
async def image_detail(image_id: int):
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
payload = await svc.get_image_with_tags(image_id)
|
|
if payload is None:
|
|
return jsonify({"error": "not found"}), 404
|
|
return jsonify(payload)
|