feat(provenance): capture which archive an extracted image came from (#87)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Failing after 3m20s

Images pulled out of a .zip/.rar previously kept no record of WHICH archive
they came from — the member->archive link was computed during extraction and
discarded, leaving only image->post. So the provenance modal could only scope
attachments to the whole post, showing every archive a 'High Resolution Files'
bundle carried instead of the one a given file lives in.

- ImageProvenance.from_attachment_id: nullable FK -> post_attachment.id
  (SET NULL), migration 0055.
- importer: _import_archive stamps from_attachment_id on every member's
  provenance row for the post (new + superseded + deduped members), resolving
  the archive's own PostAttachment by (post, sha). Post-pass UPDATE, NULL-only
  and idempotent, so it doesn't touch the dedup/supersede branches and the
  backfill is safe to re-run. Nested members link to the outer stored archive.
- provenance_service.for_image: when the originating post's provenance row
  records from_attachment_id, return ONLY that archive; else fall back to the
  primary-post scoping from 068def2.
- ProvenancePanel: heading pluralizes ('Attachment' for a single file).
- Backfill: re-running reextract_archive_attachments (ArchiveReextractCard)
  routes through _import_archive and stamps existing rows — no new code.

Tests: capture stamps on fresh import, nested-archive attribution, per-post
archive on dedup; for_image filters to the containing archive; reextract
backfill stamps the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:22:03 -04:00
parent 068def2f24
commit 5269cd0709
8 changed files with 273 additions and 19 deletions
@@ -0,0 +1,55 @@
"""image_provenance: from_attachment_id (which archive an image was extracted from)
Milestone #87. When an image is pulled out of a .zip/.rar, record WHICH archive
PostAttachment it came from, so the provenance UI can show the single archive a
file lives inside instead of every attachment on the post. Nullable FK with
ON DELETE SET NULL — a loose (non-archive) download leaves it NULL, and deleting
the archive attachment forgets the linkage without destroying the (image, post)
provenance edge. Existing rows are NULL until the reextract backfill stamps them.
Revision ID: 0055
Revises: 0054
Create Date: 2026-06-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0055"
down_revision: Union[str, None] = "0054"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"image_provenance",
sa.Column("from_attachment_id", sa.Integer(), nullable=True),
)
op.create_index(
"ix_image_provenance_from_attachment_id",
"image_provenance",
["from_attachment_id"],
)
op.create_foreign_key(
"fk_image_provenance_from_attachment",
"image_provenance",
"post_attachment",
["from_attachment_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint(
"fk_image_provenance_from_attachment",
"image_provenance",
type_="foreignkey",
)
op.drop_index(
"ix_image_provenance_from_attachment_id",
table_name="image_provenance",
)
op.drop_column("image_provenance", "from_attachment_id")
+10
View File
@@ -41,6 +41,16 @@ class ImageProvenance(Base):
source_id: Mapped[int | None] = mapped_column( source_id: Mapped[int | None] = mapped_column(
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
) )
# The archive PostAttachment this image was extracted FROM, when it came
# out of a .zip/.rar rather than as a loose file (milestone #87). Lets the
# provenance UI show the exact archive a file lives inside instead of every
# attachment on the post. NULL for loose downloads and pre-backfill rows.
# SET NULL so deleting the archive attachment never destroys the (image,
# post) edge — it just forgets which archive it came from.
from_attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"),
nullable=True, index=True,
)
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True) captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
captured_at: Mapped[datetime] = mapped_column( captured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
+56 -1
View File
@@ -17,7 +17,7 @@ from enum import StrEnum
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -506,6 +506,12 @@ class Importer:
artist_use = artist if artist is not None else self._resolve_artist(source) artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use) post = self._post_for_sidecar(source, artist_use)
member_ids: list[int] = [] member_ids: list[int] = []
# Every member image touched (new + superseded + deduped), so the
# from_attachment_id stamp below covers files that already existed in the
# library and were merely re-linked to this post — those matter most
# (the HR copy a bundle re-ships). Separate from member_ids, which is
# the NEWLY-imported subset feeding the ImportResult contract.
member_record_ids: set[int] = set()
# Per-outcome tally so the "no images" reason names the ACTUAL cause # Per-outcome tally so the "no images" reason names the ACTUAL cause
# (#718): nested-archive packs, all-deduped (benign), unsupported formats, # (#718): nested-archive packs, all-deduped (benign), unsupported formats,
# or failed/corrupt members — instead of one catch-all string. # or failed/corrupt members — instead of one catch-all string.
@@ -516,11 +522,19 @@ class Importer:
self._collect_archive_members( self._collect_archive_members(
source, attribution=source, source_row=source_row, source, attribution=source, source_row=source_row,
depth=0, member_ids=member_ids, counts=counts, depth=0, member_ids=member_ids, counts=counts,
member_record_ids=member_record_ids,
) )
# Preserve the archive itself (links to the same Post/Artist). # Preserve the archive itself (links to the same Post/Artist).
self._capture_attachment( self._capture_attachment(
source, post=post, artist=artist_use, resolved=True source, post=post, artist=artist_use, resolved=True
) )
# Stamp each member's provenance row for THIS post with the archive it
# came out of (milestone #87). Done as a post-pass rather than threaded
# through _import_media/_apply_sidecar so the many dedup/supersede
# branches stay untouched. NULL-only so a re-extract never re-stamps and
# the backfill (reextract task → this same path) is idempotent. Nested
# members link to this OUTER archive — the only one stored as a blob.
self._stamp_member_archive(post.id, source, member_record_ids)
if member_ids: if member_ids:
return ImportResult( return ImportResult(
status="imported", image_id=member_ids[0], status="imported", image_id=member_ids[0],
@@ -555,6 +569,7 @@ class Importer:
self, archive_path: Path, *, attribution: Path, self, archive_path: Path, *, attribution: Path,
source_row: Source | None, depth: int, source_row: Source | None, depth: int,
member_ids: list[int], counts: dict, member_ids: list[int], counts: dict,
member_record_ids: set[int],
) -> None: ) -> None:
"""Extract `archive_path` and import its image/video members, RECURSING """Extract `archive_path` and import its image/video members, RECURSING
into nested archives (#718). Members attribute to `attribution` — the into nested archives (#718). Members attribute to `attribution` — the
@@ -590,6 +605,7 @@ class Importer:
member_path, attribution=attribution, member_path, attribution=attribution,
source_row=source_row, depth=depth + 1, source_row=source_row, depth=depth + 1,
member_ids=member_ids, counts=counts, member_ids=member_ids, counts=counts,
member_record_ids=member_record_ids,
) )
continue continue
counts["media"] += 1 counts["media"] += 1
@@ -601,10 +617,16 @@ class Importer:
) )
if res.status in ("imported", "superseded") and res.image_id: if res.status in ("imported", "superseded") and res.image_id:
member_ids.append(res.image_id) member_ids.append(res.image_id)
member_record_ids.add(res.image_id)
elif res.status == "skipped" and res.skip_reason in ( elif res.status == "skipped" and res.skip_reason in (
SkipReason.duplicate_hash, SkipReason.duplicate_phash SkipReason.duplicate_hash, SkipReason.duplicate_phash
): ):
counts["deduped"] += 1 counts["deduped"] += 1
# A deduped member still links provenance to this post
# (enrich-on-duplicate); record it so its archive origin
# gets stamped too.
if res.image_id:
member_record_ids.add(res.image_id)
else: else:
counts["failed"] += 1 counts["failed"] += 1
except Exception as exc: # noqa: BLE001 — defensive per level; keep going except Exception as exc: # noqa: BLE001 — defensive per level; keep going
@@ -613,6 +635,39 @@ class Importer:
archive_path.name, depth, exc, archive_path.name, depth, exc,
) )
def _stamp_member_archive(
self, post_id: int | None, archive_source: Path, member_record_ids: set[int],
) -> None:
"""Record which archive each extracted member came from (milestone #87).
Resolves the archive's own PostAttachment (by post + sha — it was just
captured) and stamps from_attachment_id on every member's provenance row
FOR THIS POST. NULL-only, so re-extracting the same archive (the backfill
path) never overwrites and stays idempotent. No-op when the archive isn't
post-attached (filesystem import with no post) or yielded no members.
"""
if post_id is None or not member_record_ids:
return
sha = _sha256_of(archive_source)
att_id = self.session.execute(
select(PostAttachment.id).where(
PostAttachment.post_id == post_id,
PostAttachment.sha256 == sha,
)
).scalar_one_or_none()
if att_id is None:
return
self.session.execute(
update(ImageProvenance)
.where(
ImageProvenance.image_record_id.in_(member_record_ids),
ImageProvenance.post_id == post_id,
ImageProvenance.from_attachment_id.is_(None),
)
.values(from_attachment_id=att_id)
)
self.session.commit()
@staticmethod @staticmethod
def _video_aspect_matches(w, h, cw, ch) -> bool: def _video_aspect_matches(w, h, cw, ch) -> bool:
"""True when two (w,h) pairs share an aspect ratio within tolerance. """True when two (w,h) pairs share an aspect ratio within tolerance.
+30 -15
View File
@@ -66,6 +66,10 @@ class ProvenanceService:
).scalars().all() ).scalars().all()
return [_attachment_dict(a) for a in rows] return [_attachment_dict(a) for a in rows]
async def _attachment_by_id(self, attachment_id: int) -> list[dict]:
att = await self.session.get(PostAttachment, attachment_id)
return [_attachment_dict(att)] if att is not None else []
async def for_image(self, image_id: int) -> dict | None: async def for_image(self, image_id: int) -> dict | None:
rec = await self.session.get(ImageRecord, image_id) rec = await self.session.get(ImageRecord, image_id)
if rec is None: if rec is None:
@@ -85,22 +89,33 @@ class ProvenanceService:
) )
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
post_ids = [ip.post_id for ip, _p, _s, _a in rows] post_ids = [ip.post_id for ip, _p, _s, _a in rows]
# Scope attachments to the image's ORIGINATING post only, not every # Prefer the EXACT archive this file came out of (milestone #87): if the
# post it's pHash-linked to. An image dupe gets a provenance row per # originating post's provenance row records from_attachment_id, the image
# post it reappears in (enrich-on-duplicate), and one of those is # was extracted from that one .zip/.rar, so show only it — not the dozens
# often a "High Resolution Files" mega-bundle carrying dozens of # of unrelated archives a "High Resolution Files" bundle post carries.
# unrelated archives — aggregating across all linked posts ballooned from_att_id = next(
# the panel with files that have nothing to do with this image. (
# primary_post_id is the post this file was actually captured from; ip.from_attachment_id
# fall back to all linked posts only when it's unset (older rows / for ip, _p, _s, _a in rows
# filesystem imports). NB: FC stores archives as opaque blobs and if ip.post_id == rec.primary_post_id
# never records which archive an extracted image came from, so we and ip.from_attachment_id is not None
# cannot scope tighter than the post — see milestone for the ),
# image->archive capture work. None,
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) if from_att_id is not None:
attachments = await self._attachment_by_id(from_att_id)
else:
# No recorded containing archive (loose download, or pre-backfill):
# scope to the originating post only, not every pHash-linked post.
# primary_post_id is the post this file was actually captured from;
# fall back to all linked posts when it's unset (older rows /
# filesystem imports).
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 { return {
"image_id": image_id, "image_id": image_id,
"provenance": [ "provenance": [
@@ -70,7 +70,9 @@
</div> </div>
<div v-if="attachments.length" class="fc-prov__attach"> <div v-if="attachments.length" class="fc-prov__attach">
<h4 class="fc-prov__attach-title">Attachments ({{ attachments.length }})</h4> <h4 class="fc-prov__attach-title">
{{ attachments.length === 1 ? 'Attachment' : `Attachments (${attachments.length})` }}
</h4>
<!-- Scroll-capped: a single post can carry dozens of archives (HR <!-- Scroll-capped: a single post can carry dozens of archives (HR
bundle posts), which previously ballooned the panel past the bundle posts), which previously ballooned the panel past the
viewport. Mirror the cards' independent-scroll treatment. --> viewport. Mirror the cards' independent-scroll treatment. -->
+80
View File
@@ -164,6 +164,86 @@ def test_archive_all_deduped_is_benign_not_flagged(importer, import_layout):
assert provs == 4 # 2 images × 2 posts (enrich-on-duplicate) assert provs == 4 # 2 images × 2 posts (enrich-on-duplicate)
def test_archive_members_record_containing_archive(importer, import_layout):
"""Milestone #87: each extracted member's provenance row records the archive
PostAttachment it came out of, so provenance can show the one archive a file
lives inside."""
import_root, _ = import_layout
importer.settings.phash_threshold = 0
arc = import_root / "Bob" / "set.cbz"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("a.jpg", _split_bytes("v"))
zf.writestr("b.jpg", _split_bytes("h"))
arc.with_suffix(".cbz.json").write_text(json.dumps(
{"category": "patreon", "id": "777", "title": "Set"}))
importer.import_one(arc)
att = importer.session.execute(select(PostAttachment)).scalar_one()
provs = importer.session.execute(select(ImageProvenance)).scalars().all()
assert len(provs) == 2
assert all(p.from_attachment_id == att.id for p in provs)
def test_nested_archive_member_records_outer_archive(importer, import_layout):
"""Nested members link to the OUTER stored archive — the only one persisted
as a PostAttachment (the inner archive lives in a tempdir)."""
import_root, _ = import_layout
importer.settings.phash_threshold = 0
inner = io.BytesIO()
with zipfile.ZipFile(inner, "w") as zf:
zf.writestr("p1.jpg", _split_bytes("v"))
zf.writestr("p2.jpg", _split_bytes("h"))
arc = import_root / "Nessie" / "hr.cbz"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("chapter.zip", inner.getvalue())
arc.with_suffix(".cbz.json").write_text(json.dumps(
{"category": "patreon", "id": "900", "title": "HR Pack"}))
importer.import_one(arc)
att = importer.session.execute(select(PostAttachment)).scalar_one()
provs = importer.session.execute(select(ImageProvenance)).scalars().all()
assert len(provs) == 2
assert all(p.from_attachment_id == att.id for p in provs)
def test_deduped_archive_member_records_each_posts_archive(importer, import_layout):
"""A member re-shipped in a second post's archive gets its NEW provenance row
(for the second post) stamped with the SECOND archive — the UPDATE-of-existing
path the reextract backfill relies on. Each post's rows point at its own
archive."""
import_root, _ = import_layout
importer.settings.phash_threshold = 0
pv, ph = _split_bytes("v"), _split_bytes("h")
first = import_root / "Dup" / "a.cbz"
first.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(first, "w") as zf:
zf.writestr("a.jpg", pv)
zf.writestr("b.jpg", ph)
first.with_suffix(".cbz.json").write_text(json.dumps(
{"category": "patreon", "id": "111", "title": "First"}))
importer.import_one(first)
second = import_root / "Dup" / "b.cbz" # different post, same two images
with zipfile.ZipFile(second, "w") as zf:
zf.writestr("a.jpg", pv)
zf.writestr("b.jpg", ph)
second.with_suffix(".cbz.json").write_text(json.dumps(
{"category": "patreon", "id": "222", "title": "Second"}))
importer.import_one(second)
posts = {p.external_post_id: p.id for p in
importer.session.execute(select(Post)).scalars().all()}
atts = {a.post_id: a.id for a in
importer.session.execute(select(PostAttachment)).scalars().all()}
provs = importer.session.execute(select(ImageProvenance)).scalars().all()
assert len(provs) == 4 # 2 images × 2 posts
for p in provs:
assert p.from_attachment_id == atts[p.post_id]
assert atts[posts["111"]] != atts[posts["222"]]
def test_corrupt_archive_still_stored(importer, import_layout): def test_corrupt_archive_still_stored(importer, import_layout):
import_root, _ = import_layout import_root, _ = import_layout
arc = import_root / "Bob" / "broken.zip" arc = import_root / "Bob" / "broken.zip"
+31
View File
@@ -165,6 +165,37 @@ async def test_for_image_attachments_scoped_to_primary_post(db):
assert names == ["keep.zip"] assert names == ["keep.zip"]
@pytest.mark.asyncio
async def test_for_image_attachments_filtered_to_containing_archive(db):
# Milestone #87: when the originating post's provenance row records which
# archive the file came from, show ONLY that archive — not the post's other
# attachments.
rec = await _seed_image(db)
a1, s1, post = await _seed_post(db, artist_name="Arc", slug="arc",
platform="patreon", ext_id="500")
rec.primary_post_id = post.id
att_in = PostAttachment(
post_id=post.id, artist_id=a1.id, sha256="e" + "0" * 63,
path="/images/attachments/e00/in.cbz", original_filename="in.cbz",
ext=".cbz", mime="application/zip", size_bytes=9,
)
att_other = PostAttachment(
post_id=post.id, artist_id=a1.id, sha256="f" + "0" * 63,
path="/images/attachments/f00/other.rar", original_filename="other.rar",
ext=".rar", mime="application/x-rar", size_bytes=9,
)
db.add(att_in)
db.add(att_other)
await db.flush()
db.add(ImageProvenance(image_record_id=rec.id, post_id=post.id,
source_id=s1.id, from_attachment_id=att_in.id))
await db.flush()
payload = await ProvenanceService(db).for_image(rec.id)
names = [a["original_filename"] for a in payload["attachments"]]
assert names == ["in.cbz"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_for_image_attachments_fallback_to_all_posts_when_no_primary(db): async def test_for_image_attachments_fallback_to_all_posts_when_no_primary(db):
# No primary_post_id (older rows / filesystem imports) → preserve the # No primary_post_id (older rows / filesystem imports) → preserve the
+8 -2
View File
@@ -74,10 +74,13 @@ def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch)
with zipfile.ZipFile(arc, "w") as zf: with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("inside.jpg", _jpeg("red")) zf.writestr("inside.jpg", _jpeg("red"))
sha = hashlib.sha256(arc.read_bytes()).hexdigest() sha = hashlib.sha256(arc.read_bytes()).hexdigest()
db_sync.add(PostAttachment( att = PostAttachment(
post_id=post.id, artist_id=artist.id, sha256=sha, path=str(arc), post_id=post.id, artist_id=artist.id, sha256=sha, path=str(arc),
original_filename=arc.name, ext="", size_bytes=arc.stat().st_size, original_filename=arc.name, ext="", size_bytes=arc.stat().st_size,
)) )
db_sync.add(att)
db_sync.flush()
att_id = att.id
db_sync.commit() db_sync.commit()
summary = cleanup_service.reextract_archive_attachments( summary = cleanup_service.reextract_archive_attachments(
@@ -94,6 +97,9 @@ def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch)
).scalars().all() ).scalars().all()
assert len(prov) == 1 assert len(prov) == 1
assert prov[0].image_record_id == images[0].id assert prov[0].image_record_id == images[0].id
# Milestone #87: re-extraction stamps which archive the member came from —
# this is the backfill path for pre-existing opaque archives.
assert prov[0].from_attachment_id == att_id
# Idempotent — a second run imports nothing new (member dedups by sha256). # Idempotent — a second run imports nothing new (member dedups by sha256).
again = cleanup_service.reextract_archive_attachments( again = cleanup_service.reextract_archive_attachments(