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)