refactor(ingest): per-post handling into run stdout via a downloader outcome (#842)
Two corrections from operator review: 1. Reuse the existing 'Raw stdout' panel instead of a bespoke structured UI section — the native ingester now writes a per-post line into the run stdout (parity with gallery-dl's per-file stdout), so the per-post handling shows in the panel the operator already uses. 2. DRY: stop re-reading post['attributes'] inline in ingest_core. write_post_record now returns a PostRecordOutcome (path, post_type, title, body_chars) — mirroring the download_post -> MediaOutcome contract — and the downloader owns the read; ingest_core only formats the outcome into the log line. Reverts the post_diagnostics metadata field + DownloadDetailModal 'Post capture' section added earlier. Per-post line: 'post <id> [<post_type>] body: N chars' (+ ' — EMPTY' when 0), so an empty body is self-explanatory by post_type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -60,11 +60,10 @@ def _make_fake_dl_result(
|
||||
*, success=True, written_paths=None, quarantined_paths=None,
|
||||
files_downloaded=0, error_type=None, error_message=None,
|
||||
stdout="", stderr="", cursor=None, run_stats=None, posts_processed=0,
|
||||
relink_source_paths=None, post_diagnostics=None,
|
||||
relink_source_paths=None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
relink_source_paths=relink_source_paths or [],
|
||||
post_diagnostics=post_diagnostics or [],
|
||||
success=success,
|
||||
url="https://patreon.com/alice",
|
||||
artist_slug="alice",
|
||||
@@ -821,28 +820,6 @@ async def test_recapture_mode_selected_and_flag_cleared_on_complete(
|
||||
assert "_backfill_recapture" not in co
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_diagnostics_written_to_event_metadata(
|
||||
db, db_sync, tmp_path, seed_artist_and_source,
|
||||
):
|
||||
"""#842: per-post body diagnostics land on the DownloadEvent.metadata_ so the
|
||||
UI can show how each post was handled (post_type + body length)."""
|
||||
_artist, source = seed_artist_and_source
|
||||
diag = [
|
||||
{"post_id": "p1", "title": "Has body", "post_type": "image_file", "body_chars": 42},
|
||||
{"post_id": "p2", "title": "Empty poll", "post_type": "poll", "body_chars": 0},
|
||||
]
|
||||
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
|
||||
success=True, post_diagnostics=diag,
|
||||
))
|
||||
await svc.download_source(source.id)
|
||||
|
||||
ev = (await db.execute(
|
||||
select(DownloadEvent).order_by(DownloadEvent.id.desc()).limit(1)
|
||||
)).scalar_one()
|
||||
assert ev.metadata_["post_diagnostics"] == diag
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_error_type_maps_to_ok_status(
|
||||
db, db_sync, tmp_path, seed_artist_and_source,
|
||||
|
||||
@@ -18,6 +18,7 @@ from backend.app.services.patreon_client import MediaItem
|
||||
from backend.app.services.patreon_downloader import (
|
||||
MediaOutcome,
|
||||
PatreonDownloader,
|
||||
PostRecordOutcome,
|
||||
_sanitize,
|
||||
)
|
||||
from backend.app.utils.sidecar import find_sidecar
|
||||
@@ -646,14 +647,17 @@ def test_write_post_record_writes_enriched_post_only_sidecar(tmp_path):
|
||||
)
|
||||
post = _post()
|
||||
post["attributes"]["content"] = "" # media-less post, empty feed body
|
||||
sc = dl.write_post_record(post, "artist-x")
|
||||
rec = dl.write_post_record(post, "artist-x")
|
||||
|
||||
assert sc is not None
|
||||
assert sc.name == "_post.json" # can't collide with a media `NN_*.json`
|
||||
data = json.loads(sc.read_text())
|
||||
assert isinstance(rec, PostRecordOutcome)
|
||||
assert rec.path is not None
|
||||
assert rec.path.name == "_post.json" # can't collide with a media `NN_*.json`
|
||||
data = json.loads(rec.path.read_text())
|
||||
assert data["id"] == "1001"
|
||||
assert data["content"] == "<p>text post body</p>"
|
||||
assert calls == ["1001"]
|
||||
# Outcome reports the captured body's shape (detail-fetched here).
|
||||
assert rec.body_chars == len("<p>text post body</p>")
|
||||
|
||||
|
||||
def test_write_post_record_none_without_post_id(tmp_path):
|
||||
@@ -661,4 +665,6 @@ def test_write_post_record_none_without_post_id(tmp_path):
|
||||
images_root=tmp_path, cookies_path=None, validate=False,
|
||||
session=_FakeSession(),
|
||||
)
|
||||
assert dl.write_post_record({"id": "", "attributes": {}}, "artist-x") is None
|
||||
rec = dl.write_post_record({"id": "", "attributes": {}}, "artist-x")
|
||||
assert rec.path is None
|
||||
assert rec.body_chars == 0
|
||||
|
||||
@@ -24,7 +24,7 @@ from backend.app.services.patreon_client import (
|
||||
PatreonAuthError,
|
||||
PatreonDriftError,
|
||||
)
|
||||
from backend.app.services.patreon_downloader import MediaOutcome
|
||||
from backend.app.services.patreon_downloader import MediaOutcome, PostRecordOutcome
|
||||
from backend.app.services.patreon_ingester import PatreonIngester, _ledger_key
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -137,7 +137,14 @@ class _FakeDownloader:
|
||||
self.post_records += 1
|
||||
p = self.tmp_path / f"{post.get('id')}__post.json"
|
||||
p.write_text("{}")
|
||||
return p
|
||||
attrs = post.get("attributes") or {}
|
||||
body = attrs.get("content")
|
||||
return PostRecordOutcome(
|
||||
path=p,
|
||||
post_type=attrs.get("post_type"),
|
||||
title=attrs.get("title"),
|
||||
body_chars=len(body) if isinstance(body, str) else 0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -654,13 +661,10 @@ async def test_backfill_skips_already_captured_post_but_recapture_forces_it(
|
||||
# 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
|
||||
# #842: per-post body diagnostic carries post_type + body length so the UI can
|
||||
# show how each post was handled (and explain empties by post_type).
|
||||
assert len(res_recap.post_diagnostics) == 1
|
||||
diag = res_recap.post_diagnostics[0]
|
||||
assert diag["post_id"] == "p1"
|
||||
assert diag["post_type"] == "image_file"
|
||||
assert diag["body_chars"] == len("<p>body p1</p>")
|
||||
# #842: per-post handling is written into the SAME run stdout (reuses the
|
||||
# existing "Raw stdout" panel) — post_type + body length, so an empty body is
|
||||
# self-explanatory by its post_type.
|
||||
assert f"post p1 [image_file] body: {len('<p>body p1</p>')} chars" in res_recap.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user