23fab983a0
Cluster A, milestone #97. #5: clicking an image-modal tag chip's body now closes the modal and opens the gallery filtered for that one tag (fresh filter); ✕/kebab stay as the explicit remove/rename controls. #6a (backend of OR/exclude filtering): gallery_service._apply_scope gains a structured tag model — tag_or_groups (AND-of-OR: one EXISTS(tag_id IN group) per group) + tag_exclude (NOT EXISTS(tag_id IN exclude)) — layered additively on the existing tag_ids AND path so cursors/facets/deep-links are untouched. Threaded through scroll/timeline/jump_cursor/facets/similar + facets common dict; _require_single_filter rejects post_id combined with OR/exclude. API parses tag_or (repeatable → one OR-group each) + tag_not (csv exclude). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
717 lines
29 KiB
Python
717 lines
29 KiB
Python
"""Cursor-paginated gallery queries.
|
|
|
|
Cursor format: opaque base64-encoded "<iso8601_effective_date>:<image_id>".
|
|
|
|
Pagination key is (effective_date DESC, id DESC) where effective_date is
|
|
COALESCE(post.post_date, image_record.created_at) so the gallery surfaces
|
|
images by ORIGINAL publish date when known, falling back to FC's scan
|
|
date. Important for migrated content: ~57k IR images scanned in a single
|
|
week would otherwise all share the same created_at and pile up in one
|
|
month bucket. The effective_date spreads them across the years they
|
|
were originally published.
|
|
|
|
Decoding rejects malformed cursors with a ValueError; the API layer
|
|
translates that to HTTP 400.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
|
|
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, Source, Tag
|
|
from ..models.tag import image_tag
|
|
from .pagination import decode_cursor, encode_cursor
|
|
from .tag_query import fandom_join_alias, serialize_tag, tag_columns
|
|
|
|
# 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 _effective_date_col():
|
|
"""The materialized gallery sort key: image_record.effective_date
|
|
(alembic 0035) = COALESCE(primary post's post_date, created_at),
|
|
maintained at write time by the importer.
|
|
|
|
Canonical sort/group/filter key across the gallery so images attached
|
|
to a post surface at their original publish date, not their FC import
|
|
date — and, now that it's a single indexed column rather than a
|
|
COALESCE across the Post outer join, the cursor scroll is an index
|
|
range scan instead of a full re-sort per page.
|
|
"""
|
|
return ImageRecord.effective_date
|
|
|
|
|
|
def _outer_join_primary_post(stmt: Select) -> Select:
|
|
"""LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE
|
|
above sees Post.post_date when available. Images without a post
|
|
survive the join as NULL on the Post side; COALESCE handles it."""
|
|
return stmt.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GalleryImage:
|
|
id: int
|
|
path: str
|
|
sha256: str
|
|
mime: str
|
|
width: int | None
|
|
height: int | None
|
|
created_at: datetime # FC's row-insert time
|
|
effective_date: datetime # COALESCE(post.post_date, created_at)
|
|
posted_at: datetime | None # post.post_date if known, else None
|
|
thumbnail_url: str
|
|
artist: dict | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GalleryPage:
|
|
images: list[GalleryImage]
|
|
next_cursor: str | None
|
|
date_groups: list[tuple[int, int, list[int]]] # (year, month, [image_id...])
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TimelineBucket:
|
|
year: int
|
|
month: 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:
|
|
"""Return the URL to fetch a thumbnail.
|
|
|
|
Prefers the stored thumbnail_path verbatim — Quart serves /images/*
|
|
1:1 from the volume (frontend.py:20-36), so the URL IS the disk
|
|
path. Falls back to deriving from (sha256, mime) only when the
|
|
record's thumbnail_path is NULL (thumbnailer hasn't run yet); that
|
|
URL will 404 until backfill catches it, same as before the path
|
|
was tracked.
|
|
|
|
Pre-2026-05-30 this was derived only from (sha256, mime), which
|
|
disagreed with the actual on-disk extension when the thumbnailer
|
|
chose its format from transparency rather than MIME — every PNG
|
|
source without alpha (extension was .jpg on disk) and every WebP
|
|
source with alpha (extension was .png on disk) silently 404'd
|
|
despite the thumbnail file existing.
|
|
"""
|
|
if thumbnail_path:
|
|
return thumbnail_path
|
|
# Fallback for records with no thumbnail recorded yet — preserves
|
|
# prior behavior (URL exists but 404s until backfill regenerates).
|
|
ext = ".png" if mime in ("image/png", "image/gif") else ".jpg"
|
|
bucket = sha256_hex[:3]
|
|
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
|
|
|
|
|
def image_url(path: str) -> str:
|
|
"""Return the URL to fetch the full-size original from /images.
|
|
|
|
The on-disk `path` mirrors the source tree (artist/post folders), so it can
|
|
contain characters that are special in a URL — most importantly '#' (post
|
|
titles like 'BLUE#59'), but also spaces, '?', '%'. The serve_image route
|
|
URL-decodes its <path:subpath> fine, but only if the browser sends the whole
|
|
path; an unencoded '#' is parsed as a fragment, so '#59/01_timelapse.jpg'
|
|
never reaches the server and the original 404s while the (hash-named)
|
|
thumbnail still loads. Percent-encode the path, keeping '/' as the segment
|
|
separator. Operator-flagged 2026-06-12."""
|
|
rel = path.split("/images/", 1)[-1]
|
|
return f"/images/{quote(rel, safe='/')}"
|
|
|
|
|
|
def _require_single_filter(
|
|
tag_ids, post_id, artist_id, tag_or_groups=None, tag_exclude=None,
|
|
) -> None:
|
|
"""post_id is the post-detail view — it can't combine with the
|
|
composable filters. tag_ids / tag_or_groups / tag_exclude + artist_id
|
|
(+ media_type) compose freely (AND)."""
|
|
if post_id is not None and (
|
|
tag_ids or artist_id is not None or tag_or_groups or tag_exclude
|
|
):
|
|
raise ValueError(
|
|
"post_id cannot be combined with tag or artist filters"
|
|
)
|
|
|
|
|
|
def _apply_scope(
|
|
stmt, *, tag_ids, post_id, artist_id, media_type,
|
|
tag_or_groups=None, tag_exclude=None,
|
|
platform=None, untagged=False, no_artist=False,
|
|
date_from=None, date_to=None,
|
|
):
|
|
"""Apply the composable gallery filters to a statement.
|
|
|
|
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 filtering is one structured model (#6): AND-of-OR plus exclusions.
|
|
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
|
|
(the AND-of-singletons "include" common case; light editor + back-compat).
|
|
- tag_or_groups: list of OR-groups; the image must carry AT LEAST ONE tag
|
|
from EACH group — one EXISTS(tag_id IN group) per group, AND'd across
|
|
groups. (advanced editor)
|
|
- tag_exclude: image must carry NONE of these — a single NOT EXISTS(tag_id
|
|
IN exclude). (light "exclude" chips + advanced NOT)
|
|
- 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(
|
|
exists().where(
|
|
image_tag.c.image_record_id == ImageRecord.id,
|
|
image_tag.c.tag_id == tid,
|
|
)
|
|
)
|
|
for group in tag_or_groups or []:
|
|
if not group:
|
|
continue # an empty OR-group would match nothing; treat as absent
|
|
stmt = stmt.where(
|
|
exists().where(
|
|
image_tag.c.image_record_id == ImageRecord.id,
|
|
image_tag.c.tag_id.in_(group),
|
|
)
|
|
)
|
|
if tag_exclude:
|
|
stmt = stmt.where(
|
|
~exists().where(
|
|
image_tag.c.image_record_id == ImageRecord.id,
|
|
image_tag.c.tag_id.in_(tag_exclude),
|
|
)
|
|
)
|
|
prov = _provenance_clause(post_id, artist_id)
|
|
if prov is not None:
|
|
stmt = stmt.where(prov)
|
|
if media_type == "image":
|
|
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
|
|
(effective_date DESC, id DESC) cursor ordering is unaffected."""
|
|
if post_id is not None:
|
|
return exists().where(
|
|
ImageProvenance.image_record_id == ImageRecord.id,
|
|
ImageProvenance.post_id == post_id,
|
|
)
|
|
if artist_id is not None:
|
|
# Use Post.artist_id (alembic 0030 denormalized column) instead
|
|
# of joining through ImageProvenance.source_id → Source.artist_id.
|
|
# The denormalization is the always-present linkage; the source
|
|
# path now drops NULL-source provenance rows (filesystem-imported
|
|
# content) which would otherwise vanish from artist-filtered
|
|
# gallery views.
|
|
# ALIAS Post: the gallery query outer-joins Post on
|
|
# ImageRecord.primary_post_id (`_outer_join_primary_post`).
|
|
# SQLAlchemy would otherwise correlate a bare `Post` reference
|
|
# in this EXISTS subquery to that outer Post (which is NULL for
|
|
# images with no primary post), and the filter would silently
|
|
# match nothing.
|
|
post_inner = aliased(Post)
|
|
return exists().where(
|
|
ImageProvenance.image_record_id == ImageRecord.id,
|
|
ImageProvenance.post_id == post_inner.id,
|
|
post_inner.artist_id == artist_id,
|
|
)
|
|
return None
|
|
|
|
|
|
def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
|
|
"""Build GalleryImage list from (record, posted_at, eff_date) rows + the
|
|
artist hydration map. Shared by scroll() and similar()."""
|
|
return [
|
|
GalleryImage(
|
|
id=record.id,
|
|
path=record.path,
|
|
sha256=record.sha256,
|
|
mime=record.mime,
|
|
width=record.width,
|
|
height=record.height,
|
|
created_at=record.created_at,
|
|
effective_date=eff_date,
|
|
posted_at=posted_at,
|
|
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
|
artist=artists.get(record.id),
|
|
)
|
|
for record, posted_at, eff_date in rows
|
|
]
|
|
|
|
|
|
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
|
"""Map image_id -> {"name","slug"} via the canonical
|
|
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
|
if not image_ids:
|
|
return {}
|
|
stmt = (
|
|
select(ImageRecord.id, Artist.name, Artist.slug)
|
|
.join(Artist, Artist.id == ImageRecord.artist_id)
|
|
.where(ImageRecord.id.in_(image_ids))
|
|
)
|
|
return {
|
|
img_id: {"name": name, "slug": slug}
|
|
for img_id, name, slug in (await session.execute(stmt)).all()
|
|
}
|
|
|
|
|
|
class GalleryService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def scroll(
|
|
self,
|
|
cursor: str | None,
|
|
limit: int = 50,
|
|
tag_ids: list[int] | None = None,
|
|
post_id: int | None = None,
|
|
artist_id: int | None = None,
|
|
media_type: str | None = None,
|
|
sort: str = "newest",
|
|
tag_or_groups: list[list[int]] | None = None,
|
|
tag_exclude: list[int] | None = None,
|
|
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")
|
|
_require_single_filter(
|
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
|
)
|
|
|
|
eff = _effective_date_col()
|
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
|
stmt = _outer_join_primary_post(stmt)
|
|
stmt = _apply_scope(
|
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
|
artist_id=artist_id, media_type=media_type,
|
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
|
date_from=date_from, date_to=date_to,
|
|
)
|
|
|
|
descending = sort != "oldest"
|
|
if cursor:
|
|
cur_ts, cur_id = decode_cursor(cursor)
|
|
# The cursor is just (last eff, last id); the request's sort
|
|
# decides which side of it the next page lies on.
|
|
if descending:
|
|
stmt = stmt.where(
|
|
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
|
|
)
|
|
else:
|
|
stmt = stmt.where(
|
|
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
|
|
)
|
|
|
|
if descending:
|
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
|
else:
|
|
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
|
stmt = stmt.limit(limit + 1)
|
|
rows = (await self.session.execute(stmt)).all()
|
|
|
|
next_cursor = None
|
|
if len(rows) > limit:
|
|
last_record, _last_posted_at, last_eff = rows[limit - 1]
|
|
next_cursor = encode_cursor(last_eff, last_record.id)
|
|
rows = rows[:limit]
|
|
|
|
artists = await _artists_for(
|
|
self.session, [r[0].id for r in rows]
|
|
)
|
|
images = _gallery_images(rows, artists)
|
|
return GalleryPage(
|
|
images=images,
|
|
next_cursor=next_cursor,
|
|
date_groups=_group_by_year_month(images),
|
|
)
|
|
|
|
async def timeline(
|
|
self,
|
|
tag_ids: list[int] | None = None,
|
|
post_id: int | None = None,
|
|
artist_id: int | None = None,
|
|
media_type: str | None = None,
|
|
tag_or_groups: list[list[int]] | None = None,
|
|
tag_exclude: list[int] | 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")
|
|
month_col = func.date_part("month", eff).label("mo")
|
|
stmt = select(
|
|
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
|
)
|
|
stmt = _outer_join_primary_post(stmt)
|
|
_require_single_filter(
|
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
|
)
|
|
stmt = _apply_scope(
|
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
|
artist_id=artist_id, media_type=media_type,
|
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
|
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()
|
|
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
|
|
|
|
async def jump_cursor(
|
|
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",
|
|
tag_or_groups: list[list[int]] | None = None,
|
|
tag_exclude: list[int] | None = None,
|
|
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
|
|
bucket is empty.
|
|
"""
|
|
from sqlalchemy import extract
|
|
|
|
eff = _effective_date_col()
|
|
stmt = select(ImageRecord, eff.label("eff")).where(
|
|
extract("year", eff) == year,
|
|
extract("month", eff) == month,
|
|
)
|
|
stmt = _outer_join_primary_post(stmt)
|
|
_require_single_filter(
|
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
|
)
|
|
stmt = _apply_scope(
|
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
|
artist_id=artist_id, media_type=media_type,
|
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
|
date_from=date_from, date_to=date_to,
|
|
)
|
|
descending = sort != "oldest"
|
|
if descending:
|
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
|
else:
|
|
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
|
first = (await self.session.execute(stmt.limit(1))).first()
|
|
if first is None:
|
|
return None
|
|
record, eff_date = first
|
|
# Cursor is exclusive; nudge the id one past the boundary row (in the
|
|
# scan direction) so the row itself is the first result of scroll().
|
|
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,
|
|
tag_or_groups: list[list[int]] | None = None,
|
|
tag_exclude: list[int] | 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, tag_or_groups, tag_exclude,
|
|
)
|
|
common = {
|
|
"tag_ids": tag_ids, "post_id": post_id,
|
|
"artist_id": artist_id, "media_type": media_type,
|
|
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
|
|
}
|
|
|
|
# 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 = {
|
|
**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 similar(
|
|
self, image_id: int, limit: int = 100, *,
|
|
tag_ids: list[int] | None = None, artist_id: int | None = None,
|
|
media_type: str | None = None,
|
|
tag_or_groups: list[list[int]] | None = None,
|
|
tag_exclude: list[int] | None = None,
|
|
platform: str | None = None,
|
|
untagged: bool = False, no_artist: bool = False,
|
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
|
) -> list[GalleryImage] | None:
|
|
"""Visual "more like this": images ranked by cosine distance to
|
|
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
|
|
No ML inference here; the embedding was computed at import.
|
|
|
|
Returns None if the source image doesn't exist (→ 404), [] if it has
|
|
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
|
|
scope filters (AND) but REPLACES the date sort — always nearest-first,
|
|
bounded to `limit` (no cursor; distance-ranking has no date cursor).
|
|
"""
|
|
if limit < 1 or limit > 200:
|
|
raise ValueError("limit must be between 1 and 200")
|
|
src = await self.session.get(ImageRecord, image_id)
|
|
if src is None:
|
|
return None
|
|
if src.siglip_embedding is None:
|
|
return []
|
|
|
|
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
|
eff = _effective_date_col()
|
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
|
stmt = _outer_join_primary_post(stmt)
|
|
stmt = stmt.where(
|
|
ImageRecord.siglip_embedding.is_not(None),
|
|
ImageRecord.id != image_id,
|
|
)
|
|
stmt = _apply_scope(
|
|
stmt, tag_ids=tag_ids, post_id=None,
|
|
artist_id=artist_id, media_type=media_type,
|
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
|
date_from=date_from, date_to=date_to,
|
|
)
|
|
stmt = stmt.order_by(distance.asc()).limit(limit)
|
|
rows = (await self.session.execute(stmt)).all()
|
|
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
|
return _gallery_images(rows, artists)
|
|
|
|
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
|
record = await self.session.get(ImageRecord, image_id)
|
|
if record is None:
|
|
return None
|
|
# Self-join Tag to resolve a character's fandom NAME (not just id) so the
|
|
# modal chip can label it without an N+1 (shared tag_query helpers).
|
|
fandom_alias = fandom_join_alias()
|
|
tag_stmt = (
|
|
select(*tag_columns(fandom_alias))
|
|
.select_from(
|
|
Tag.__table__
|
|
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
|
.outerjoin(fandom_alias, Tag.fandom_id == fandom_alias.c.id)
|
|
)
|
|
.where(image_tag.c.image_record_id == image_id)
|
|
.order_by(Tag.kind.asc(), Tag.name.asc())
|
|
)
|
|
tags = (await self.session.execute(tag_stmt)).all()
|
|
# Fetch the canonical post.post_date for this image (if any) so
|
|
# the modal can show "Posted on <date>" alongside import date.
|
|
posted_at = None
|
|
if record.primary_post_id is not None:
|
|
posted_at = (await self.session.execute(
|
|
select(Post.post_date).where(Post.id == record.primary_post_id)
|
|
)).scalar_one_or_none()
|
|
neighbors = await self._neighbors(record)
|
|
# Direct artist FK — used by the modal's ProvenancePanel as a
|
|
# fallback when ImageProvenance is empty (i.e., filesystem-
|
|
# imported images without a post-track provenance row). The
|
|
# source of truth for richer post-level data is still
|
|
# ImageProvenance/Post; this is just the "we at least know who
|
|
# made it" line.
|
|
artist = None
|
|
if record.artist_id is not None:
|
|
artist = await self.session.get(Artist, record.artist_id)
|
|
return {
|
|
"id": record.id,
|
|
"path": record.path,
|
|
"sha256": record.sha256,
|
|
"mime": record.mime,
|
|
"width": record.width,
|
|
"height": record.height,
|
|
"size_bytes": record.size_bytes,
|
|
"integrity_status": record.integrity_status,
|
|
# Phase 3: lets the modal hide the "Related"/find-similar surface
|
|
# for images that have no embedding yet (videos / pending ML).
|
|
"has_embedding": record.siglip_embedding is not None,
|
|
"created_at": record.created_at.isoformat(),
|
|
"posted_at": posted_at.isoformat() if posted_at else None,
|
|
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
|
"image_url": image_url(record.path),
|
|
"artist": (
|
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
|
if artist is not None else None
|
|
),
|
|
"tags": [serialize_tag(t) for t in tags],
|
|
"neighbors": neighbors,
|
|
}
|
|
|
|
async def _neighbors(self, record: ImageRecord) -> dict:
|
|
# The boundary image's sort key is materialized on the row now
|
|
# (alembic 0035) — read it directly instead of re-deriving COALESCE
|
|
# via an extra Post lookup.
|
|
boundary_eff = record.effective_date
|
|
|
|
eff = _effective_date_col()
|
|
prev_stmt = _outer_join_primary_post(
|
|
select(ImageRecord.id).where(
|
|
or_(
|
|
eff > boundary_eff,
|
|
and_(
|
|
eff == boundary_eff,
|
|
ImageRecord.id > record.id,
|
|
),
|
|
)
|
|
)
|
|
).order_by(eff.asc(), ImageRecord.id.asc()).limit(1)
|
|
next_stmt = _outer_join_primary_post(
|
|
select(ImageRecord.id).where(
|
|
or_(
|
|
eff < boundary_eff,
|
|
and_(
|
|
eff == boundary_eff,
|
|
ImageRecord.id < record.id,
|
|
),
|
|
)
|
|
)
|
|
).order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
|
prev_id = (await self.session.execute(prev_stmt)).scalar_one_or_none()
|
|
next_id = (await self.session.execute(next_stmt)).scalar_one_or_none()
|
|
return {"prev_id": prev_id, "next_id": next_id}
|
|
|
|
|
|
def _group_by_year_month(
|
|
images: list[GalleryImage],
|
|
) -> list[tuple[int, int, list[int]]]:
|
|
"""Group by effective_date's year/month so migrated content surfaces
|
|
in the publish-date buckets, not the FC-scan-date bucket."""
|
|
groups: list[tuple[int, int, list[int]]] = []
|
|
for img in images:
|
|
y, m = img.effective_date.year, img.effective_date.month
|
|
if groups and groups[-1][0] == y and groups[-1][1] == m:
|
|
groups[-1][2].append(img.id)
|
|
else:
|
|
groups.append((y, m, [img.id]))
|
|
return groups
|