feat(gallery): faceted filter params + /facets counts endpoint (Phase 2 backend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m50s

Extend the composable gallery filter with platform / untagged / no_artist /
date_from / date_to, AND-composed with the existing tag/artist/media/sort
params and threaded through scroll, timeline, and jump_cursor.

Add GalleryService.facets() + GET /api/gallery/facets returning live counts
scoped to the current filter with per-group minus-self semantics: platform
counts (COUNT(DISTINCT image) incl. a null unsourced bucket), curation-flag
counts (untagged / no_artist), and effective_date min/max bounds. The
UNSOURCED_PLATFORM sentinel makes filesystem-imported content reachable via
the platform facet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 07:01:07 -04:00
parent 16e0268da7
commit 9fe534139a
4 changed files with 526 additions and 26 deletions
+64 -19
View File
@@ -1,4 +1,6 @@
"""Gallery API: cursor scroll, timeline, jump, image detail."""
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request
@@ -8,11 +10,24 @@ from ..services.gallery_service import GalleryService
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
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. Raises
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a
comma-separated list (AND); `media` is image|video; `sort` is
newest|oldest."""
"""Parse the composable gallery filters from query args, returning
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
`tag_id` accepts a single id or a comma-separated list (AND); `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
@@ -25,7 +40,20 @@ def _parse_filters():
media_type = media if media in ("image", "video") else None
sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest") else "newest"
return tag_ids, post_id, artist_id, media_type, sort
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, "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"])
@@ -33,7 +61,7 @@ async def scroll():
cursor = request.args.get("cursor") or None
try:
limit = int(request.args.get("limit", "50"))
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
filters, sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter or limit parameter"}), 400
@@ -41,9 +69,7 @@ async def scroll():
svc = GalleryService(session)
try:
page = await svc.scroll(
cursor=cursor, limit=limit, tag_ids=tag_ids,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
cursor=cursor, limit=limit, sort=sort, **filters,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@@ -75,16 +101,13 @@ async def scroll():
@gallery_bp.route("/timeline", methods=["GET"])
async def timeline():
try:
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters()
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(
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
media_type=media_type,
)
buckets = await svc.timeline(**filters)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
@@ -92,21 +115,43 @@ async def timeline():
)
@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"])
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
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, tag_ids=tag_ids,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
year=year, month=month, sort=sort, **filters,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400