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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user