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
|
||||
|
||||
@@ -18,15 +18,22 @@ import base64
|
||||
from dataclasses import dataclass
|
||||
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.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
|
||||
|
||||
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:
|
||||
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
||||
@@ -92,6 +99,16 @@ class TimelineBucket:
|
||||
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:
|
||||
"""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):
|
||||
"""Apply the composable gallery filters to a statement already joined
|
||||
to Post via _outer_join_primary_post.
|
||||
def _apply_scope(
|
||||
stmt, *, tag_ids, post_id, artist_id, media_type,
|
||||
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
|
||||
(AND), which avoids the row-multiplication a multi-join would cause.
|
||||
All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
|
||||
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
|
||||
by _require_single_filter).
|
||||
- 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 []:
|
||||
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/%"))
|
||||
elif media_type == "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
|
||||
|
||||
|
||||
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):
|
||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||
matching provenance rows is returned exactly once and the
|
||||
@@ -215,6 +274,11 @@ class GalleryService:
|
||||
artist_id: int | None = None,
|
||||
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,
|
||||
) -> GalleryPage:
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
@@ -226,6 +290,8 @@ class GalleryService:
|
||||
stmt = _apply_scope(
|
||||
stmt, 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,
|
||||
)
|
||||
|
||||
descending = sort != "oldest"
|
||||
@@ -286,6 +352,11 @@ class GalleryService:
|
||||
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,
|
||||
) -> list[TimelineBucket]:
|
||||
eff = _effective_date_col()
|
||||
year_col = func.date_part("year", eff).label("yr")
|
||||
@@ -298,6 +369,8 @@ class GalleryService:
|
||||
stmt = _apply_scope(
|
||||
stmt, 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,
|
||||
)
|
||||
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
@@ -307,6 +380,9 @@ class GalleryService:
|
||||
self, year: int, month: int, tag_ids: list[int] | None = None,
|
||||
post_id: int | None = None, artist_id: int | None = None,
|
||||
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:
|
||||
"""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
|
||||
@@ -324,6 +400,8 @@ class GalleryService:
|
||||
stmt = _apply_scope(
|
||||
stmt, 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,
|
||||
)
|
||||
descending = sort != "oldest"
|
||||
if descending:
|
||||
@@ -339,6 +417,94 @@ class GalleryService:
|
||||
boundary = record.id + 1 if descending else record.id - 1
|
||||
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:
|
||||
record = await self.session.get(ImageRecord, image_id)
|
||||
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")
|
||||
assert resp.status_code == 200
|
||||
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