diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 9979907..250b315 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -573,6 +573,29 @@ def _repoint_post_links(session: Session, loser_id: int, keeper_id: int) -> None dup_imgs = select(ImageProvenance.image_record_id).where( ImageProvenance.post_id == keeper_id ) + # Before dropping the colliding loser rows, carry their from_attachment_id + # (which archive the file came out of, milestone #87) onto the keeper's + # surviving row when the keeper didn't record one. For the gallery-dl→native + # case this very milestone targets, the keeper is the native stub (no + # archive) and the loser is the gallery-dl row that extracted the member, so + # a blind delete would silently lose the containing-archive linkage. + for img_id, att_id in session.execute( + select(ImageProvenance.image_record_id, ImageProvenance.from_attachment_id) + .where( + ImageProvenance.post_id == loser_id, + ImageProvenance.image_record_id.in_(dup_imgs), + ImageProvenance.from_attachment_id.is_not(None), + ) + ).all(): + session.execute( + update(ImageProvenance) + .where( + ImageProvenance.post_id == keeper_id, + ImageProvenance.image_record_id == img_id, + ImageProvenance.from_attachment_id.is_(None), + ) + .values(from_attachment_id=att_id) + ) session.execute( delete(ImageProvenance).where( ImageProvenance.post_id == loser_id, diff --git a/tests/test_cleanup_service.py b/tests/test_cleanup_service.py index f5419d7..af56753 100644 --- a/tests/test_cleanup_service.py +++ b/tests/test_cleanup_service.py @@ -853,3 +853,36 @@ def test_reconcile_dedups_provenance_collision(db_sync, tmp_path): select(ImageProvenance.post_id).where(ImageProvenance.image_record_id == img_id) ).scalars().all() assert prov == [native_id] + + +def test_reconcile_preserves_from_attachment_on_provenance_collision(db_sync, tmp_path): + # The gallery-dl loser row recorded which archive the image came out of + # (#87); the native keeper row didn't. Merging drops the loser provenance row + # on the (image, post) collision — but must first carry its from_attachment_id + # onto the keeper so the containing-archive linkage survives. + a = _make_artist(db_sync, slug="rc4") + img = _make_image(db_sync, artist=a, path=str(tmp_path / "z.jpg"), sha256="e" * 64) + legacy = Post(artist_id=a.id, external_post_id="9", raw_metadata={"post_id": 77}) + native = Post(artist_id=a.id, external_post_id="77", raw_metadata={"post_id": 77}) + db_sync.add_all([legacy, native]) + db_sync.flush() + att = PostAttachment( + post_id=legacy.id, artist_id=a.id, sha256="f" * 64, + path=str(tmp_path / "bundle.cbz"), original_filename="bundle.cbz", + ext=".cbz", size_bytes=9, + ) + db_sync.add(att) + db_sync.flush() + db_sync.add(ImageProvenance( + image_record_id=img.id, post_id=legacy.id, from_attachment_id=att.id, + )) + db_sync.add(ImageProvenance(image_record_id=img.id, post_id=native.id)) + img_id, native_id, att_id = img.id, native.id, att.id + db_sync.commit() + + cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=False) + rows = db_sync.execute( + select(ImageProvenance.post_id, ImageProvenance.from_attachment_id) + .where(ImageProvenance.image_record_id == img_id) + ).all() + assert rows == [(native_id, att_id)]