From 42c33e44f9b08a867fa1bbb59946826be9592fb3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:47:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(post-api):=20get=5Fpost=20returns=20uncapp?= =?UTF-8?q?ed=20thumbnails=20=E2=80=94=20PostModal=20masonry=20needs=20ful?= =?UTF-8?q?l=20image=20list;=20feed=20query=20unchanged=20(still=20capped?= =?UTF-8?q?=20at=206=20for=20previews).=20=5Fthumbnails=5Ffor=20gains=20a?= =?UTF-8?q?=20`limit`=20kwarg;=20get=5Fpost=20passes=20limit=3DNone.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/post_feed_service.py | 34 ++++++++++------ tests/test_api_posts.py | 47 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index d137188..974427e 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -109,7 +109,10 @@ class PostFeedService: if row is None: return None post, artist, source = row - thumbs_map = await self._thumbnails_for([post.id]) + # Detail endpoint returns the FULL image list for PostModal's + # masonry grid — feed query still caps at THUMBNAIL_LIMIT via + # the default arg. + thumbs_map = await self._thumbnails_for([post.id], limit=None) atts_map = await self._attachments_for([post.id]) item = self._to_dict(post, artist, source, thumbs_map, atts_map) item["description_full"] = html_to_plain(post.description) @@ -117,15 +120,21 @@ class PostFeedService: # --- composition helpers --------------------------------------------- - async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]: - """post_id -> {"thumbs": [...up to 6], "more": int}. + async def _thumbnails_for( + self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT, + ) -> dict[int, dict]: + """post_id -> {"thumbs": [...up to limit], "more": int}. - Selects THUMBNAIL_LIMIT+1 images per post via window function so we - can detect overflow in a single query. + Selects up to `limit` images per post via window function so we + can detect overflow in a single query. Pass `limit=None` to + return ALL thumbnails per post (used by `get_post` for PostModal's + masonry grid; the feed pass keeps the default cap so payloads + stay small). """ if not post_ids: return {} - # Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1. + # Rank images within each post; cap at `limit` rows per post when + # limit is set, return all when limit is None. ranked = ( select( ImageRecord.id, @@ -143,12 +152,13 @@ class PostFeedService: .where(ImageRecord.primary_post_id.in_(post_ids)) .subquery() ) - rows = (await self.session.execute( - select( - ranked.c.id, ranked.c.primary_post_id, - ranked.c.sha256, ranked.c.mime, ranked.c.total, - ).where(ranked.c.rn <= THUMBNAIL_LIMIT) - )).all() + stmt = select( + ranked.c.id, ranked.c.primary_post_id, + ranked.c.sha256, ranked.c.mime, 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: diff --git a/tests/test_api_posts.py b/tests/test_api_posts.py index a820ad8..997bfa7 100644 --- a/tests/test_api_posts.py +++ b/tests/test_api_posts.py @@ -117,3 +117,50 @@ async def test_detail_404_for_unknown(client): assert resp.status_code == 404 body = await resp.get_json() assert body["error"] == "not_found" + + +@pytest.mark.asyncio +async def test_detail_returns_uncapped_thumbnails(client, db): + """Feed query caps thumbnails at 6 for previews; detail endpoint + returns the full list so PostModal can render the masonry grid.""" + from backend.app.models import ImageRecord + + a = Artist(name="yuki-api", slug="yuki-api") + db.add(a) + await db.flush() + s = Source( + artist_id=a.id, platform="patreon", + url="https://patreon.com/cw/yuki-api", enabled=True, + ) + db.add(s) + await db.flush() + p = Post( + source_id=s.id, external_post_id="DETAIL10", + post_title="big post", description="

body

", + ) + db.add(p) + await db.flush() + # Seed 10 ImageRecord rows linked to this post via primary_post_id. + for i in range(10): + sha = f"y{i:x}".ljust(64, "0")[:64] + rec = ImageRecord( + path=f"/images/test-yuki-{i}.jpg", + sha256=sha, + size_bytes=1, + mime="image/jpeg", + width=64, + height=64, + origin="downloaded", + integrity_status="unknown", + primary_post_id=p.id, + artist_id=a.id, + ) + db.add(rec) + await db.commit() + + resp = await client.get(f"/api/posts/{p.id}") + assert resp.status_code == 200 + body = await resp.get_json() + # Detail returns ALL 10 thumbnails (feed would return 6 + thumbnails_more). + assert len(body["thumbnails"]) == 10 + assert body["description_full"] == "body"