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