From bcc7266021eb7824b603bbb08ebdacc267490d66 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 23:09:37 -0400 Subject: [PATCH] feat(downloads): per-post body-capture diagnostics in the event UI (#842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator can't (and shouldn't have to) hunt worker logs to see why a recapture left a post body empty. Surface per-post handling ON THE EVENT, in the UI. The feed already requests post_type (in _FIELDS_POST), so ingest_core builds a per-post diagnostic {post_id, title, post_type, body_chars} with zero extra fetching — a 0-char body next to its post_type explains an empty post at a glance (e.g. polls/embeds whose body the API never returns). - ingest_core: accumulate post_diagnostics; thread via DownloadResult - download_service: write to DownloadEvent.metadata_['post_diagnostics'] - DownloadDetailModal: 'Post capture' section — totals + empty-body table (post_type + chars, flagged) + all-posts table; included in Copy-all - tests: ingester diag (post_type + body_chars), download_service metadata Co-Authored-By: Claude Opus 4.8 --- backend/app/services/download_service.py | 4 + backend/app/services/gallery_dl.py | 5 ++ backend/app/services/ingest_core.py | 19 +++++ .../downloads/DownloadDetailModal.vue | 80 +++++++++++++++++++ tests/test_download_service.py | 25 +++++- tests/test_patreon_ingester.py | 23 +++++- 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/backend/app/services/download_service.py b/backend/app/services/download_service.py index bb099a1..a59c6c1 100644 --- a/backend/app/services/download_service.py +++ b/backend/app/services/download_service.py @@ -477,6 +477,10 @@ class DownloadService: # recurring "post shows a zip but no images" report. Each entry is # {file, reason}; None when every archive extracted cleanly. "unextracted_archives": unextracted_archives or None, + # #842: per-post body-capture outcomes ({post_id, title, post_type, + # body_chars}) so the UI shows how each post was handled — a 0-char + # body next to its post_type explains an empty post at a glance. + "post_diagnostics": getattr(dl_result, "post_diagnostics", None) or None, } await self._update_source_health( source_id=ctx["source_id"], status=status, error_message=ev.error, diff --git a/backend/app/services/gallery_dl.py b/backend/app/services/gallery_dl.py index 6797b89..0c25eee 100644 --- a/backend/app/services/gallery_dl.py +++ b/backend/app/services/gallery_dl.py @@ -158,6 +158,11 @@ class DownloadResult: # backfilled (inline-image localization) WITHOUT re-download or unlink. Empty # on the gallery-dl path and outside recapture. relink_source_paths: list[tuple[str, str]] = field(default_factory=list) + # Native ingester (#842): per-post body-capture diagnostics + # ({post_id, title, post_type, body_chars}) surfaced on the DownloadEvent so + # the operator can see in the UI how each post was handled. Empty on the + # gallery-dl path and for posts whose body wasn't (re)captured this run. + post_diagnostics: list[dict] = field(default_factory=list) stdout: str = "" stderr: str = "" return_code: int = 0 diff --git a/backend/app/services/ingest_core.py b/backend/app/services/ingest_core.py index 933af56..c2b1665 100644 --- a/backend/app/services/ingest_core.py +++ b/backend/app/services/ingest_core.py @@ -143,6 +143,11 @@ class Ingester: # media, so phase 3 can backfill the ImageRecord's source_filehash WITHOUT # re-downloading or unlinking the file. Empty outside recapture mode. relink: list[tuple[str, str]] = [] + # #842: per-post body-capture diagnostics surfaced on the DownloadEvent so + # the operator can see IN THE UI how each post was handled — crucially the + # post_type + final body length, which explains an empty body (e.g. a poll + # whose body the API never returns) without digging through worker logs. + post_diag: list[dict] = [] downloaded = 0 errors = 0 quarantined = 0 @@ -180,6 +185,7 @@ class Ingester: written_paths=written, post_record_paths=list(post_records), relink_source_paths=list(relink), + post_diagnostics=list(post_diag), stdout="\n".join(log_lines), stderr="", return_code=return_code, @@ -264,6 +270,19 @@ class Ingester: if rec_path is not None: post_records.append(str(rec_path)) self._mark_seen(source_id, [(pkey, ppid)]) + # Read the FINAL body (write_post_record memoized any + # detail-fetched content onto the post) + post_type + # (already in the feed fieldset) for the per-post UI + # diagnostic. A body_chars of 0 with the post_type is + # the operator's "why is this one empty" answer. + pattrs = post.get("attributes") or {} + pbody = pattrs.get("content") + post_diag.append({ + "post_id": ppid, + "title": pattrs.get("title") or None, + "post_type": pattrs.get("post_type") or None, + "body_chars": len(pbody) if isinstance(pbody, str) else 0, + }) media = self.client.extract_media(post, included) if not media: diff --git a/frontend/src/components/downloads/DownloadDetailModal.vue b/frontend/src/components/downloads/DownloadDetailModal.vue index 260bf5e..4026a05 100644 --- a/frontend/src/components/downloads/DownloadDetailModal.vue +++ b/frontend/src/components/downloads/DownloadDetailModal.vue @@ -28,6 +28,54 @@
Errors{{ summary.errors ?? 0 }}
+ +