23fab983a0
Cluster A, milestone #97. #5: clicking an image-modal tag chip's body now closes the modal and opens the gallery filtered for that one tag (fresh filter); ✕/kebab stay as the explicit remove/rename controls. #6a (backend of OR/exclude filtering): gallery_service._apply_scope gains a structured tag model — tag_or_groups (AND-of-OR: one EXISTS(tag_id IN group) per group) + tag_exclude (NOT EXISTS(tag_id IN exclude)) — layered additively on the existing tag_ids AND path so cursors/facets/deep-links are untouched. Threaded through scroll/timeline/jump_cursor/facets/similar + facets common dict; _require_single_filter rejects post_id combined with OR/exclude. API parses tag_or (repeatable → one OR-group each) + tag_not (csv exclude). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
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")
|
|
|
|
|
|
def _image_json(i):
|
|
"""Serialize a GalleryImage for the scroll/similar list responses."""
|
|
return {
|
|
"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,
|
|
"thumbnail_url": i.thumbnail_url,
|
|
"artist": i.artist,
|
|
}
|
|
|
|
|
|
def _parse_date(raw):
|
|
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
|
|
Raises ValueError (→ 400) on a malformed value."""
|
|
if not raw:
|
|
return None
|
|
return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
|
|
|
|
def _parse_filters():
|
|
"""Parse the composable gallery filters from query args, returning
|
|
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
|
|
|
|
The structured tag filter (#6) is AND-of-OR plus exclusions:
|
|
- `tag_id` accepts a single id or a comma-separated list — all ANDed
|
|
(the include common case; back-compat).
|
|
- `tag_or` is REPEATABLE; each instance is a comma-separated OR-group, and
|
|
the image must match at least one tag from EACH group (groups ANDed).
|
|
- `tag_not` is a comma-separated exclude list (image must carry none).
|
|
|
|
`media` is image|video; `sort` is newest|oldest; `platform` selects one
|
|
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
|
|
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
|
|
(date_to is widened by a day so the whole day is covered by the service's
|
|
half-open `< date_to`)."""
|
|
tag_raw = request.args.get("tag_id")
|
|
tag_ids = (
|
|
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
|
|
) or None
|
|
tag_or_groups = [
|
|
grp for raw in request.args.getlist("tag_or")
|
|
if (grp := [int(x) for x in raw.split(",") if x.strip()])
|
|
] or None
|
|
not_raw = request.args.get("tag_not")
|
|
tag_exclude = (
|
|
[int(x) for x in not_raw.split(",") if x.strip()] if not_raw else None
|
|
) or 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
|
|
media = request.args.get("media")
|
|
media_type = media if media in ("image", "video") else None
|
|
sort = request.args.get("sort")
|
|
sort = sort if sort in ("newest", "oldest") else "newest"
|
|
platform = request.args.get("platform") or None
|
|
untagged = request.args.get("untagged") in ("1", "true", "yes")
|
|
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
|
|
date_from = _parse_date(request.args.get("date_from"))
|
|
date_to = _parse_date(request.args.get("date_to"))
|
|
if date_to is not None:
|
|
date_to += timedelta(days=1) # inclusive of the date_to calendar day
|
|
filters = {
|
|
"tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
|
|
"media_type": media_type,
|
|
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
|
|
"platform": platform,
|
|
"untagged": untagged, "no_artist": no_artist,
|
|
"date_from": date_from, "date_to": date_to,
|
|
}
|
|
return filters, sort
|
|
|
|
|
|
@gallery_bp.route("/scroll", methods=["GET"])
|
|
async def scroll():
|
|
cursor = request.args.get("cursor") or None
|
|
try:
|
|
limit = int(request.args.get("limit", "50"))
|
|
filters, sort = _parse_filters()
|
|
except ValueError:
|
|
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
|
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
page = await svc.scroll(
|
|
cursor=cursor, limit=limit, sort=sort, **filters,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
return jsonify(
|
|
{
|
|
"images": [_image_json(i) 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("/similar", methods=["GET"])
|
|
async def similar():
|
|
"""Visual "more like this": images ranked by cosine distance to the
|
|
`similar_to` image's embedding. Composes with the scope filters (AND) but
|
|
ignores post_id and sort. Bounded top-N, no cursor."""
|
|
try:
|
|
similar_to = int(request.args["similar_to"])
|
|
limit = int(request.args.get("limit", "100"))
|
|
filters, _sort = _parse_filters()
|
|
except (KeyError, ValueError):
|
|
return jsonify({"error": "similar_to query param required"}), 400
|
|
# post_id is the exclusive post-detail view — not a similarity scope.
|
|
scope = {k: v for k, v in filters.items() if k != "post_id"}
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
if images is None:
|
|
return jsonify({"error": "not found"}), 404
|
|
return jsonify(
|
|
{
|
|
"images": [_image_json(i) for i in images],
|
|
"next_cursor": None,
|
|
"date_groups": [],
|
|
}
|
|
)
|
|
|
|
|
|
@gallery_bp.route("/timeline", methods=["GET"])
|
|
async def timeline():
|
|
try:
|
|
filters, _sort = _parse_filters()
|
|
except ValueError:
|
|
return jsonify({"error": "invalid filter parameter"}), 400
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
buckets = await svc.timeline(**filters)
|
|
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("/facets", methods=["GET"])
|
|
async def facets():
|
|
try:
|
|
filters, _sort = _parse_filters()
|
|
except ValueError:
|
|
return jsonify({"error": "invalid filter parameter"}), 400
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
f = await svc.facets(**filters)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify(
|
|
{
|
|
"total": f.total,
|
|
"platforms": f.platforms,
|
|
"untagged": f.untagged,
|
|
"no_artist": f.no_artist,
|
|
"date_min": f.date_min.isoformat() if f.date_min else None,
|
|
"date_max": f.date_max.isoformat() if f.date_max else None,
|
|
}
|
|
)
|
|
|
|
|
|
@gallery_bp.route("/jump", methods=["GET"])
|
|
async def jump():
|
|
try:
|
|
year = int(request.args["year"])
|
|
month = int(request.args["month"])
|
|
filters, sort = _parse_filters()
|
|
except (KeyError, ValueError):
|
|
return jsonify({"error": "year and month query params required"}), 400
|
|
async with get_session() as session:
|
|
svc = GalleryService(session)
|
|
try:
|
|
cursor = await svc.jump_cursor(
|
|
year=year, month=month, sort=sort, **filters,
|
|
)
|
|
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)
|