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
|
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")
|
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():
|
def _parse_filters():
|
||||||
"""Parse the composable gallery filters from query args. Raises
|
"""Parse the composable gallery filters from query args, returning
|
||||||
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a
|
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
|
||||||
comma-separated list (AND); `media` is image|video; `sort` is
|
|
||||||
newest|oldest."""
|
`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_raw = request.args.get("tag_id")
|
||||||
tag_ids = (
|
tag_ids = (
|
||||||
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
|
[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
|
media_type = media if media in ("image", "video") else None
|
||||||
sort = request.args.get("sort")
|
sort = request.args.get("sort")
|
||||||
sort = sort if sort in ("newest", "oldest") else "newest"
|
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"])
|
@gallery_bp.route("/scroll", methods=["GET"])
|
||||||
@@ -33,7 +61,7 @@ async def scroll():
|
|||||||
cursor = request.args.get("cursor") or None
|
cursor = request.args.get("cursor") or None
|
||||||
try:
|
try:
|
||||||
limit = int(request.args.get("limit", "50"))
|
limit = int(request.args.get("limit", "50"))
|
||||||
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
filters, sort = _parse_filters()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
||||||
|
|
||||||
@@ -41,9 +69,7 @@ async def scroll():
|
|||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
page = await svc.scroll(
|
page = await svc.scroll(
|
||||||
cursor=cursor, limit=limit, tag_ids=tag_ids,
|
cursor=cursor, limit=limit, sort=sort, **filters,
|
||||||
post_id=post_id, artist_id=artist_id,
|
|
||||||
media_type=media_type, sort=sort,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
@@ -75,16 +101,13 @@ async def scroll():
|
|||||||
@gallery_bp.route("/timeline", methods=["GET"])
|
@gallery_bp.route("/timeline", methods=["GET"])
|
||||||
async def timeline():
|
async def timeline():
|
||||||
try:
|
try:
|
||||||
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters()
|
filters, _sort = _parse_filters()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "invalid filter parameter"}), 400
|
return jsonify({"error": "invalid filter parameter"}), 400
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
buckets = await svc.timeline(
|
buckets = await svc.timeline(**filters)
|
||||||
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
|
|
||||||
media_type=media_type,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
return jsonify(
|
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"])
|
@gallery_bp.route("/jump", methods=["GET"])
|
||||||
async def jump():
|
async def jump():
|
||||||
try:
|
try:
|
||||||
year = int(request.args["year"])
|
year = int(request.args["year"])
|
||||||
month = int(request.args["month"])
|
month = int(request.args["month"])
|
||||||
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
filters, sort = _parse_filters()
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return jsonify({"error": "year and month query params required"}), 400
|
return jsonify({"error": "year and month query params required"}), 400
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
cursor = await svc.jump_cursor(
|
cursor = await svc.jump_cursor(
|
||||||
year=year, month=month, tag_ids=tag_ids,
|
year=year, month=month, sort=sort, **filters,
|
||||||
post_id=post_id, artist_id=artist_id,
|
|
||||||
media_type=media_type, sort=sort,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|||||||
@@ -18,15 +18,22 @@ import base64
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Select, and_, exists, func, or_, select
|
from sqlalchemy import Select, and_, distinct, exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from ..models import Artist, ImageProvenance, ImageRecord, Post, Tag
|
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
|
||||||
CURSOR_SEPARATOR = "|"
|
CURSOR_SEPARATOR = "|"
|
||||||
|
|
||||||
|
# Reserved `platform` filter value selecting images with NO platformed
|
||||||
|
# provenance (filesystem imports). Returned by facets() as a null-valued
|
||||||
|
# bucket; the frontend maps that null back to this sentinel in the URL so the
|
||||||
|
# bucket is selectable. Underscore-wrapped so it can't collide with a real
|
||||||
|
# gallery-dl platform name (patreon/pixiv/...).
|
||||||
|
UNSOURCED_PLATFORM = "__unsourced__"
|
||||||
|
|
||||||
|
|
||||||
def encode_cursor(effective_date: datetime, image_id: int) -> str:
|
def encode_cursor(effective_date: datetime, image_id: int) -> str:
|
||||||
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
||||||
@@ -92,6 +99,16 @@ class TimelineBucket:
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GalleryFacets:
|
||||||
|
total: int # images matching the FULL active filter
|
||||||
|
platforms: list[dict] # [{"value": str|None, "count": int}], null = unsourced
|
||||||
|
untagged: int # how many the Untagged flag would isolate
|
||||||
|
no_artist: int # how many the No-artist flag would isolate
|
||||||
|
date_min: datetime | None
|
||||||
|
date_max: datetime | None
|
||||||
|
|
||||||
|
|
||||||
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
|
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
|
||||||
"""Return the URL to fetch a thumbnail.
|
"""Return the URL to fetch a thumbnail.
|
||||||
|
|
||||||
@@ -128,15 +145,27 @@ def _require_single_filter(tag_ids, post_id, artist_id) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
|
def _apply_scope(
|
||||||
"""Apply the composable gallery filters to a statement already joined
|
stmt, *, tag_ids, post_id, artist_id, media_type,
|
||||||
to Post via _outer_join_primary_post.
|
platform=None, untagged=False, no_artist=False,
|
||||||
|
date_from=None, date_to=None,
|
||||||
|
):
|
||||||
|
"""Apply the composable gallery filters to a statement.
|
||||||
|
|
||||||
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
|
All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
|
||||||
(AND), which avoids the row-multiplication a multi-join would cause.
|
they AND together without row-multiplication and don't require any join to
|
||||||
|
be present on `stmt` (the artist/platform paths alias Post/Source inside
|
||||||
|
their own EXISTS).
|
||||||
|
|
||||||
|
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag.
|
||||||
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
|
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
|
||||||
by _require_single_filter).
|
by _require_single_filter).
|
||||||
- media_type: 'image' | 'video' narrows by mime prefix.
|
- media_type: 'image' | 'video' narrows by mime prefix.
|
||||||
|
- platform: EXISTS a provenance→source with that platform; the
|
||||||
|
UNSOURCED_PLATFORM sentinel inverts it (NO platformed provenance).
|
||||||
|
- untagged: NOT EXISTS any image_tag row.
|
||||||
|
- no_artist: ImageRecord.artist_id IS NULL.
|
||||||
|
- date_from / date_to: half-open [from, to) bounds on effective_date.
|
||||||
"""
|
"""
|
||||||
for tid in tag_ids or []:
|
for tid in tag_ids or []:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
@@ -152,9 +181,39 @@ def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
|
|||||||
stmt = stmt.where(ImageRecord.mime.like("image/%"))
|
stmt = stmt.where(ImageRecord.mime.like("image/%"))
|
||||||
elif media_type == "video":
|
elif media_type == "video":
|
||||||
stmt = stmt.where(ImageRecord.mime.like("video/%"))
|
stmt = stmt.where(ImageRecord.mime.like("video/%"))
|
||||||
|
if platform is not None:
|
||||||
|
stmt = stmt.where(_platform_clause(platform))
|
||||||
|
if untagged:
|
||||||
|
stmt = stmt.where(
|
||||||
|
~exists().where(image_tag.c.image_record_id == ImageRecord.id)
|
||||||
|
)
|
||||||
|
if no_artist:
|
||||||
|
stmt = stmt.where(ImageRecord.artist_id.is_(None))
|
||||||
|
eff = _effective_date_col()
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(eff >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(eff < date_to)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_clause(platform):
|
||||||
|
"""Correlated EXISTS on a provenance row whose Source carries `platform`.
|
||||||
|
The UNSOURCED_PLATFORM sentinel inverts to NOT EXISTS(any sourced
|
||||||
|
provenance) — i.e. filesystem-imported content with no platform."""
|
||||||
|
src = aliased(Source)
|
||||||
|
if platform == UNSOURCED_PLATFORM:
|
||||||
|
return ~exists().where(
|
||||||
|
ImageProvenance.image_record_id == ImageRecord.id,
|
||||||
|
ImageProvenance.source_id == src.id,
|
||||||
|
)
|
||||||
|
return exists().where(
|
||||||
|
ImageProvenance.image_record_id == ImageRecord.id,
|
||||||
|
ImageProvenance.source_id == src.id,
|
||||||
|
src.platform == platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provenance_clause(post_id, artist_id):
|
def _provenance_clause(post_id, artist_id):
|
||||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||||
matching provenance rows is returned exactly once and the
|
matching provenance rows is returned exactly once and the
|
||||||
@@ -215,6 +274,11 @@ class GalleryService:
|
|||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
media_type: str | None = None,
|
media_type: str | None = None,
|
||||||
sort: str = "newest",
|
sort: str = "newest",
|
||||||
|
platform: str | None = None,
|
||||||
|
untagged: bool = False,
|
||||||
|
no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
) -> GalleryPage:
|
) -> GalleryPage:
|
||||||
if limit < 1 or limit > 200:
|
if limit < 1 or limit > 200:
|
||||||
raise ValueError("limit must be between 1 and 200")
|
raise ValueError("limit must be between 1 and 200")
|
||||||
@@ -226,6 +290,8 @@ class GalleryService:
|
|||||||
stmt = _apply_scope(
|
stmt = _apply_scope(
|
||||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
artist_id=artist_id, media_type=media_type,
|
artist_id=artist_id, media_type=media_type,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
descending = sort != "oldest"
|
descending = sort != "oldest"
|
||||||
@@ -286,6 +352,11 @@ class GalleryService:
|
|||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
media_type: str | None = None,
|
media_type: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
untagged: bool = False,
|
||||||
|
no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
) -> list[TimelineBucket]:
|
) -> list[TimelineBucket]:
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
year_col = func.date_part("year", eff).label("yr")
|
year_col = func.date_part("year", eff).label("yr")
|
||||||
@@ -298,6 +369,8 @@ class GalleryService:
|
|||||||
stmt = _apply_scope(
|
stmt = _apply_scope(
|
||||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
artist_id=artist_id, media_type=media_type,
|
artist_id=artist_id, media_type=media_type,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
)
|
)
|
||||||
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
@@ -307,6 +380,9 @@ class GalleryService:
|
|||||||
self, year: int, month: int, tag_ids: list[int] | None = None,
|
self, year: int, month: int, tag_ids: list[int] | None = None,
|
||||||
post_id: int | None = None, artist_id: int | None = None,
|
post_id: int | None = None, artist_id: int | None = None,
|
||||||
media_type: str | None = None, sort: str = "newest",
|
media_type: str | None = None, sort: str = "newest",
|
||||||
|
platform: str | None = None, untagged: bool = False,
|
||||||
|
no_artist: bool = False, date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Returns a cursor that, when passed to scroll() with the same sort,
|
"""Returns a cursor that, when passed to scroll() with the same sort,
|
||||||
positions at the first image of the given year-month. None if the
|
positions at the first image of the given year-month. None if the
|
||||||
@@ -324,6 +400,8 @@ class GalleryService:
|
|||||||
stmt = _apply_scope(
|
stmt = _apply_scope(
|
||||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
artist_id=artist_id, media_type=media_type,
|
artist_id=artist_id, media_type=media_type,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
)
|
)
|
||||||
descending = sort != "oldest"
|
descending = sort != "oldest"
|
||||||
if descending:
|
if descending:
|
||||||
@@ -339,6 +417,94 @@ class GalleryService:
|
|||||||
boundary = record.id + 1 if descending else record.id - 1
|
boundary = record.id + 1 if descending else record.id - 1
|
||||||
return encode_cursor(eff_date, boundary)
|
return encode_cursor(eff_date, boundary)
|
||||||
|
|
||||||
|
async def facets(
|
||||||
|
self, *, tag_ids: list[int] | None = None,
|
||||||
|
post_id: int | None = None, artist_id: int | None = None,
|
||||||
|
media_type: str | None = None, platform: str | None = None,
|
||||||
|
untagged: bool = False, no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||||
|
) -> GalleryFacets:
|
||||||
|
"""Live facet counts scoped to the current filter. Each facet GROUP is
|
||||||
|
computed with all OTHER active filters applied but its OWN selection
|
||||||
|
ignored ("minus-self"), so sibling options stay visible/switchable.
|
||||||
|
No outer join is needed — every clause is a correlated EXISTS or a
|
||||||
|
column predicate on ImageRecord.
|
||||||
|
"""
|
||||||
|
_require_single_filter(tag_ids, post_id, artist_id)
|
||||||
|
common = dict(
|
||||||
|
tag_ids=tag_ids, post_id=post_id,
|
||||||
|
artist_id=artist_id, media_type=media_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# total — the full active filter (the headline result count).
|
||||||
|
total = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **common,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
# platforms — scope minus the platform selection. Inner-join
|
||||||
|
# provenance→source and COUNT(DISTINCT image) per platform (a
|
||||||
|
# cross-posted image counts under each of its platforms).
|
||||||
|
plat_scope = dict(
|
||||||
|
common, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
)
|
||||||
|
src = aliased(Source)
|
||||||
|
plat_stmt = (
|
||||||
|
select(src.platform, func.count(distinct(ImageRecord.id)))
|
||||||
|
.select_from(ImageRecord)
|
||||||
|
.join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id)
|
||||||
|
.join(src, src.id == ImageProvenance.source_id)
|
||||||
|
)
|
||||||
|
plat_stmt = _apply_scope(plat_stmt, **plat_scope).group_by(src.platform)
|
||||||
|
platforms = [
|
||||||
|
{"value": p, "count": c}
|
||||||
|
for p, c in (await self.session.execute(plat_stmt)).all()
|
||||||
|
]
|
||||||
|
# Unsourced (filesystem) bucket — same minus-platform scope.
|
||||||
|
unsourced = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **plat_scope,
|
||||||
|
platform=UNSOURCED_PLATFORM,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
if unsourced:
|
||||||
|
platforms.append({"value": None, "count": unsourced})
|
||||||
|
|
||||||
|
# curation flags — each minus its OWN flag.
|
||||||
|
untagged_count = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **common,
|
||||||
|
platform=platform, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to, untagged=True,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
no_artist_count = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **common,
|
||||||
|
platform=platform, untagged=untagged,
|
||||||
|
date_from=date_from, date_to=date_to, no_artist=True,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
# date bounds — scope minus the date params (those drive the picker).
|
||||||
|
eff = _effective_date_col()
|
||||||
|
dmin, dmax = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.min(eff), func.max(eff)), **common,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
)
|
||||||
|
)).one()
|
||||||
|
|
||||||
|
return GalleryFacets(
|
||||||
|
total=total, platforms=platforms,
|
||||||
|
untagged=untagged_count, no_artist=no_artist_count,
|
||||||
|
date_min=dmin, date_max=dmax,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||||
record = await self.session.get(ImageRecord, image_id)
|
record = await self.session.get(ImageRecord, image_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
|
|||||||
@@ -77,3 +77,40 @@ async def test_scroll_media_param(client, db):
|
|||||||
resp = await client.get("/api/gallery/scroll?limit=10&media=video")
|
resp = await client.get("/api/gallery/scroll?limit=10&media=video")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert (await resp.get_json())["images"] == []
|
assert (await resp.get_json())["images"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_endpoint(client, db):
|
||||||
|
await _seed(db, 3) # filesystem images: no artist, no tags, no platform
|
||||||
|
resp = await client.get("/api/gallery/facets")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
assert body["untagged"] == 3
|
||||||
|
assert body["no_artist"] == 3
|
||||||
|
assert "platforms" in body
|
||||||
|
assert body["date_min"] is not None
|
||||||
|
assert body["date_max"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_rejects_bad_date(client):
|
||||||
|
resp = await client.get("/api/gallery/facets?date_from=notadate")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_untagged_param(client, db):
|
||||||
|
await _seed(db, 2)
|
||||||
|
resp = await client.get("/api/gallery/scroll?untagged=1&limit=10")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len((await resp.get_json())["images"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_date_from_param(client, db):
|
||||||
|
await _seed(db, 2) # all created ~now
|
||||||
|
# A future date_from excludes everything.
|
||||||
|
resp = await client.get("/api/gallery/scroll?date_from=2099-01-01&limit=10")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert (await resp.get_json())["images"] == []
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Phase-2 faceted refine: new composable filter params + the facets()
|
||||||
|
aggregate (platform / curation-flags / date-range counts, scoped live to the
|
||||||
|
current filter with per-group minus-self semantics)."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.models import (
|
||||||
|
Artist,
|
||||||
|
ImageProvenance,
|
||||||
|
ImageRecord,
|
||||||
|
Post,
|
||||||
|
Source,
|
||||||
|
Tag,
|
||||||
|
TagKind,
|
||||||
|
)
|
||||||
|
from backend.app.models.tag import image_tag
|
||||||
|
from backend.app.services.gallery_service import (
|
||||||
|
UNSOURCED_PLATFORM,
|
||||||
|
GalleryService,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BASE = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def _artist(db, name):
|
||||||
|
a = Artist(name=name, slug=name.lower())
|
||||||
|
db.add(a)
|
||||||
|
await db.flush()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
async def _sourced_image(db, n, platform, artist, eff):
|
||||||
|
"""A downloaded image with a Source(platform) + Post + provenance row."""
|
||||||
|
s = Source(
|
||||||
|
artist_id=artist.id, platform=platform,
|
||||||
|
url=f"https://{platform}.test/{artist.slug}/{n}",
|
||||||
|
)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
p = Post(source_id=s.id, artist_id=artist.id, external_post_id=f"{platform}-{n}")
|
||||||
|
db.add(p)
|
||||||
|
await db.flush()
|
||||||
|
rec = ImageRecord(
|
||||||
|
path=f"/images/s/{platform}-{n}.jpg", sha256=f"a{n:063d}",
|
||||||
|
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||||
|
origin="downloaded", integrity_status="ok",
|
||||||
|
artist_id=artist.id, primary_post_id=p.id,
|
||||||
|
)
|
||||||
|
rec.created_at = eff
|
||||||
|
rec.effective_date = eff
|
||||||
|
db.add(rec)
|
||||||
|
await db.flush()
|
||||||
|
db.add(ImageProvenance(image_record_id=rec.id, post_id=p.id, source_id=s.id))
|
||||||
|
await db.flush()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
async def _fs_image(db, n, eff, artist=None):
|
||||||
|
"""A filesystem-imported image: no provenance/source (unsourced),
|
||||||
|
artist optional."""
|
||||||
|
rec = ImageRecord(
|
||||||
|
path=f"/images/fs/{n}.jpg", sha256=f"b{n:063d}",
|
||||||
|
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||||
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
|
artist_id=artist.id if artist else None,
|
||||||
|
)
|
||||||
|
rec.created_at = eff
|
||||||
|
rec.effective_date = eff
|
||||||
|
db.add(rec)
|
||||||
|
await db.flush()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
async def _tag(db, image, name):
|
||||||
|
t = Tag(name=name, kind=TagKind.general)
|
||||||
|
db.add(t)
|
||||||
|
await db.flush()
|
||||||
|
await db.execute(image_tag.insert().values(
|
||||||
|
image_record_id=image.id, tag_id=t.id, source="manual"))
|
||||||
|
await db.flush()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# --- facets() aggregate ----------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_platform_counts(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||||
|
await _sourced_image(db, 2, "patreon", ar, _BASE)
|
||||||
|
await _sourced_image(db, 3, "pixiv", ar, _BASE)
|
||||||
|
await _fs_image(db, 4, _BASE) # unsourced → null bucket
|
||||||
|
svc = GalleryService(db)
|
||||||
|
f = await svc.facets()
|
||||||
|
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||||
|
assert counts["patreon"] == 2
|
||||||
|
assert counts["pixiv"] == 1
|
||||||
|
assert counts[None] == 1
|
||||||
|
assert f.total == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_counts_image_under_each_platform(db):
|
||||||
|
"""A single image cross-posted to two platforms counts under both
|
||||||
|
(DISTINCT image per platform), but is one image in total."""
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
rec = await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||||
|
s2 = Source(artist_id=ar.id, platform="pixiv", url="https://pixiv.test/ar/x")
|
||||||
|
db.add(s2)
|
||||||
|
await db.flush()
|
||||||
|
p2 = Post(source_id=s2.id, artist_id=ar.id, external_post_id="pixiv-x")
|
||||||
|
db.add(p2)
|
||||||
|
await db.flush()
|
||||||
|
db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id, source_id=s2.id))
|
||||||
|
await db.flush()
|
||||||
|
svc = GalleryService(db)
|
||||||
|
f = await svc.facets()
|
||||||
|
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||||
|
assert counts["patreon"] == 1
|
||||||
|
assert counts["pixiv"] == 1
|
||||||
|
assert f.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_curation_flags(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
tagged = await _fs_image(db, 1, _BASE, artist=ar)
|
||||||
|
await _tag(db, tagged, "x")
|
||||||
|
await _fs_image(db, 2, _BASE, artist=ar) # untagged, has artist
|
||||||
|
await _fs_image(db, 3, _BASE, artist=None) # untagged, no artist
|
||||||
|
svc = GalleryService(db)
|
||||||
|
f = await svc.facets()
|
||||||
|
assert f.untagged == 2
|
||||||
|
assert f.no_artist == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_date_bounds(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar)
|
||||||
|
await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
f = await svc.facets()
|
||||||
|
assert f.date_min == datetime(2020, 1, 1, tzinfo=UTC)
|
||||||
|
assert f.date_max == datetime(2026, 6, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_platform_minus_self(db):
|
||||||
|
"""The platform group ignores its OWN active selection so siblings stay
|
||||||
|
visible/switchable; total still honors the platform filter."""
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||||
|
await _sourced_image(db, 2, "pixiv", ar, _BASE)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
f = await svc.facets(platform="patreon")
|
||||||
|
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||||
|
assert counts.get("pixiv") == 1
|
||||||
|
assert counts.get("patreon") == 1
|
||||||
|
assert f.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_facets_other_filter_scopes_platforms(db):
|
||||||
|
"""A non-platform filter (media) narrows the platform counts."""
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||||
|
vid = await _sourced_image(db, 2, "patreon", ar, _BASE)
|
||||||
|
vid.mime = "video/mp4"
|
||||||
|
await db.flush()
|
||||||
|
svc = GalleryService(db)
|
||||||
|
f = await svc.facets(media_type="image")
|
||||||
|
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||||
|
assert counts["patreon"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- new scroll filter params ----------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_platform_filter(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
pat = await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||||
|
await _sourced_image(db, 2, "pixiv", ar, _BASE)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(cursor=None, limit=10, platform="patreon")
|
||||||
|
assert [i.id for i in page.images] == [pat.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_platform_unsourced(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||||
|
fs = await _fs_image(db, 2, _BASE)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(cursor=None, limit=10, platform=UNSOURCED_PLATFORM)
|
||||||
|
assert [i.id for i in page.images] == [fs.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_untagged_filter(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
tagged = await _fs_image(db, 1, _BASE, artist=ar)
|
||||||
|
await _tag(db, tagged, "x")
|
||||||
|
untag = await _fs_image(db, 2, _BASE, artist=ar)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(cursor=None, limit=10, untagged=True)
|
||||||
|
assert [i.id for i in page.images] == [untag.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_no_artist_filter(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
await _fs_image(db, 1, _BASE, artist=ar)
|
||||||
|
none = await _fs_image(db, 2, _BASE, artist=None)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(cursor=None, limit=10, no_artist=True)
|
||||||
|
assert [i.id for i in page.images] == [none.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_date_range_filter(db):
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
old = await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar)
|
||||||
|
new = await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(
|
||||||
|
cursor=None, limit=10, date_from=datetime(2026, 1, 1, tzinfo=UTC))
|
||||||
|
assert [i.id for i in page.images] == [new.id]
|
||||||
|
page2 = await svc.scroll(
|
||||||
|
cursor=None, limit=10, date_to=datetime(2021, 1, 1, tzinfo=UTC))
|
||||||
|
assert [i.id for i in page2.images] == [old.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_combines_new_filters_and(db):
|
||||||
|
"""platform + untagged + no_artist compose with AND."""
|
||||||
|
ar = await _artist(db, "Ar")
|
||||||
|
# match: unsourced, untagged, no artist
|
||||||
|
match = await _fs_image(db, 1, _BASE, artist=None)
|
||||||
|
# decoys violating one clause each
|
||||||
|
await _fs_image(db, 2, _BASE, artist=ar) # has artist
|
||||||
|
tagged = await _fs_image(db, 3, _BASE, artist=None)
|
||||||
|
await _tag(db, tagged, "x") # tagged
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(
|
||||||
|
cursor=None, limit=10,
|
||||||
|
platform=UNSOURCED_PLATFORM, untagged=True, no_artist=True)
|
||||||
|
assert [i.id for i in page.images] == [match.id]
|
||||||
Reference in New Issue
Block a user