Merge pull request 'Nested-archive extraction (#718) + post-first ingest (#67) + post-body canary (#862)' (#109) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m19s

This commit was merged in pull request #109.
This commit is contained in:
2026-06-15 21:37:39 -04:00
8 changed files with 457 additions and 92 deletions
+8
View File
@@ -274,6 +274,14 @@ class DownloadService:
artist = self.sync_session.get(Artist, ctx["artist_id"]) artist = self.sync_session.get(Artist, ctx["artist_id"])
source_row = self.sync_session.get(Source, ctx["source_id"]) source_row = self.sync_session.get(Source, ctx["source_id"])
# Post-first (#856): on the native ingester the post-record is the sole
# writer of the post body/links, so the per-media import must NOT apply post
# fields. gallery-dl platforms keep writing them via the sidecar. The
# Importer is per-task, so this per-instance flag is safe. uses_native_ingester
# is the future-proof seam — a platform migrating onto the native core
# flips to post-first automatically (milestone #67, step 3).
self.importer.post_first = uses_native_ingester(ctx["platform"])
import_summary = {"attached": 0, "skipped": 0, "errors": 0} import_summary = {"attached": 0, "skipped": 0, "errors": 0}
# Archives detected but captured WITHOUT extracting any image (probe # Archives detected but captured WITHOUT extracting any image (probe
# rejected / corrupt / missing extractor backend). Surfaced on the event # rejected / corrupt / missing extractor backend). Surfaced on the event
+135 -45
View File
@@ -79,6 +79,12 @@ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"} VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS
# A "high-resolution files" pack often wraps per-chapter .rar/.zip inside one
# outer archive (incase #718) — recurse into nested archives so their images
# land instead of being silently dropped. Cap the depth so a maliciously
# deep-nested archive can't recurse forever; the bomb-probe re-runs per level.
_ARCHIVE_MAX_DEPTH = 3
def is_supported(path: Path) -> bool: def is_supported(path: Path) -> bool:
return path.suffix.lower() in ALL_EXTS return path.suffix.lower() in ALL_EXTS
@@ -149,6 +155,14 @@ class Importer:
self.thumbnailer = thumbnailer self.thumbnailer = thumbnailer
self.settings = settings self.settings = settings
self.deep = deep self.deep = deep
# Post-first ingest (#856, milestone #67): on the NATIVE core ingester the
# post-record (`upsert_post_record`) is the SOLE writer of a post's
# body/links/metadata; the per-media import only links image provenance +
# localization, NOT the post body. download_service sets this True for
# native platforms before the phase-3 media loop. Default False keeps the
# gallery-dl path writing post fields via `_apply_sidecar` (unchanged).
# Safe as a per-instance flag: Importer is constructed per Celery task.
self.post_first = False
self.attachments = AttachmentStore(images_root) self.attachments = AttachmentStore(images_root)
# phash near-dup candidate cache. Archive imports call _import_media # phash near-dup candidate cache. Archive imports call _import_media
# per-member; without this cache the per-member SELECT *FROM # per-member; without this cache the per-member SELECT *FROM
@@ -483,17 +497,17 @@ 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] = []
member_total = 0 # Per-outcome tally so the "no images" reason names the ACTUAL cause
with extract_archive(source) as members: # (#718): nested-archive packs, all-deduped (benign), unsupported formats,
for _name, member_path in members: # or failed/corrupt members — instead of one catch-all string.
member_total += 1 counts = {
if not is_supported(member_path): "media": 0, "deduped": 0, "unsupported": 0,
continue # non-media preserved via the stored archive "failed": 0, "nested": 0, "nested_rejected": 0,
res = self._import_media( }
member_path, source, explicit_source=source_row, self._collect_archive_members(
) source, attribution=source, source_row=source_row,
if res.status in ("imported", "superseded") and res.image_id: depth=0, member_ids=member_ids, counts=counts,
member_ids.append(res.image_id) )
# 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
@@ -503,20 +517,93 @@ class Importer:
status="imported", image_id=member_ids[0], status="imported", image_id=member_ids[0],
member_image_ids=member_ids, member_image_ids=member_ids,
) )
# No images landed — surface WHY so a post showing "no images" beside an # No NEW images landed. Name WHY precisely so a post showing "no images"
# archive is diagnosable instead of silent. Zero members usually means # beside an archive is diagnosable — and DON'T flag the benign all-deduped
# the extractor backend is missing/failed (unar for rar, py7zr for 7z) # case as a problem: those images already exist in the library and were
# or the file is corrupt; non-zero-but-no-images means it held only # re-linked to this post (enrich-on-duplicate), so the post DOES show them.
# non-media files. if counts["media"] == 0 and counts["nested"] == 0:
reason = ( reason = ("archive yielded no members (unsupported/corrupt, or the "
"archive extracted but held no supported image/video members" "extractor backend failed)")
if member_total elif counts["deduped"] and not (
else "archive yielded no members (unsupported/corrupt, or the " counts["unsupported"] or counts["failed"] or counts["nested_rejected"]
"extractor backend failed)" ):
) log.info(
"%s: all %d image member(s) already in library (deduped + linked "
"to this post) — archive preserved, no new import",
source.name, counts["deduped"],
)
return ImportResult(status="attached")
else:
reason = (
f"no new images — media={counts['media']} deduped={counts['deduped']} "
f"unsupported/non-media={counts['unsupported']} failed={counts['failed']} "
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']}"
)
log.warning("%s: %s", source.name, reason) log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason) return ImportResult(status="attached", error=reason)
def _collect_archive_members(
self, archive_path: Path, *, attribution: Path,
source_row: Source | None, depth: int,
member_ids: list[int], counts: dict,
) -> None:
"""Extract `archive_path` and import its image/video members, RECURSING
into nested archives (#718). Members attribute to `attribution` — the
OUTER archive's path, so its sidecar resolves them to the right Post even
when they came from a nested archive. Depth-capped + bomb-probed per
nested level. Mutates `member_ids` and `counts` in place.
extract_archive is fail-soft, but each level is wrapped so one bad nested
archive can't abort the whole import."""
try:
with extract_archive(archive_path) as members:
for _name, member_path in members:
if not member_path.is_file():
continue # directory entries etc. — not media
if is_archive(member_path):
counts["nested"] += 1
if depth + 1 > _ARCHIVE_MAX_DEPTH:
counts["nested_rejected"] += 1
log.warning(
"nested archive past depth cap %d, skipped: %s",
_ARCHIVE_MAX_DEPTH, member_path.name,
)
continue
probe = safe_probe.probe_archive(member_path)
if not probe.ok:
counts["nested_rejected"] += 1
log.warning(
"nested archive rejected (%s): %s",
probe.reason, member_path.name,
)
continue
self._collect_archive_members(
member_path, attribution=attribution,
source_row=source_row, depth=depth + 1,
member_ids=member_ids, counts=counts,
)
continue
counts["media"] += 1
if not is_supported(member_path):
counts["unsupported"] += 1
continue # non-media preserved via the stored archive
res = self._import_media(
member_path, attribution, explicit_source=source_row,
)
if res.status in ("imported", "superseded") and res.image_id:
member_ids.append(res.image_id)
elif res.status == "skipped" and res.skip_reason in (
SkipReason.duplicate_hash, SkipReason.duplicate_phash
):
counts["deduped"] += 1
else:
counts["failed"] += 1
except Exception as exc: # noqa: BLE001 — defensive per level; keep going
log.warning(
"archive extraction failed for %s (depth %d): %s",
archive_path.name, depth, exc,
)
def _import_media( def _import_media(
self, source: Path, attribution_path: Path, self, source: Path, attribution_path: Path,
*, explicit_source: Source | None = None, *, explicit_source: Source | None = None,
@@ -595,6 +682,15 @@ class Importer:
error=f"{pct:.2%} transparent", error=f"{pct:.2%} transparent",
) )
# Artist anchored to the attribution path (folder→artist), resolved
# UP-FRONT so the enrich-on-duplicate branches link provenance with the
# right artist even when the sidecar carries none — which is now the norm
# for archive members under post-first (the per-media sidecar is minimal).
# Without this a cross-posted / re-packed archive image deduped and was
# left UNLINKED from the new post = a post showing "no images" (#718).
_artist_name = derive_top_level_artist(attribution_path, self.import_root)
path_artist = self._upsert_artist(_artist_name) if _artist_name else None
# Hash dedup (exact). # Hash dedup (exact).
sha = _sha256_of(source) sha = _sha256_of(source)
existing_stmt = select(ImageRecord).where(ImageRecord.sha256 == sha) existing_stmt = select(ImageRecord).where(ImageRecord.sha256 == sha)
@@ -604,9 +700,9 @@ class Importer:
# Enrich-on-duplicate (parity with attach_in_place): a re-scanned # Enrich-on-duplicate (parity with attach_in_place): a re-scanned
# file that is a byte-dup of an existing image still links its # file that is a byte-dup of an existing image still links its
# post via _apply_sidecar, so a cross-posted image shows on every # post via _apply_sidecar, so a cross-posted image shows on every
# post. Artist is derived from the sidecar here (not yet resolved). # post.
self._apply_sidecar( self._apply_sidecar(
existing, attribution_path, None, existing, attribution_path, path_artist,
explicit_source=explicit_source, explicit_source=explicit_source,
) )
self.session.commit() self.session.commit()
@@ -640,7 +736,7 @@ class Importer:
larger = self.session.get(ImageRecord, match_id) larger = self.session.get(ImageRecord, match_id)
if larger is not None: if larger is not None:
self._apply_sidecar( self._apply_sidecar(
larger, attribution_path, None, larger, attribution_path, path_artist,
explicit_source=explicit_source, explicit_source=explicit_source,
) )
self.session.commit() self.session.commit()
@@ -674,13 +770,11 @@ class Importer:
self.session.flush() self.session.flush()
self._phash_cache_append(phash, width, height, record.id) self._phash_cache_append(phash, width, height, record.id)
# Folder→artist (anchored to attribution_path). # Folder→artist (path_artist resolved up-front above); bind it to the
artist = None # new record so it carries the canonical Artist.
artist_name = derive_top_level_artist( if path_artist is not None and record.artist_id is None:
attribution_path, self.import_root record.artist_id = path_artist.id
) self.session.flush()
if artist_name:
artist = self._attach_artist(record, artist_name)
# Sidecar provenance (best-effort; never fails the import). # Sidecar provenance (best-effort; never fails the import).
# explicit_source lets the FC-3c download path bind the new # explicit_source lets the FC-3c download path bind the new
@@ -690,7 +784,7 @@ class Importer:
# subscription-downloaded zip previously lost subscription # subscription-downloaded zip previously lost subscription
# linkage if the on-disk layout didn't match assumptions. # linkage if the on-disk layout didn't match assumptions.
self._apply_sidecar( self._apply_sidecar(
record, attribution_path, artist, explicit_source=explicit_source, record, attribution_path, path_artist, explicit_source=explicit_source,
) )
# Thumbnail is queued separately by the calling task; the importer # Thumbnail is queued separately by the calling task; the importer
@@ -989,17 +1083,6 @@ class Importer:
self.session.flush() self.session.flush()
return artist return artist
def _attach_artist(self, record: ImageRecord, artist_name: str) -> Artist:
"""Upsert the Artist and set it as the image's canonical artist.
Artist-kind tags were retired in FC-2d-vii-c — the Artist row is
the single source of truth. Returns the Artist (sidecar
provenance attaches its Source to it)."""
artist = self._upsert_artist(artist_name)
if record.artist_id is None:
record.artist_id = artist.id
self.session.flush()
return artist
@staticmethod @staticmethod
def _sidecar_artist_name(data: dict) -> str | None: def _sidecar_artist_name(data: dict) -> str | None:
for k in ("artist", "author_name"): for k in ("artist", "author_name"):
@@ -1110,7 +1193,14 @@ class Importer:
external_post_id=epid, external_post_id=epid,
artist_id=artist.id, artist_id=artist.id,
) )
self._apply_post_fields(post, sd) # Post-first (#856): on the native path the post-record owns the body/
# links/metadata, so the per-media import must NOT write post fields — its
# minimal sidecar carries no body, and applying it would clobber
# raw_metadata + re-run link-sync off empty data. The image still links
# provenance + localization below. gallery-dl (post_first False) is
# unchanged: its sidecar IS the only body source, so it still applies.
if not self.post_first:
self._apply_post_fields(post, sd)
# Race-safe (image_record_id, post_id) upsert — mirrors the # Race-safe (image_record_id, post_id) upsert — mirrors the
# _find_or_create_source/post savepoint pattern. The plain # _find_or_create_source/post savepoint pattern. The plain
+59 -3
View File
@@ -57,6 +57,19 @@ _ERROR_MAX = 1000
# page-tied update would lurch). Trivial churn (~one single-row UPDATE / 5s). # page-tied update would lurch). Trivial churn (~one single-row UPDATE / 5s).
_LIVE_PROGRESS_INTERVAL = 5.0 _LIVE_PROGRESS_INTERVAL = 5.0
# Post-body schema-drift canary (#862). Patreon's body lives in
# content/content_json_string with NO post_type gate, so a field rename (as
# content→content_json_string already was) zeroes EVERY body at once — across
# every artist, every walk. If a native walk records at least this many posts
# and extracts a body from NONE of them, treat it as that break (fail the run
# API_DRIFT) rather than silently archiving empties. A *fraction* threshold would
# false-positive on gallery/art creators who legitimately post images with no
# caption, so the gate is "zero across a minimum sample": a real creator nearly
# always has SOME text across this many posts, a broken parser has none. Set high
# enough that a small tick (a few new posts) can't trip it — only a backfill /
# recapture (the operator's schema-test flow) reaches the sample.
_CANARY_MIN_SAMPLE = 30
class Ingester: class Ingester:
"""Generic native-ingest orchestration. Subclass with a platform adapter """Generic native-ingest orchestration. Subclass with a platform adapter
@@ -128,9 +141,18 @@ class Ingester:
# tick has no resumable backfill state. # tick has no resumable backfill state.
checkpoint = mode in ("backfill", "recovery", "recapture") checkpoint = mode in ("backfill", "recovery", "recapture")
ledger_key = self._ledger_key ledger_key = self._ledger_key
# Optional seams (Patreon native ingester): capture pure-text posts that # POST-FIRST CONTRACT (milestone #67): these two optional seams make a
# have NO downloadable media so the artist archive is complete. Absent on # platform "post-first" on the native core ingester — the post-record is
# stub clients/downloaders (unit tests) → media-less posts skipped as before. # the single authoritative writer of the post body/links/metadata, and the
# per-media sidecar carries image identity only (download_service flips
# importer.post_first via uses_native_ingester, so the import side follows
# automatically). A platform migrating off gallery-dl onto the native core
# adopts post-first by implementing BOTH:
# client.post_record_key(post) -> (ledger_key, post_id) | None (gate)
# downloader.write_post_record(post, artist_slug) -> PostRecordOutcome
# Absent on stub clients/downloaders (unit tests) and on not-yet-migrated
# platforms → media-less posts are skipped as before and the body still
# comes from the per-media sidecar (gallery-dl path). See [[post-first-ingest-contract]].
post_record_key = getattr(self.client, "post_record_key", None) post_record_key = getattr(self.client, "post_record_key", None)
write_post_record = getattr(self.downloader, "write_post_record", None) write_post_record = getattr(self.downloader, "write_post_record", None)
start = time.monotonic() start = time.monotonic()
@@ -149,6 +171,12 @@ class Ingester:
dead_lettered = 0 dead_lettered = 0
skipped_count = 0 skipped_count = 0
posts_processed = 0 posts_processed = 0
# Post-body schema-drift canary counters (#862, native ingester only —
# gallery-dl walks never enter the post-record block below so these stay 0
# and the canary can't fire there). posts_recorded = post-records attempted
# this walk; posts_with_body = how many yielded a non-empty body.
posts_recorded = 0
posts_with_body = 0
# Net-new posts THIS chunk for the live progress badge (plan #704 #5); # Net-new posts THIS chunk for the live progress badge (plan #704 #5);
# excludes the re-walked resume page so _backfill_posts stays a monotonic # excludes the re-walked resume page so _backfill_posts stays a monotonic
# absolute across chunks instead of an inflating sum. posts_processed # absolute across chunks instead of an inflating sum. posts_processed
@@ -261,6 +289,9 @@ class Ingester:
) )
if pkey not in already: if pkey not in already:
rec = write_post_record(post, artist_slug) rec = write_post_record(post, artist_slug)
posts_recorded += 1
if rec.body_chars:
posts_with_body += 1
if rec.path is not None: if rec.path is not None:
post_records.append(str(rec.path)) post_records.append(str(rec.path))
self._mark_seen(source_id, [(pkey, ppid)]) self._mark_seen(source_id, [(pkey, ppid)])
@@ -414,6 +445,10 @@ class Ingester:
f"{dead_lettered} dead-lettered, {errors} error(s), " f"{dead_lettered} dead-lettered, {errors} error(s), "
f"{posts_processed} post(s), {len(post_records)} post-record(s), " f"{posts_processed} post(s), {len(post_records)} post-record(s), "
f"{len(relink)} relinked" f"{len(relink)} relinked"
# Body-capture health (#862): even below the canary's red-alarm
# threshold, surfacing the ratio makes a partial extraction regression
# visible in the Raw stdout (e.g. "bodies 3/180" reads as off).
+ (f", bodies {posts_with_body}/{posts_recorded}" if posts_recorded else "")
+ (", reached end" if reached_bottom else "") + (", reached end" if reached_bottom else "")
+ (", time-boxed" if budget_hit else "") + (", time-boxed" if budget_hit else "")
) )
@@ -439,6 +474,27 @@ class Ingester:
error_message="Chunk timed out with no progress", error_message="Chunk timed out with no progress",
) )
# Post-body schema-drift canary (#862): a native walk recorded a
# meaningful sample of posts but extracted a body from NONE of them. Since
# the body field has no post_type gate, that's the signature of Patreon
# renaming/restructuring the body field (as content→content_json_string
# already was) — fail RED (API_DRIFT: "fix is the field-set/parser, not
# creds") so the breakage screams instead of silently archiving empties.
# Only reached on an otherwise-clean walk (timeout/stop/error returned
# above), so it never masks a more specific failure.
if posts_recorded >= _CANARY_MIN_SAMPLE and posts_with_body == 0:
msg = (
f"Post-body canary: extracted a body from 0 of {posts_recorded} "
"posts — Patreon's body field shape likely changed; the ingester "
"needs a field-set/parser update."
)
log_lines.append(msg)
log.error("%s (artist=%s)", msg, artist_slug)
return _result(
success=False, return_code=-1,
error_type=ErrorType.API_DRIFT, error_message=msg,
)
# Normal success: reached the bottom, or a tick that early-outed. rc 0 + # Normal success: reached the bottom, or a tick that early-outed. rc 0 +
# error_type None is REQUIRED for a backfill/recovery walk that reached # error_type None is REQUIRED for a backfill/recovery walk that reached
# the bottom to be marked COMPLETE by # the bottom to be marked COMPLETE by
+25 -16
View File
@@ -529,29 +529,38 @@ class PatreonDownloader:
def _write_sidecar( def _write_sidecar(
self, post: dict, media_path: Path, *, source_url: str | None = None self, post: dict, media_path: Path, *, source_url: str | None = None
) -> Path: ) -> Path:
"""Write the importer-consumed sidecar next to `media_path`. """Write the per-media sidecar next to `media_path` — post-first (#856).
Patreon uses base-default sidecar keys (parse_sidecar maps On the native ingester the POST-RECORD (`write_post_record` → `_post.json`)
category->platform, id->external_post_id, title->post_title, is the sole writer of the post body/links/metadata, captured once per post
content->description, published_at->post_date, url->post_url). Patreon BEFORE its media in the walk. So the per-media sidecar carries ONLY
registers no derive_post_url, so `url` is trusted as the permalink — we image-specific identity: `category` (platform) + `id` (external_post_id, to
pass the post's attributes.url. link provenance to the right Post) + this file's `source_url` (its CDN URL,
#830 Phase 2 — the importer persists its filehash so the body's inline
`source_url` is THIS file's CDN URL (#830 Phase 2). The importer persists `<img src>` remaps to the local copy at render time). No body: writing it
its filehash on the ImageRecord so the post body's inline `<img src>` can next to every image duplicated the post body N+1× and risked divergence
be remapped to the local copy at render time. (milestone #67). The importer skips post fields for these (post_first).
""" """
return self._write_sidecar_data( return self._write_sidecar_data(
post, media_path.with_suffix(".json"), source_url=source_url post, media_path.with_suffix(".json"), source_url=source_url,
minimal=True,
) )
def _write_sidecar_data( def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
minimal: bool = False,
) -> Path: ) -> Path:
"""Serialize the post's metadata to `sidecar_path`. Shared by the """Serialize the post's metadata to `sidecar_path`. The post-only record
per-media sidecar (next to each file) and the post-only sidecar (`write_post_record`) writes the FULL post (body/title/date/url); the
(`write_post_record`, for media-less posts). `source_url` is set only for per-media sidecar (`_write_sidecar`, minimal=True) writes only image
the per-media sidecar — a media-less post has no source file.""" identity (category/id/source_url) — post-first (#856). `source_url` is set
only for the per-media sidecar — a media-less post has no source file."""
if minimal:
data = {"category": "patreon", "id": str(post.get("id") or "")}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
attrs = post.get("attributes") or {} attrs = post.get("attributes") or {}
title = attrs.get("title") title = attrs.get("title")
# Resolve the body HTML from the feed attrs: legacy flat `content`, else # Resolve the body HTML from the feed attrs: legacy flat `content`, else
+71
View File
@@ -93,6 +93,77 @@ def test_archive_imports_members_and_stores_archive(importer, import_layout):
assert att.post_id == post.id assert att.post_id == post.id
def test_nested_archive_members_imported(importer, import_layout):
"""#718: a 'high-res' pack that wraps a nested archive must still import the
nested images and link them to the post — not silently drop them (incase's
per-chapter .rar inside an outer 'High Resolution files' pack)."""
import_root, _ = import_layout
importer.settings.phash_threshold = 0 # v/h splits are distinct only at 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()) # nested archive, no direct media
arc.with_suffix(".cbz.json").write_text(json.dumps(
{"category": "patreon", "id": "900", "title": "HR Pack"}))
r = importer.import_one(arc)
assert r.status == "imported"
assert len(r.member_image_ids) == 2 # both nested images surfaced
assert importer.session.execute(
select(func.count()).select_from(ImageRecord)
).scalar_one() == 2
post = importer.session.execute(select(Post)).scalar_one()
provs = importer.session.execute(
select(func.count()).select_from(ImageProvenance)
).scalar_one()
assert provs == 2 # both nested members linked to the one outer post
# The outer pack is still preserved as an attachment.
att = importer.session.execute(select(PostAttachment)).scalar_one()
assert att.original_filename == "hr.cbz"
assert att.post_id == post.id
def test_archive_all_deduped_is_benign_not_flagged(importer, import_layout):
"""#718 reason-string fix: when every image in an archive already exists
(cross-posted), the images re-link to the new post and the archive is NOT
flagged as an unextracted-archive problem (no error set)."""
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"}))
assert importer.import_one(first).status == "imported"
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"}))
r = importer.import_one(second)
assert r.status == "attached"
assert r.error is None # benign all-deduped — NOT a flagged problem
assert importer.session.execute(
select(func.count()).select_from(ImageRecord)
).scalar_one() == 2 # no new records
provs = importer.session.execute(
select(func.count()).select_from(ImageProvenance)
).scalar_one()
assert provs == 4 # 2 images × 2 posts (enrich-on-duplicate)
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"
+72
View File
@@ -231,6 +231,78 @@ def test_attach_in_place_duplicate_attachment_links_both_posts(importer, db_sync
assert set(linked) == {"301", "302"} # one row per post, both present assert set(linked) == {"301", "302"} # one row per post, both present
def test_attach_in_place_post_first_skips_post_fields(importer, db_sync):
"""Post-first (#856, milestone #67): with post_first set (native path), the
per-media import links provenance + localization (source_filehash) but does
NOT write the post body/title — the post-record owns those. Guards the import
side even if a body sneaks into the sidecar."""
from backend.app.models import ImageProvenance, Post
importer.post_first = True
images_root = importer.images_root
artist = Artist(name="Pat", slug="pat")
db_sync.add(artist)
db_sync.flush()
target = images_root / "pat" / "patreon" / "post1" / "img.jpg"
_make_jpg(target)
cdn = "https://cdn.patreon.com/" + "a" * 32 + "/x.jpg"
target.with_suffix(target.suffix + ".json").write_text(
'{"category": "patreon", "id": "900", "title": "Should NOT apply", '
'"content": "<p>body should NOT apply</p>", '
f'"source_url": "{cdn}"}}'
)
result = importer.attach_in_place(target, artist=artist)
assert result.status == "imported"
# Image-specific work STILL happens: localization filehash + provenance link.
rec = db_sync.execute(
select(ImageRecord).where(ImageRecord.id == result.image_id)
).scalar_one()
assert rec.source_filehash == "a" * 32
assert rec.primary_post_id is not None
post = db_sync.get(Post, rec.primary_post_id)
assert post.external_post_id == "900"
# Post-first: body/title NOT written by the media path (post-record owns them).
assert post.description is None
assert post.post_title is None
# Provenance link exists regardless of post_first.
prov = db_sync.execute(
select(ImageProvenance).where(
ImageProvenance.image_record_id == result.image_id
)
).scalars().all()
assert len(prov) == 1
def test_attach_in_place_post_first_false_applies_post_fields(importer, db_sync):
"""Default (gallery-dl path): the sidecar IS the body source, so post fields
ARE applied. Guards that the post_first flag actually gates this — not a no-op."""
from backend.app.models import Post
assert importer.post_first is False # default
images_root = importer.images_root
artist = Artist(name="Gal", slug="gal")
db_sync.add(artist)
db_sync.flush()
target = images_root / "gal" / "patreon" / "post1" / "img.jpg"
_make_jpg(target)
target.with_suffix(target.suffix + ".json").write_text(
'{"category": "patreon", "id": "901", "title": "Applies", '
'"content": "<p>body applies</p>"}'
)
result = importer.attach_in_place(target, artist=artist)
assert result.status == "imported"
rec = db_sync.get(ImageRecord, result.image_id)
post = db_sync.get(Post, rec.primary_post_id)
assert post.description == "<p>body applies</p>"
assert post.post_title == "Applies"
def test_attach_in_place_invalid_image_returns_skipped(importer): def test_attach_in_place_invalid_image_returns_skipped(importer):
images_root = importer.images_root images_root = importer.images_root
bad = images_root / "fred" / "bad.jpg" bad = images_root / "fred" / "bad.jpg"
+23 -26
View File
@@ -174,8 +174,11 @@ def test_sidecar_written_and_findable(tmp_path):
data = json.loads(sidecar.read_text()) data = json.loads(sidecar.read_text())
assert data["category"] == "patreon" assert data["category"] == "patreon"
assert data["id"] == "1001" assert data["id"] == "1001"
assert data["title"] == "My Post Title" # Post-first (#856): the per-media sidecar carries image identity ONLY — no
assert data["url"] == "https://www.patreon.com/posts/1001" # post body/title/url. The post-record (`_post.json`) owns those now.
assert "title" not in data
assert "url" not in data
assert "content" not in data
# #830 Phase 2: the per-media sidecar records THIS file's CDN URL so the # #830 Phase 2: the per-media sidecar records THIS file's CDN URL so the
# importer can persist its filehash for inline-image localization. # importer can persist its filehash for inline-image localization.
assert data["source_url"] == "https://cdn.patreon.com/media1.png" assert data["source_url"] == "https://cdn.patreon.com/media1.png"
@@ -594,40 +597,34 @@ def test_range_ignored_by_server_refetches_clean(tmp_path, monkeypatch):
# -- content enrichment (adaptive detail-fetch of an empty feed body) ------- # -- content enrichment (adaptive detail-fetch of an empty feed body) -------
def test_enriches_empty_content_via_fetcher(tmp_path): def test_media_sidecar_is_minimal_post_first(tmp_path):
"""An empty feed body is backfilled from the detail-fetch seam — once per """Post-first (#856): the per-media sidecar carries ONLY image identity
post (memoized on the shared dict) — and lands in the importer sidecar.""" (category/id/source_url), NOT the post body — the post-record (`_post.json`)
owns the body and is written first in the walk. So download_post never hits
the detail fetcher (that's write_post_record's job), and the body is no longer
duplicated next to every image."""
calls: list[str] = [] calls: list[str] = []
def _fetcher(post_id: str) -> str:
calls.append(post_id)
return "<p>full body</p>"
dl = PatreonDownloader( dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False, images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(), content_fetcher=_fetcher, session=_FakeSession(),
content_fetcher=lambda pid: calls.append(pid) or "<p>full body</p>",
) )
post = _post() post = _post()
post["attributes"]["content"] = "" # feed returned an empty body post["attributes"]["content"] = "" # even an empty feed body isn't fetched here
dl.download_post(post, [_img("a.png"), _img("b.png")], "artist-x") dl.download_post(post, [_img("a.png"), _img("b.png")], "artist-x")
post_dir = tmp_path / "artist-x" / "patreon" / pd_mod._post_dir_name(post) post_dir = tmp_path / "artist-x" / "patreon" / pd_mod._post_dir_name(post)
sc = find_sidecar(post_dir / "01_a.png") sc = find_sidecar(post_dir / "01_a.png")
assert sc is not None assert sc is not None
assert json.loads(sc.read_text())["content"] == "<p>full body</p>" data = json.loads(sc.read_text())
# Fetched ONCE for the post, not once per media item. assert data == {
assert calls == ["1001"] "category": "patreon",
"id": "1001",
"source_url": "https://cdn.patreon.com/a.png",
def test_no_enrichment_when_feed_body_present(tmp_path): }
"""A non-empty feed body is trusted as-is; the detail endpoint isn't hit.""" # No post body/title next to the image; the detail endpoint is never hit here.
calls: list[str] = [] assert "content" not in data
dl = PatreonDownloader( assert "title" not in data
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(),
content_fetcher=lambda pid: calls.append(pid) or "x",
)
dl.download_post(_post(), [_img("a.png")], "artist-x") # _post body is non-empty
assert calls == [] assert calls == []
+64 -2
View File
@@ -18,6 +18,7 @@ from backend.app.models import (
Source, Source,
) )
from backend.app.services.gallery_dl import ErrorType from backend.app.services.gallery_dl import ErrorType
from backend.app.services.ingest_core import _CANARY_MIN_SAMPLE
from backend.app.services.patreon_client import ( from backend.app.services.patreon_client import (
MediaItem, MediaItem,
PatreonAPIError, PatreonAPIError,
@@ -49,9 +50,12 @@ class _FakeClient:
"""Stub PatreonClient. `pages` is a list of (page_cursor, [posts]); each post """Stub PatreonClient. `pages` is a list of (page_cursor, [posts]); each post
is (post_id, [MediaItem]). `raise_on_first` lets a test trip drift.""" is (post_id, [MediaItem]). `raise_on_first` lets a test trip drift."""
def __init__(self, pages, raise_exc=None): def __init__(self, pages, raise_exc=None, empty_body=False):
self._pages = pages self._pages = pages
self._raise_exc = raise_exc self._raise_exc = raise_exc
# empty_body simulates a body-field schema break: every post comes back
# with no content (the #862 canary's trip condition).
self._empty_body = empty_body
self.consumed_posts = 0 self.consumed_posts = 0
def iter_posts(self, campaign_id, cursor=None): def iter_posts(self, campaign_id, cursor=None):
@@ -62,6 +66,7 @@ class _FakeClient:
self.consumed_posts += 1 self.consumed_posts += 1
# Carry feed attributes (title/post_type/content) like the real # Carry feed attributes (title/post_type/content) like the real
# client — ingest_core reads these for the per-post diagnostic. # client — ingest_core reads these for the per-post diagnostic.
content = "" if self._empty_body else f"<p>body {post_id}</p>"
yield ( yield (
{ {
"id": post_id, "id": post_id,
@@ -69,7 +74,7 @@ class _FakeClient:
"attributes": { "attributes": {
"title": f"Post {post_id}", "title": f"Post {post_id}",
"post_type": "image_file", "post_type": "image_file",
"content": f"<p>body {post_id}</p>", "content": content,
}, },
}, },
{}, {},
@@ -941,3 +946,60 @@ async def test_recovery_bypasses_gate_and_re_records(source_id, sync_engine, tmp
url="https://patreon.com/ingest", mode="recovery") url="https://patreon.com/ingest", mode="recovery")
assert len(result.post_record_paths) == 1 assert len(result.post_record_paths) == 1
assert dl2.post_records == 1 assert dl2.post_records == 1
# --- post-body schema-drift canary (#862) --------------------------------
def _media_less_posts(n):
# A single page of n media-less posts; each still gets a post-record, so the
# canary counts all n. Unique ids keep the seen-ledger gate from collapsing them.
return [(None, [(f"c{i}", []) for i in range(n)])]
@pytest.mark.asyncio
async def test_body_canary_fails_run_when_all_bodies_empty(source_id, sync_engine, tmp_path):
"""#862: a native walk that records >= _CANARY_MIN_SAMPLE posts but extracts a
body from NONE of them is the signature of a Patreon body-field rename (as
content→content_json_string was) — fail the run RED (API_DRIFT) instead of
silently archiving empties."""
client = _FakeClient(_media_less_posts(_CANARY_MIN_SAMPLE), empty_body=True)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="backfill",
)
assert result.success is False
assert result.error_type == ErrorType.API_DRIFT
assert "canary" in (result.error_message or "").lower()
@pytest.mark.asyncio
async def test_body_canary_silent_when_some_bodies_present(source_id, sync_engine, tmp_path):
"""No false alarm: a large walk that DOES capture bodies completes normally
(normal operation — e.g. a gallery creator who writes captions)."""
client = _FakeClient(_media_less_posts(_CANARY_MIN_SAMPLE)) # bodies present
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="backfill",
)
assert result.success is True
assert result.error_type is None
@pytest.mark.asyncio
async def test_body_canary_silent_below_min_sample(source_id, sync_engine, tmp_path):
"""Tick safety: a small walk (a few new captionless posts) is below the sample
floor and must NOT trip the canary, even with every body empty."""
client = _FakeClient(_media_less_posts(_CANARY_MIN_SAMPLE - 1), empty_body=True)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="backfill",
)
assert result.success is True
assert result.error_type is None