From 44bb12a93db6971c8a4b379bd671efb22e120efe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 30 May 2026 16:55:38 -0400 Subject: [PATCH] fix(thumbnails): derive URL from stored thumbnail_path, not (sha256, mime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/services/artist_directory_service.py | 9 ++++-- backend/app/services/artist_service.py | 2 +- backend/app/services/gallery_service.py | 28 +++++++++++++++---- backend/app/services/post_feed_service.py | 7 +++-- backend/app/services/series_service.py | 3 +- backend/app/services/showcase_service.py | 2 +- backend/app/services/tag_directory_service.py | 11 ++++++-- 7 files changed, 45 insertions(+), 17 deletions(-) diff --git a/backend/app/services/artist_directory_service.py b/backend/app/services/artist_directory_service.py index a16c1aa..eda9afc 100644 --- a/backend/app/services/artist_directory_service.py +++ b/backend/app/services/artist_directory_service.py @@ -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 diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index c6aff2b..56f19ff 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -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 ], diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 0a46fec..095890d 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -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} diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index 319d645..634cfd3 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -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. diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index 6ed3df8..361caa5 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -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 diff --git a/backend/app/services/showcase_service.py b/backend/app/services/showcase_service.py index b45e0b5..f34051b 100644 --- a/backend/app/services/showcase_service.py +++ b/backend/app/services/showcase_service.py @@ -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 ] diff --git a/backend/app/services/tag_directory_service.py b/backend/app/services/tag_directory_service.py index a0ffaaa..c88b04c 100644 --- a/backend/app/services/tag_directory_service.py +++ b/backend/app/services/tag_directory_service.py @@ -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