From 068def2f24ea077a64b3c857f69d4f7dd5afd6e4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 21 Jun 2026 21:27:36 -0400 Subject: [PATCH] fix(provenance): scope attachments to originating post + scroll-cap the list The Attachments section aggregated PostAttachment rows across EVERY post an image was pHash-linked to. When one of those was a 'High Resolution Files' mega-bundle (dozens of unrelated archives), the list ballooned past the viewport and overwhelmed the modal's right rail. - for_image() now scopes attachments to ImageRecord.primary_post_id (the post the file was actually captured from), falling back to all linked posts only when primary_post_id is unset (older rows / filesystem imports). - ProvenancePanel wraps the list in a max-height scroll container with a count in the heading, mirroring the cards' independent-scroll treatment. Note: FC stores archives as opaque blobs and never records which archive an extracted image came from, so attachments can't yet be scoped tighter than the post. Capturing image->archive containment is tracked as separate work. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/provenance_service.py | 17 +++++- .../src/components/modal/ProvenancePanel.vue | 30 ++++++--- tests/test_provenance_service.py | 61 +++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/backend/app/services/provenance_service.py b/backend/app/services/provenance_service.py index 9c2739b..b0d4cd8 100644 --- a/backend/app/services/provenance_service.py +++ b/backend/app/services/provenance_service.py @@ -85,7 +85,22 @@ class ProvenanceService: ) rows = (await self.session.execute(stmt)).all() post_ids = [ip.post_id for ip, _p, _s, _a in rows] - attachments = await self._attachments_for_posts(post_ids) + # Scope attachments to the image's ORIGINATING post only, not every + # post it's pHash-linked to. An image dupe gets a provenance row per + # post it reappears in (enrich-on-duplicate), and one of those is + # often a "High Resolution Files" mega-bundle carrying dozens of + # unrelated archives — aggregating across all linked posts ballooned + # the panel with files that have nothing to do with this image. + # primary_post_id is the post this file was actually captured from; + # fall back to all linked posts only when it's unset (older rows / + # filesystem imports). NB: FC stores archives as opaque blobs and + # never records which archive an extracted image came from, so we + # cannot scope tighter than the post — see milestone for the + # image->archive capture work. + attach_post_ids = ( + [rec.primary_post_id] if rec.primary_post_id is not None else post_ids + ) + attachments = await self._attachments_for_posts(attach_post_ids) return { "image_id": image_id, "provenance": [ diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue index 51d16f2..228f7a6 100644 --- a/frontend/src/components/modal/ProvenancePanel.vue +++ b/frontend/src/components/modal/ProvenancePanel.vue @@ -70,14 +70,19 @@ @@ -229,6 +234,15 @@ function openPost(postId, artistId) { font-size: 13px; color: rgb(var(--v-theme-on-surface-variant)); margin: 8px 0 4px; } +.fc-prov__attach-list { + /* ~7 rows visible before scrolling; the clipped row hints at more. + Hairline scrollbar matching .fc-prov__cards. */ + max-height: 180px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgb(var(--v-theme-surface-light)) transparent; + padding-right: 4px; +} .fc-prov__attach-row { display: block; font-size: 13px; text-decoration: none; color: rgb(var(--v-theme-accent)); padding: 2px 0; diff --git a/tests/test_provenance_service.py b/tests/test_provenance_service.py index bd61081..3540271 100644 --- a/tests/test_provenance_service.py +++ b/tests/test_provenance_service.py @@ -134,6 +134,67 @@ async def test_for_image_null_post_fields_serialize_null(db): assert e["post"]["attachment_count"] is None +@pytest.mark.asyncio +async def test_for_image_attachments_scoped_to_primary_post(db): + # Image linked to TWO posts; only the primary (originating) post's + # attachments should surface — not the other post's mega-bundle. + rec = await _seed_image(db) + a1, s1, primary = await _seed_post(db, artist_name="P1", slug="p1", + platform="patreon", ext_id="100") + a2, s2, bundle = await _seed_post(db, artist_name="P2", slug="p2", + platform="patreon", ext_id="200") + rec.primary_post_id = primary.id + db.add(ImageProvenance(image_record_id=rec.id, post_id=primary.id, + source_id=s1.id)) + db.add(ImageProvenance(image_record_id=rec.id, post_id=bundle.id, + source_id=s2.id)) + db.add(PostAttachment( + post_id=primary.id, artist_id=a1.id, sha256="p" + "0" * 63, + path="/images/attachments/p00/keep.zip", original_filename="keep.zip", + ext=".zip", mime="application/zip", size_bytes=9, + )) + db.add(PostAttachment( + post_id=bundle.id, artist_id=a2.id, sha256="b" + "0" * 63, + path="/images/attachments/b00/drop.rar", original_filename="drop.rar", + ext=".rar", mime="application/x-rar", size_bytes=9, + )) + await db.flush() + + payload = await ProvenanceService(db).for_image(rec.id) + names = [a["original_filename"] for a in payload["attachments"]] + assert names == ["keep.zip"] + + +@pytest.mark.asyncio +async def test_for_image_attachments_fallback_to_all_posts_when_no_primary(db): + # No primary_post_id (older rows / filesystem imports) → preserve the + # aggregate-across-linked-posts behavior so attachments aren't lost. + rec = await _seed_image(db) + a1, s1, p1 = await _seed_post(db, artist_name="F1", slug="f1", + platform="patreon", ext_id="300") + a2, s2, p2 = await _seed_post(db, artist_name="F2", slug="f2", + platform="patreon", ext_id="400") + db.add(ImageProvenance(image_record_id=rec.id, post_id=p1.id, + source_id=s1.id)) + db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id, + source_id=s2.id)) + db.add(PostAttachment( + post_id=p1.id, artist_id=a1.id, sha256="c" + "0" * 63, + path="/images/attachments/c00/one.zip", original_filename="one.zip", + ext=".zip", mime="application/zip", size_bytes=9, + )) + db.add(PostAttachment( + post_id=p2.id, artist_id=a2.id, sha256="d" + "0" * 63, + path="/images/attachments/d00/two.zip", original_filename="two.zip", + ext=".zip", mime="application/zip", size_bytes=9, + )) + await db.flush() + + payload = await ProvenanceService(db).for_image(rec.id) + names = sorted(a["original_filename"] for a in payload["attachments"]) + assert names == ["one.zip", "two.zip"] + + @pytest.mark.asyncio async def test_for_post_missing_returns_none(db): svc = ProvenanceService(db)