fix(thumbnails): derive URL from stored thumbnail_path, not (sha256, mime)

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>
This commit is contained in:
2026-05-30 16:55:38 -04:00
parent 1eefed9ab3
commit 44bb12a93d
7 changed files with 45 additions and 17 deletions
@@ -127,17 +127,20 @@ class ArtistDirectoryService:
ImageRecord.artist_id.label("artist_id"),
ImageRecord.sha256.label("sha256"),
ImageRecord.mime.label("mime"),
ImageRecord.thumbnail_path.label("thumbnail_path"),
rn,
)
.where(ImageRecord.artist_id.in_(artist_ids))
.subquery()
)
stmt = (
select(sub.c.artist_id, sub.c.sha256, sub.c.mime)
select(
sub.c.artist_id, sub.c.sha256, sub.c.mime, sub.c.thumbnail_path,
)
.where(sub.c.rn <= _PREVIEW_COUNT)
.order_by(sub.c.artist_id, sub.c.rn)
)
out: dict[int, list[str]] = {}
for aid, sha, mime in (await self.session.execute(stmt)).all():
out.setdefault(aid, []).append(thumbnail_url(sha, mime))
for aid, sha, mime, tp in (await self.session.execute(stmt)).all():
out.setdefault(aid, []).append(thumbnail_url(tp, sha, mime))
return out
+1 -1
View File
@@ -198,7 +198,7 @@ class ArtistService:
"mime": r.mime,
"width": r.width,
"height": r.height,
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
}
for r in rows
],
+23 -5
View File
@@ -90,9 +90,27 @@ class TimelineBucket:
count: int
def thumbnail_url(sha256_hex: str, mime: str) -> str:
# Quart serves /images/* via the frontend blueprint (FC-1); thumbnails go
# under /images/thumbs/. The MIME determines the extension.
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}"
@@ -198,7 +216,7 @@ class GalleryService:
created_at=record.created_at,
effective_date=eff_date,
posted_at=posted_at,
thumbnail_url=thumbnail_url(record.sha256, record.mime),
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
artist=artists.get(record.id),
)
for record, posted_at, eff_date in rows
@@ -306,7 +324,7 @@ class GalleryService:
"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.sha256, record.mime),
"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}
+4 -3
View File
@@ -207,6 +207,7 @@ class PostFeedService:
ImageRecord.primary_post_id,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.thumbnail_path,
func.row_number().over(
partition_by=ImageRecord.primary_post_id,
order_by=ImageRecord.id.asc(),
@@ -220,18 +221,18 @@ class PostFeedService:
)
stmt = select(
ranked.c.id, ranked.c.primary_post_id,
ranked.c.sha256, ranked.c.mime, ranked.c.total,
ranked.c.sha256, ranked.c.mime, ranked.c.thumbnail_path, ranked.c.total,
)
if limit is not None:
stmt = stmt.where(ranked.c.rn <= limit)
rows = (await self.session.execute(stmt)).all()
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
for img_id, pid, sha, mime, total in rows:
for img_id, pid, sha, mime, tp, total in rows:
entry = out.setdefault(pid, {"thumbs": [], "more": 0})
entry["thumbs"].append({
"image_id": img_id,
"thumbnail_url": thumbnail_url(sha, mime),
"thumbnail_url": thumbnail_url(tp, sha, mime),
"mime": mime,
})
# `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
+2 -1
View File
@@ -63,6 +63,7 @@ class SeriesService:
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.path,
ImageRecord.thumbnail_path,
)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
@@ -75,7 +76,7 @@ class SeriesService:
{
"image_id": r.image_id,
"page_number": r.page_number,
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
"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
+1 -1
View File
@@ -34,7 +34,7 @@ class ShowcaseService:
"mime": r.mime,
"width": r.width,
"height": r.height,
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
}
for r in rows
]
@@ -115,12 +115,17 @@ class TagDirectoryService:
.subquery()
)
stmt = (
select(sub.c.tag_id, ImageRecord.sha256, ImageRecord.mime)
select(
sub.c.tag_id,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.thumbnail_path,
)
.join(ImageRecord, ImageRecord.id == sub.c.image_record_id)
.where(sub.c.rn <= 3)
.order_by(sub.c.tag_id, sub.c.rn)
)
out: dict[int, list[str]] = {}
for tag_id, sha, mime in (await self.session.execute(stmt)).all():
out.setdefault(tag_id, []).append(thumbnail_url(sha, mime))
for tag_id, sha, mime, tp in (await self.session.execute(stmt)).all():
out.setdefault(tag_id, []).append(thumbnail_url(tp, sha, mime))
return out