fix(recapture): link on-disk images to their post (#1288)
Recapture disk-skips already-downloaded media, and upsert_post_record only writes Post fields — so a pre-existing image (e.g. one pulled under the old gallery-dl path, imported bare with no post) stays orphaned even after its post record is (re)written. Confirmed on the operator's instance: 329 pixiv images with primary_post_id NULL, 694 pixiv posts with content but no linked images, 0 duplicate posts. Fix: the recapture relink channel now carries the media's post_id (2- → 3-tuple path/url/post_id), and phase 3 calls importer.link_existing_image_to_post — match the on-disk image by path, find its Post by (source, external_post_id), upsert image_provenance + primary_post_id. Factored the provenance-linking out of _apply_sidecar into a shared _attach_provenance so the fresh-import and recapture-backlink paths can't diverge. Idempotent; generic across native platforms (no-op for already-linked Patreon/SubscribeStar). Re-running recapture now repairs orphaned images; future walks never orphan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -667,10 +667,12 @@ async def test_backfill_skips_already_captured_post_but_recapture_forces_it(
|
||||
assert downloader2.post_records == 1
|
||||
assert res_recap.files_downloaded == 0 # no media re-download
|
||||
assert downloader2.download_calls == 0
|
||||
# The on-disk media is surfaced as a (path, CDN url) relink pair.
|
||||
# The on-disk media is surfaced as a (path, CDN url, post_id) relink triple
|
||||
# (post_id drives the #1288 image→post back-link in phase 3).
|
||||
assert len(res_recap.relink_source_paths) == 1
|
||||
rel_path, rel_url = res_recap.relink_source_paths[0]
|
||||
rel_path, rel_url, rel_post_id = res_recap.relink_source_paths[0]
|
||||
assert rel_url == m1.url
|
||||
assert rel_post_id == m1.post_id
|
||||
# Diagnostic summary surfaces the post-record + relink counts (operator reads
|
||||
# this off the event stdout to see what a recapture actually did).
|
||||
assert "1 post-record(s), 1 relinked" in res_recap.stdout
|
||||
|
||||
@@ -158,6 +158,60 @@ def test_relink_source_filehash_no_match_is_noop(importer, import_layout):
|
||||
) is False
|
||||
|
||||
|
||||
def test_link_existing_image_to_post_backfills_provenance(importer, import_layout, db_sync):
|
||||
"""#1288: an on-disk image imported BARE (no sidecar → no post link, as the
|
||||
pre-cutover gallery-dl pixiv images were) gets linked to its post from the
|
||||
recapture walk's (path, external_post_id) pairing — image_provenance +
|
||||
primary_post_id — without re-importing. Idempotent."""
|
||||
import_root, _ = import_layout
|
||||
m = import_root / "Alice" / "img.jpg"
|
||||
_split(m, "v")
|
||||
rec = importer.session.get(ImageRecord, importer.import_one(m).image_id)
|
||||
assert rec.primary_post_id is None # bare: no post link yet
|
||||
artist = db_sync.get(Artist, rec.artist_id)
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="pixiv",
|
||||
url="https://www.pixiv.net/users/1", enabled=True,
|
||||
)
|
||||
db_sync.add(src)
|
||||
db_sync.flush()
|
||||
# The post already exists (upsert_post_record wrote it earlier in recapture).
|
||||
post = Post(
|
||||
source_id=src.id, artist_id=artist.id,
|
||||
external_post_id="146132304", post_title="t",
|
||||
)
|
||||
db_sync.add(post)
|
||||
db_sync.commit()
|
||||
|
||||
assert importer.link_existing_image_to_post(
|
||||
Path(rec.path), "146132304", source=src, artist=artist,
|
||||
) is True
|
||||
importer.session.refresh(rec)
|
||||
assert rec.primary_post_id == post.id
|
||||
prov = db_sync.execute(select(ImageProvenance).where(
|
||||
ImageProvenance.image_record_id == rec.id,
|
||||
ImageProvenance.post_id == post.id,
|
||||
)).scalar_one()
|
||||
assert prov.source_id == src.id
|
||||
|
||||
# Idempotent: a second call re-affirms without duplicating the row.
|
||||
assert importer.link_existing_image_to_post(
|
||||
Path(rec.path), "146132304", source=src, artist=artist,
|
||||
) is True
|
||||
n = db_sync.execute(select(func.count(ImageProvenance.id)).where(
|
||||
ImageProvenance.image_record_id == rec.id,
|
||||
ImageProvenance.post_id == post.id,
|
||||
)).scalar_one()
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_link_existing_image_to_post_no_match_is_noop(importer):
|
||||
"""A path with no ImageRecord, or an empty external id, is a clean no-op."""
|
||||
assert importer.link_existing_image_to_post(
|
||||
Path("/nonexistent/x.jpg"), "999",
|
||||
) is False
|
||||
|
||||
|
||||
def test_reimport_same_post_idempotent(importer, import_layout):
|
||||
import_root, _ = import_layout
|
||||
# Threshold 0: only an exact phash match collapses. Orthogonal splits
|
||||
|
||||
Reference in New Issue
Block a user