feat(gallery): faceted filter params + /facets counts endpoint (Phase 2 backend)
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:
+64
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user