2f66de2928
Operator-asked 2026-06-01 after the Dymkens orphan investigation (Scribe plan #540). The pre-2030 sidecar-synthetic Source pattern (`sidecar:<platform>:<slug>` enabled=false rows) existed solely to satisfy `Post.source_id NOT NULL`, and leaked into the Subscriptions UI as phantom subscriptions. Now the data model says what's true: filesystem-imported content with no live subscription has NULL source_id, full stop. ## Schema (alembic 0030) - `post.artist_id` — NEW NOT NULL FK to artist (CASCADE). Backfilled from source.artist_id in the migration. Indexed for the artist-filter queries. - `post.source_id` — NOT NULL → nullable; FK ondelete CASCADE → SET NULL. Deleting a Source detaches its Posts instead of destroying archived content (subscription ends, archive stays). - `image_provenance.source_id` — same nullable + SET NULL. - Partial unique index `uq_post_artist_external_id_null_source` on (artist_id, external_post_id) WHERE source_id IS NULL — guards filesystem-import dedup since the existing source-bound unique ignores NULLs (Postgres treats NULL != NULL). - Sidecar synthetic Sources deleted: NULL out FKs in post, image_provenance first, then DELETE FROM source WHERE url LIKE 'sidecar:%'. The Dymkens cleanup. ## Model + service changes - `Post.source_id` → `Mapped[int | None]`; new `Post.artist_id` denormalized. - `ImageProvenance.source_id` → `Mapped[int | None]`. - Importer: `_source_for_sidecar` (synthetic-creating) → `_lookup_source_for_sidecar` (returns None when no subscription). `_find_or_create_post` takes required `artist_id`; matches on (source_id, external_post_id) for source-bound posts or (artist_id, external_post_id) for NULL-source posts. - Service queries switched off the Source detour to use Post.artist_id directly: post_feed_service.scroll/around/get_post (LEFT JOIN to Source so NULL-source posts surface); artist_service date_row/ activity/post_count; provenance_service.for_image/for_post (LEFT JOIN); gallery_service._provenance_exists_where_artist via Post.artist_id instead of ImageProvenance.source_id → Source. - `_to_dict` and provenance dict-builders emit `"source": null` for NULL-source rows. ## Frontend - `ProvenancePanel.vue` + `PostCard.vue`: render `e.source?.platform ?? 'filesystem import'` so NULL-source posts get a clear "filesystem import" affordance instead of a NaN crash. ## Tests - `test_importer_upsert_helpers`: removed the four synthetic-anchor tests; added `_find_or_create_post_idempotent_with_null_source` (dedup via the partial unique index) and `_lookup_source_for_sidecar_returns_*` (existing-subscription + none cases). The existing `_find_or_create_post_idempotent` now also passes `artist_id` and asserts it. - 8 other test files updated: every direct `Post(...)` construction gains `artist_id=<artist>.id`. The `_seed_post` helper in `test_post_feed_service` looks up artist_id from the source row so callsites stay one-arg. ## Verification on deploy After alembic 0030 runs: - `SELECT COUNT(*) FROM source WHERE url LIKE 'sidecar:%'` → 0. - `SELECT COUNT(*) FROM post WHERE source_id IS NULL` → count of filesystem-imported posts (Dymkens + any other historical). - Every `post.artist_id` non-null; consistent with source.artist_id for source-bound rows. - Subscriptions tab: no Dymkens phantom row. - Artist detail → Posts/Gallery: Dymkens's content still reachable via Post.artist_id. - Provenance panel renders "filesystem import" chip for NULL-source posts; PostCard same. ## Out of scope - UI to manage/delete orphan NULL-source Posts. Data model is right; UI follows if operator wants it.
405 lines
16 KiB
Python
405 lines
16 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.
|
|
"""
|
|
|
|
import base64
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import Select, and_, exists, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
|
from ..models.tag import image_tag
|
|
|
|
CURSOR_SEPARATOR = "|"
|
|
|
|
|
|
def encode_cursor(effective_date: datetime, image_id: int) -> str:
|
|
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
|
return base64.urlsafe_b64encode(raw.encode()).decode()
|
|
|
|
|
|
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|
try:
|
|
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
|
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
|
|
return datetime.fromisoformat(ts_part), int(id_part)
|
|
except Exception as exc:
|
|
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|
|
|
|
|
|
def _effective_date_col():
|
|
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
|
|
|
|
Used as the canonical sort/group/filter key across the gallery so
|
|
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
|
|
surface at their original publish date, not their FC import date.
|
|
Images without a Post (or with Post.post_date NULL) fall back to
|
|
image_record.created_at and still order coherently against
|
|
post-attached ones.
|
|
"""
|
|
return func.coalesce(Post.post_date, ImageRecord.created_at)
|
|
|
|
|
|
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
|
|
|
|
|
|
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 _require_single_filter(tag_id, post_id, artist_id) -> None:
|
|
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
|
|
raise ValueError(
|
|
"tag_id, post_id, artist_id are mutually exclusive"
|
|
)
|
|
|
|
|
|
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.
|
|
return exists().where(
|
|
ImageProvenance.image_record_id == ImageRecord.id,
|
|
ImageProvenance.post_id == Post.id,
|
|
Post.artist_id == artist_id,
|
|
)
|
|
return None
|
|
|
|
|
|
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_id: int | None = None,
|
|
post_id: int | None = None,
|
|
artist_id: int | None = None,
|
|
) -> GalleryPage:
|
|
if limit < 1 or limit > 200:
|
|
raise ValueError("limit must be between 1 and 200")
|
|
_require_single_filter(tag_id, post_id, artist_id)
|
|
|
|
eff = _effective_date_col()
|
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
|
stmt = _outer_join_primary_post(stmt)
|
|
if tag_id is not None:
|
|
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
|
image_tag.c.tag_id == tag_id
|
|
)
|
|
prov = _provenance_clause(post_id, artist_id)
|
|
if prov is not None:
|
|
stmt = stmt.where(prov)
|
|
|
|
if cursor:
|
|
cur_ts, cur_id = decode_cursor(cursor)
|
|
stmt = stmt.where(
|
|
or_(
|
|
eff < cur_ts,
|
|
and_(eff == cur_ts, ImageRecord.id < cur_id),
|
|
)
|
|
)
|
|
|
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).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 = [
|
|
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
|
|
]
|
|
return GalleryPage(
|
|
images=images,
|
|
next_cursor=next_cursor,
|
|
date_groups=_group_by_year_month(images),
|
|
)
|
|
|
|
async def timeline(
|
|
self,
|
|
tag_id: int | None = None,
|
|
post_id: int | None = None,
|
|
artist_id: int | 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_id, post_id, artist_id)
|
|
if tag_id is not None:
|
|
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
|
image_tag.c.tag_id == tag_id
|
|
)
|
|
prov = _provenance_clause(post_id, artist_id)
|
|
if prov is not None:
|
|
stmt = stmt.where(prov)
|
|
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_id: int | None = None,
|
|
post_id: int | None = None, artist_id: int | None = None,
|
|
) -> str | None:
|
|
"""Returns a cursor that, when passed to scroll(), positions at the
|
|
first image of the given year-month (by effective_date, not
|
|
created_at). 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_id, post_id, artist_id)
|
|
if tag_id is not None:
|
|
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
|
image_tag.c.tag_id == tag_id
|
|
)
|
|
prov = _provenance_clause(post_id, artist_id)
|
|
if prov is not None:
|
|
stmt = stmt.where(prov)
|
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
|
first = (await self.session.execute(stmt)).first()
|
|
if first is None:
|
|
return None
|
|
record, eff_date = first
|
|
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
|
# is the first result in the next scroll().
|
|
return encode_cursor(eff_date, record.id + 1)
|
|
|
|
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
|
|
tag_stmt = (
|
|
select(Tag)
|
|
.join(image_tag, image_tag.c.tag_id == Tag.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)).scalars().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,
|
|
"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": f"/images/{record.path.split('/images/', 1)[-1]}",
|
|
"artist": (
|
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
|
if artist is not None else None
|
|
),
|
|
"tags": [
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
|
|
"fandom_id": t.fandom_id,
|
|
}
|
|
for t in tags
|
|
],
|
|
"neighbors": neighbors,
|
|
}
|
|
|
|
async def _neighbors(self, record: ImageRecord) -> dict:
|
|
# Compute the boundary image's effective_date in Python (one query
|
|
# below + the SELECT we already have on `record`) and use it for
|
|
# the neighbor comparison. Cheaper than re-deriving in SQL via
|
|
# correlated subquery.
|
|
boundary_eff = record.created_at
|
|
if record.primary_post_id is not None:
|
|
post_date = (await self.session.execute(
|
|
select(Post.post_date).where(Post.id == record.primary_post_id)
|
|
)).scalar_one_or_none()
|
|
if post_date is not None:
|
|
boundary_eff = post_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
|