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:
@@ -1313,6 +1313,113 @@ class Importer:
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def link_existing_image_to_post(
|
||||
self, path: Path, external_post_id: str, *,
|
||||
source: Source | None = None, artist: Artist | None = None,
|
||||
) -> bool:
|
||||
"""Recapture back-link: associate an ALREADY-on-disk image (matched by
|
||||
its stored path) with its post — the link _apply_sidecar normally makes
|
||||
at per-media import time.
|
||||
|
||||
Recapture disk-skips downloaded media, so a pre-existing image (e.g. one
|
||||
pulled under the old gallery-dl path, imported as a bare record with no
|
||||
post) never gets its `image_provenance` row / `primary_post_id`. This
|
||||
backfills it from the walk's (on-disk path, post external id) pairing:
|
||||
find the ImageRecord by path, find/attach the Post by (source,
|
||||
external_post_id), upsert provenance. Idempotent (issue #1288). Returns
|
||||
True when a record matched and was linked; no-op when the file has no
|
||||
record or no id is given."""
|
||||
if not external_post_id:
|
||||
return False
|
||||
record = self.session.execute(
|
||||
select(ImageRecord).where(ImageRecord.path == str(path))
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
artist_id = record.artist_id or (artist.id if artist else None)
|
||||
if artist_id is None:
|
||||
return False
|
||||
if record.artist_id is None:
|
||||
record.artist_id = artist_id
|
||||
post = self._find_or_create_post(
|
||||
source_id=source.id if source else None,
|
||||
external_post_id=str(external_post_id),
|
||||
artist_id=artist_id,
|
||||
)
|
||||
self._attach_provenance(
|
||||
record, post, source_id=source.id if source else None,
|
||||
)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def _attach_provenance(
|
||||
self, record: ImageRecord, post: Post, *,
|
||||
source_id: int | None, captured_metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Upsert the (image, post) `image_provenance` link + keep
|
||||
`primary_post_id` and the denormalized gallery sort keys aligned. Shared
|
||||
by _apply_sidecar (fresh per-media import) and link_existing_image_to_post
|
||||
(recapture back-link, #1288) so the two paths can't diverge. Idempotent.
|
||||
|
||||
Race-safe (image_record_id, post_id) upsert — mirrors the
|
||||
_find_or_create_source/post savepoint pattern. The plain
|
||||
SELECT-then-INSERT pattern lost a race when two workers ran on the same
|
||||
(image, post) pair (e.g. the 5-min recovery sweep re-enqueued a
|
||||
still-running long import), planting duplicates that then broke
|
||||
.scalar_one_or_none() on every later deep-scan rederive
|
||||
(MultipleResultsFound). Alembic 0021's uq_image_provenance_image_post
|
||||
UNIQUE makes this savepoint trip on collision."""
|
||||
exists = self.session.execute(
|
||||
select(ImageProvenance.id).where(
|
||||
ImageProvenance.image_record_id == record.id,
|
||||
ImageProvenance.post_id == post.id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
sp = self.session.begin_nested()
|
||||
try:
|
||||
self.session.add(
|
||||
ImageProvenance(
|
||||
image_record_id=record.id,
|
||||
post_id=post.id,
|
||||
source_id=source_id,
|
||||
captured_metadata=captured_metadata,
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
sp.commit()
|
||||
except IntegrityError:
|
||||
sp.rollback()
|
||||
if record.primary_post_id is None:
|
||||
record.primary_post_id = post.id
|
||||
# Keep the denormalized gallery sort key (alembic 0035) aligned with
|
||||
# the primary post's publish date so /scroll orders off
|
||||
# ix_image_record_effective_date instead of COALESCE-ing across the
|
||||
# post join. Only override when THIS post is the primary AND carries
|
||||
# a date; otherwise the column keeps its created_at-equivalent server
|
||||
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||
if record.primary_post_id == post.id and post.post_date is not None:
|
||||
record.effective_date = post.post_date
|
||||
# earliest_post_date (alembic 0071) = MIN(post_date) across ALL of this
|
||||
# image's provenance posts, not just the primary — so the gallery can
|
||||
# sort by original publish rather than the download/repost the primary
|
||||
# points at. Recompute from provenance whenever a dated post is linked;
|
||||
# the provenance row for THIS post was committed above, so the MIN
|
||||
# includes it. Leaves the created_at default when no linked post is dated.
|
||||
if post.post_date is not None:
|
||||
earliest = self.session.execute(
|
||||
select(func.min(Post.post_date))
|
||||
.select_from(ImageProvenance)
|
||||
.join(Post, Post.id == ImageProvenance.post_id)
|
||||
.where(
|
||||
ImageProvenance.image_record_id == record.id,
|
||||
Post.post_date.is_not(None),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if earliest is not None:
|
||||
record.earliest_post_date = earliest
|
||||
self.session.flush()
|
||||
|
||||
def _apply_sidecar(
|
||||
self,
|
||||
record: ImageRecord,
|
||||
@@ -1386,65 +1493,12 @@ class Importer:
|
||||
if not self.post_first:
|
||||
self._apply_post_fields(post, sd)
|
||||
|
||||
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
||||
# _find_or_create_source/post savepoint pattern. The plain
|
||||
# SELECT-then-INSERT pattern lost a race when two workers ran
|
||||
# _apply_sidecar on the same (image, post) pair (e.g. the 5-min
|
||||
# recovery sweep re-enqueued a still-running long import), planting
|
||||
# duplicates that then broke .scalar_one_or_none() on every later
|
||||
# deep-scan rederive (MultipleResultsFound). Alembic 0021 adds the
|
||||
# uq_image_provenance_image_post UNIQUE so this savepoint actually
|
||||
# trips on collision.
|
||||
exists = self.session.execute(
|
||||
select(ImageProvenance.id).where(
|
||||
ImageProvenance.image_record_id == record.id,
|
||||
ImageProvenance.post_id == post.id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
sp = self.session.begin_nested()
|
||||
try:
|
||||
self.session.add(
|
||||
ImageProvenance(
|
||||
image_record_id=record.id,
|
||||
post_id=post.id,
|
||||
source_id=src.id if src else None,
|
||||
captured_metadata=sd.raw,
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
sp.commit()
|
||||
except IntegrityError:
|
||||
sp.rollback()
|
||||
if record.primary_post_id is None:
|
||||
record.primary_post_id = post.id
|
||||
# Keep the denormalized gallery sort key (alembic 0035) aligned with
|
||||
# the primary post's publish date so /scroll orders off
|
||||
# ix_image_record_effective_date instead of COALESCE-ing across the
|
||||
# post join. Only override when THIS post is the primary AND carries
|
||||
# a date; otherwise the column keeps its created_at-equivalent server
|
||||
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||
if record.primary_post_id == post.id and post.post_date is not None:
|
||||
record.effective_date = post.post_date
|
||||
# earliest_post_date (alembic 0071) = MIN(post_date) across ALL of this
|
||||
# image's provenance posts, not just the primary — so the gallery can
|
||||
# sort by original publish rather than the download/repost the primary
|
||||
# points at. Recompute from provenance whenever a dated post is linked;
|
||||
# the provenance row for THIS post was committed above, so the MIN
|
||||
# includes it. Leaves the created_at default when no linked post is dated.
|
||||
if post.post_date is not None:
|
||||
earliest = self.session.execute(
|
||||
select(func.min(Post.post_date))
|
||||
.select_from(ImageProvenance)
|
||||
.join(Post, Post.id == ImageProvenance.post_id)
|
||||
.where(
|
||||
ImageProvenance.image_record_id == record.id,
|
||||
Post.post_date.is_not(None),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if earliest is not None:
|
||||
record.earliest_post_date = earliest
|
||||
self.session.flush()
|
||||
# Link the (image, post) provenance + keep primary_post_id / the gallery
|
||||
# sort keys aligned — shared with the recapture back-link path (#1288).
|
||||
self._attach_provenance(
|
||||
record, post, source_id=src.id if src else None,
|
||||
captured_metadata=sd.raw,
|
||||
)
|
||||
|
||||
def _copy_to_library(
|
||||
self, source: Path, sha: str, attribution_path: Path
|
||||
|
||||
Reference in New Issue
Block a user