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