44bb12a93d
The showcase/gallery/artist/series/post-feed APIs were constructing
thumbnail URLs from (sha256, mime). The MIME-based extension predicate
("png if image/png or image/gif else jpg") DISAGREED with the
thumbnailer's actual on-disk extension predicate ("png if alpha else
jpg"). Result: every PNG source without transparency 404'd (URL asked
.png, disk had .jpg); every WebP/AVIF source with transparency 404'd
(URL asked .jpg, disk had .png) — despite the thumbnail file existing
on disk.
The backfill task couldn't catch these because backfill checks the
ACTUAL thumbnail_path stored on the record (correct), not the URL the
browser fetches (broken derivation). So records with valid on-disk
thumbnails kept showing as broken in the UI no matter how many times
backfill ran.
Operator-flagged 2026-05-30: "the generate thumbnails function appears
to not catch all of the failed thumbnail cases" — turned out to not be
a backfill bug at all.
Fix: thumbnail_url now takes (thumbnail_path, sha256, mime) and returns
the stored path verbatim — Quart serves /images/* 1:1 from the volume
(frontend.py:20-36), so the URL IS the disk path. Falls back to the old
sha256+mime derivation only when thumbnail_path is NULL (thumbnailer
hasn't run yet); that URL will 404 in the browser until backfill catches
it, same as before the path was tracked.
All 8 callers updated: showcase_service, gallery_service (2 sites),
artist_service, series_service, post_feed_service, tag_directory_service,
artist_directory_service. The four sites whose query was raw-tuple now
also SELECT ImageRecord.thumbnail_path.
Net effect: every record that has a valid on-disk thumbnail will now
render correctly, regardless of which extension the thumbnailer chose,
without any DB migration or backfill rerun needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
6.1 KiB
Python
173 lines
6.1 KiB
Python
"""Series = a series-kind Tag + ordered series_page membership.
|
|
|
|
All mutations are Core/set-based and run in the request transaction.
|
|
"""
|
|
|
|
from sqlalchemy import and_, func, select, update
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import ImageRecord, Tag, TagKind
|
|
from ..models.series_page import SeriesPage
|
|
from .gallery_service import thumbnail_url
|
|
|
|
|
|
class SeriesError(ValueError):
|
|
"""Series validation failure. The API maps 'not a series'/'not found'
|
|
to 404 and everything else to 400."""
|
|
|
|
|
|
class SeriesService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def _require_series(self, series_tag_id: int) -> Tag:
|
|
tag = await self.session.get(Tag, series_tag_id)
|
|
if tag is None:
|
|
raise SeriesError(f"Tag {series_tag_id} not found")
|
|
if tag.kind != TagKind.series:
|
|
raise SeriesError(f"Tag {series_tag_id} is not a series")
|
|
return tag
|
|
|
|
@staticmethod
|
|
def _clean_ids(ids: list[int]) -> list[int]:
|
|
if not ids:
|
|
raise SeriesError("image_ids must be a non-empty list")
|
|
if len(ids) > 500:
|
|
raise SeriesError("selection too large (max 500)")
|
|
seen: set[int] = set()
|
|
out: list[int] = []
|
|
for x in ids:
|
|
if x not in seen:
|
|
seen.add(x)
|
|
out.append(int(x))
|
|
return out
|
|
|
|
async def _member_order(self, series_tag_id: int) -> list[int]:
|
|
rows = (
|
|
await self.session.execute(
|
|
select(SeriesPage.image_id)
|
|
.where(SeriesPage.series_tag_id == series_tag_id)
|
|
.order_by(SeriesPage.page_number.asc())
|
|
)
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
async def list_pages(self, series_tag_id: int) -> dict:
|
|
tag = await self._require_series(series_tag_id)
|
|
rows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesPage.image_id,
|
|
SeriesPage.page_number,
|
|
ImageRecord.sha256,
|
|
ImageRecord.mime,
|
|
ImageRecord.path,
|
|
ImageRecord.thumbnail_path,
|
|
)
|
|
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
|
.where(SeriesPage.series_tag_id == series_tag_id)
|
|
.order_by(SeriesPage.page_number.asc())
|
|
)
|
|
).all()
|
|
return {
|
|
"series": {"id": tag.id, "name": tag.name},
|
|
"pages": [
|
|
{
|
|
"image_id": r.image_id,
|
|
"page_number": r.page_number,
|
|
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
|
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
|
|
}
|
|
for r in rows
|
|
],
|
|
}
|
|
|
|
async def add_images(
|
|
self, series_tag_id: int, image_ids: list[int]
|
|
) -> int:
|
|
await self._require_series(series_tag_id)
|
|
ids = self._clean_ids(image_ids)
|
|
existing = dict(
|
|
(
|
|
await self.session.execute(
|
|
select(SeriesPage.image_id, SeriesPage.series_tag_id)
|
|
.where(SeriesPage.image_id.in_(ids))
|
|
)
|
|
).all()
|
|
)
|
|
# Already in THIS series → leave untouched (no churn).
|
|
to_add = [i for i in ids if existing.get(i) != series_tag_id]
|
|
if not to_add:
|
|
return 0
|
|
# Remove any prior membership for the to_add images (the "move").
|
|
await self.session.execute(
|
|
SeriesPage.__table__.delete().where(
|
|
SeriesPage.image_id.in_(to_add)
|
|
)
|
|
)
|
|
max_pn = (
|
|
await self.session.scalar(
|
|
select(func.coalesce(func.max(SeriesPage.page_number), 0))
|
|
.where(SeriesPage.series_tag_id == series_tag_id)
|
|
)
|
|
) or 0
|
|
await self.session.execute(
|
|
pg_insert(SeriesPage).values(
|
|
[
|
|
{
|
|
"series_tag_id": series_tag_id,
|
|
"image_id": iid,
|
|
"page_number": max_pn + offset,
|
|
}
|
|
for offset, iid in enumerate(to_add, start=1)
|
|
]
|
|
)
|
|
)
|
|
return len(to_add)
|
|
|
|
async def remove_images(
|
|
self, series_tag_id: int, image_ids: list[int]
|
|
) -> int:
|
|
await self._require_series(series_tag_id)
|
|
ids = self._clean_ids(image_ids)
|
|
res = await self.session.execute(
|
|
SeriesPage.__table__.delete().where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.image_id.in_(ids),
|
|
)
|
|
)
|
|
)
|
|
return res.rowcount or 0
|
|
|
|
async def reorder(
|
|
self, series_tag_id: int, ordered_image_ids: list[int]
|
|
) -> None:
|
|
await self._require_series(series_tag_id)
|
|
ordered = self._clean_ids(ordered_image_ids)
|
|
current = set(await self._member_order(series_tag_id))
|
|
if set(ordered) != current or len(ordered) != len(current):
|
|
raise SeriesError(
|
|
"ordered image_ids must exactly match series membership"
|
|
)
|
|
for idx, iid in enumerate(ordered, start=1):
|
|
await self.session.execute(
|
|
update(SeriesPage)
|
|
.where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.image_id == iid,
|
|
)
|
|
)
|
|
.values(page_number=idx)
|
|
)
|
|
|
|
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
|
|
await self._require_series(series_tag_id)
|
|
order = await self._member_order(series_tag_id)
|
|
if image_id not in order:
|
|
raise SeriesError(f"image {image_id} is not in this series")
|
|
new_order = [image_id] + [i for i in order if i != image_id]
|
|
await self.reorder(series_tag_id, new_order)
|