feat(ingest): Recapture mode — re-grab post bodies/links + localize on-disk inline images (#830)
A plain backfill gates post-body capture on the seen-ledger, so a post whose media is already on disk AND whose post key is already seen never gets its body recaptured (operator-flagged: Industrial Lust description missing). Recovery recaptures unconditionally but re-downloads the whole source. New 'recapture' walk mode (4th beside tick/backfill/recovery): bypasses the post-record gate so EVERY post's body + external links are re-captured (detail-fetching empty bodies) WITHOUT re-downloading on-disk media; and surfaces already-present media via a separate non-deleting relink channel so the importer backfills ImageRecord.source_filehash for inline-image localization. - ingest_core: recapture mode + recapture_records gate bypass + relink collect - patreon_downloader: recapture surfaces seen-on-disk as skipped_disk(path), never refetches seen-missing media, still downloads genuinely-new - importer.relink_source_filehash: NULL-only sha256 backfill, never unlinks - download_service: mode derivation + phase-3 relink loop + lifecycle clear - source_service/api: start_recapture + backfill_recapture field + action - frontend: Recapture kebab action + 'Recapturing' badge across SourceActions/ Row/Card/SubscriptionsTab + sources store - tests across ingester/downloader/importer/source_service/api/download_service Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,44 @@ def test_sidecar_source_url_persists_filehash(importer, import_layout):
|
||||
assert rec.source_filehash == "0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
def test_relink_source_filehash_backfills_existing(importer, import_layout):
|
||||
"""#830 recapture (Part B): relink_source_filehash backfills source_url +
|
||||
source_filehash on an existing on-disk image's ImageRecord (matched by
|
||||
sha256) WITHOUT re-importing or unlinking the file. NULL-only — never
|
||||
clobbers a filehash already set."""
|
||||
import_root, _ = import_layout
|
||||
m = import_root / "Alice" / "img.jpg"
|
||||
_split(m, "v")
|
||||
r = importer.import_one(m)
|
||||
assert r.status == "imported"
|
||||
rec = importer.session.get(ImageRecord, r.image_id)
|
||||
assert rec.source_filehash is None
|
||||
|
||||
url = "https://cdn.test/p/0123456789ABCDEF0123456789abcdef/img.jpg"
|
||||
assert importer.relink_source_filehash(m, url) is True
|
||||
importer.session.refresh(rec)
|
||||
assert rec.source_url == url
|
||||
assert rec.source_filehash == "0123456789abcdef0123456789abcdef"
|
||||
assert m.exists() # never unlinked
|
||||
|
||||
# NULL-only: a second relink with a different url is a no-op.
|
||||
other = "https://cdn.test/p/ffffffffffffffffffffffffffffffff/x.jpg"
|
||||
assert importer.relink_source_filehash(m, other) is False
|
||||
importer.session.refresh(rec)
|
||||
assert rec.source_filehash == "0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
def test_relink_source_filehash_no_match_is_noop(importer, import_layout):
|
||||
"""A path whose bytes match no ImageRecord (never imported) is a clean no-op,
|
||||
not an error."""
|
||||
import_root, _ = import_layout
|
||||
m = import_root / "Alice" / "ghost.jpg"
|
||||
_split(m, "h")
|
||||
assert importer.relink_source_filehash(
|
||||
m, "https://cdn.test/p/0123456789abcdef0123456789abcdef/ghost.jpg"
|
||||
) 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