feat(patreon): structured ingester results + quarantine surfacing — #704 step 1
The native ingester faked gallery-dl stdout (`Cursor:` lines, summary) and phase 3 regex-scraped it back — so Patreon run-stats were mostly zero and quarantine stats blank. We own the ingester, so it now RETURNS structured data and phase 3 reads it directly. - DownloadResult gains run_stats/cursor/posts_processed (None/0 on the gallery-dl path, which keeps the text route). - Ingester builds real run_stats from per-media outcome counts, sets the checkpoint cursor structurally (no fake `Cursor:` stdout), and counts posts processed. download_service phase 3 uses dl_result.run_stats when present; the backfill lifecycle + TIMEOUT→PARTIAL block checkpoint dl_result.cursor instead of parse_last_cursor(stdout). - #4 quarantine: PatreonDownloader reports a distinct "quarantined" MediaOutcome (with the _quarantine dest); the ingester surfaces a real files_quarantined + quarantined_paths + run_stats.quarantined_count (was hardcoded 0). Quarantined media isn't written or marked seen. - Cleanup: parse_last_cursor + _CURSOR_RE (and the now-unused `import re`) removed from gallery_dl — the structured cursor replaced the scrape. Tests: ingester result carries real run_stats/cursor/posts_processed + quarantine counts; downloader quarantines an invalid file as "quarantined"; backfill cursor tests pass cursor= structurally; dropped the parse_last_cursor tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ def _make_jpg(path: Path, split: str = "h"):
|
||||
def _make_fake_dl_result(
|
||||
*, success=True, written_paths=None, quarantined_paths=None,
|
||||
files_downloaded=0, error_type=None, error_message=None,
|
||||
stdout="", stderr="",
|
||||
stdout="", stderr="", cursor=None, run_stats=None, posts_processed=0,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
success=success,
|
||||
@@ -78,6 +78,11 @@ def _make_fake_dl_result(
|
||||
duration_seconds=1.23,
|
||||
started_at="2026-05-20T14:00:00+00:00",
|
||||
completed_at="2026-05-20T14:01:00+00:00",
|
||||
# plan #704: native ingester now returns the cursor + run_stats
|
||||
# structurally (no more stderr `Cursor:` scraping).
|
||||
cursor=cursor,
|
||||
run_stats=run_stats,
|
||||
posts_processed=posts_processed,
|
||||
)
|
||||
|
||||
|
||||
@@ -505,7 +510,7 @@ async def test_backfill_chunk_progress_advances_cursor(
|
||||
|
||||
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
|
||||
success=False, written_paths=[], files_downloaded=0,
|
||||
stderr="[patreon][debug] Cursor: 03:PAGE2:xyz\n",
|
||||
cursor="03:PAGE2:xyz",
|
||||
))
|
||||
await svc.download_source(source.id)
|
||||
|
||||
@@ -534,7 +539,7 @@ async def test_backfill_state_running_selects_backfill_mode_and_resumes(
|
||||
|
||||
svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
|
||||
success=False, written_paths=[],
|
||||
stderr="[patreon][debug] Cursor: 03:RESUME2:next\n",
|
||||
cursor="03:RESUME2:next",
|
||||
))
|
||||
await svc.download_source(source.id)
|
||||
|
||||
@@ -587,7 +592,7 @@ async def test_backfill_cap_exhaustion_stalls(
|
||||
|
||||
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
|
||||
success=False, written_paths=[],
|
||||
stderr="[patreon][debug] Cursor: 03:MORE:left\n",
|
||||
cursor="03:MORE:left",
|
||||
))
|
||||
await svc.download_source(source.id)
|
||||
|
||||
@@ -623,7 +628,7 @@ async def test_backfill_stuck_guard_stalls_after_two_no_advance(
|
||||
"config_overrides": {"_backfill_state": "running", "_backfill_cursor": "stuck"},
|
||||
"backfill_runs_remaining": 5,
|
||||
}
|
||||
stuck = _make_fake_dl_result(success=False, stderr="Cursor: stuck\n")
|
||||
stuck = _make_fake_dl_result(success=False, cursor="stuck")
|
||||
|
||||
await svc._apply_backfill_lifecycle(ctx, stuck)
|
||||
await db.commit()
|
||||
@@ -661,7 +666,7 @@ async def test_backfill_timeout_chunk_reclassified_to_ok(
|
||||
success=False, files_downloaded=3,
|
||||
error_type=ErrorType.TIMEOUT,
|
||||
error_message="Download timed out",
|
||||
stderr="[patreon][debug] Cursor: 03:NEXT:page\n",
|
||||
cursor="03:NEXT:page",
|
||||
))
|
||||
event_id = await svc.download_source(source.id)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from backend.app.services.gallery_dl import (
|
||||
ErrorType,
|
||||
GalleryDLService,
|
||||
SourceConfig,
|
||||
parse_last_cursor,
|
||||
)
|
||||
|
||||
|
||||
@@ -316,28 +315,6 @@ def test_categorize_tier_limited_wins_over_partial(gdl):
|
||||
return_code=1, stdout=stdout, stderr=stderr,
|
||||
)
|
||||
assert etype == ErrorType.TIER_LIMITED
|
||||
|
||||
|
||||
# --- Cursor parsing (plan #689 / native ingester #697) ---------------------
|
||||
# parse_last_cursor is retained post-cutover: the native Patreon ingester emits
|
||||
# `Cursor: <token>` per page into DownloadResult.stdout and download_service
|
||||
# checkpoints the last one. (The gallery-dl Patreon `files`/`cursor` config and
|
||||
# the Mux yt-dlp Referer block were removed at the #697 cutover, along with the
|
||||
# tests that pinned them.)
|
||||
|
||||
|
||||
def test_parse_last_cursor_returns_last_match_across_streams():
|
||||
# One `Cursor: <token>` line per page; the last is the furthest-progressed
|
||||
# resume point.
|
||||
stderr = (
|
||||
"[patreon][debug] Cursor: 03:AAAA:bbb\n"
|
||||
"[urllib3] GET ...\n"
|
||||
"[patreon][debug] Cursor: 03:CCCC:ddd\n"
|
||||
)
|
||||
assert parse_last_cursor("", stderr) == "03:CCCC:ddd"
|
||||
|
||||
|
||||
def test_parse_last_cursor_scans_stdout_too_and_none_when_absent():
|
||||
assert parse_last_cursor("Cursor: 99:ZZZ", "") == "99:ZZZ"
|
||||
assert parse_last_cursor("no marker here", "still nothing") is None
|
||||
assert parse_last_cursor("", "") is None
|
||||
# (The cursor-parsing tests were removed in plan #704: parse_last_cursor is gone
|
||||
# — the native ingester now carries its checkpoint cursor as a structured
|
||||
# DownloadResult.cursor field instead of a `Cursor:` log line to scrape.)
|
||||
|
||||
@@ -165,6 +165,22 @@ def test_skip_seen(tmp_path):
|
||||
assert not (tmp_path / "artist-x").exists()
|
||||
|
||||
|
||||
def test_invalid_file_is_quarantined(tmp_path):
|
||||
"""plan #704 (#4): a downloaded file that fails validation is moved to
|
||||
_quarantine and reported as a 'quarantined' outcome carrying that path —
|
||||
distinct from a download 'error'."""
|
||||
bad_url = "https://cdn.patreon.com/bad.png"
|
||||
session = _FakeSession({bad_url: b"this is not a valid PNG"})
|
||||
dl = _downloader(tmp_path, session=session, validate=True)
|
||||
outcomes = dl.download_post(
|
||||
_post(), [_img("bad.png", url=bad_url)], "artist-x"
|
||||
)
|
||||
assert outcomes[0].status == "quarantined"
|
||||
assert outcomes[0].path is not None
|
||||
assert "_quarantine" in str(outcomes[0].path)
|
||||
assert outcomes[0].path.is_file()
|
||||
|
||||
|
||||
def test_skip_disk(tmp_path):
|
||||
dl = _downloader(tmp_path)
|
||||
post_dir = tmp_path / "artist-x" / "patreon" / "2026-05-01_1001_My Post Title"
|
||||
|
||||
@@ -63,11 +63,13 @@ class _FakeClient:
|
||||
|
||||
class _FakeDownloader:
|
||||
"""Stub PatreonDownloader. Honors the injected is_seen (tier-1); media in
|
||||
`on_disk` report skipped_disk (tier-2); everything else downloads."""
|
||||
`on_disk` report skipped_disk (tier-2); media in `quarantine` report
|
||||
quarantined; everything else downloads."""
|
||||
|
||||
def __init__(self, tmp_path, on_disk=None):
|
||||
def __init__(self, tmp_path, on_disk=None, quarantine=None):
|
||||
self.tmp_path = tmp_path
|
||||
self.on_disk = set(on_disk or ())
|
||||
self.quarantine = set(quarantine or ())
|
||||
self.download_calls = 0
|
||||
|
||||
def download_post(self, post, media_items, artist_slug, *, is_seen):
|
||||
@@ -78,6 +80,10 @@ class _FakeDownloader:
|
||||
elif _ledger_key(m) in self.on_disk:
|
||||
p = self.tmp_path / f"{m.post_id}_{m.filename}"
|
||||
outcomes.append(MediaOutcome(media=m, status="skipped_disk", path=p, error=None))
|
||||
elif _ledger_key(m) in self.quarantine:
|
||||
self.download_calls += 1
|
||||
q = self.tmp_path / "_quarantine" / f"{m.post_id}_{m.filename}"
|
||||
outcomes.append(MediaOutcome(media=m, status="quarantined", path=q, error="bad image"))
|
||||
else:
|
||||
self.download_calls += 1
|
||||
p = self.tmp_path / f"{m.post_id}_{m.filename}"
|
||||
@@ -137,9 +143,36 @@ async def test_tick_downloads_unseen_and_marks_seen(source_id, sync_engine, tmp_
|
||||
assert result.return_code == 0
|
||||
assert result.files_downloaded == 2
|
||||
assert len(result.written_paths) == 2
|
||||
# plan #704: structured run_stats carry the real counts.
|
||||
assert result.run_stats["downloaded_count"] == 2
|
||||
assert result.posts_processed == 1
|
||||
assert _count_ledger(sync_engine, source_id) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quarantined_media_surfaced_in_result(source_id, sync_engine, tmp_path):
|
||||
"""plan #704 (#4): a media that fails validation is reported as quarantined —
|
||||
a real files_quarantined count + paths + run_stats — not a blank 0, and is
|
||||
NOT written or marked seen."""
|
||||
m1, m2 = _media("p1", 1), _media("p1", 2)
|
||||
client = _FakeClient([(None, [("p1", [m1, m2])])])
|
||||
downloader = _FakeDownloader(tmp_path, quarantine={_ledger_key(m2)})
|
||||
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||
|
||||
result = ing.run(
|
||||
source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
url="https://patreon.com/ingest", mode="tick",
|
||||
)
|
||||
assert result.files_downloaded == 1 # only m1
|
||||
assert result.files_quarantined == 1 # m2
|
||||
assert len(result.quarantined_paths) == 1
|
||||
assert result.run_stats["quarantined_count"] == 1
|
||||
assert result.run_stats["downloaded_count"] == 1
|
||||
assert len(result.written_paths) == 1 # quarantined NOT written
|
||||
# Quarantined media is NOT marked seen (a fixed file may be re-fetched).
|
||||
assert _count_ledger(sync_engine, source_id) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_skips_seen_via_ledger(source_id, sync_engine, tmp_path):
|
||||
m1 = _media("p1", 1)
|
||||
@@ -206,8 +239,10 @@ async def test_backfill_reaches_bottom_signals_complete(source_id, sync_engine,
|
||||
assert result.return_code == 0
|
||||
assert result.error_type is None
|
||||
assert result.files_downloaded == 2
|
||||
# The page-2 cursor was emitted for checkpointing.
|
||||
assert "Cursor: CUR2" in result.stdout
|
||||
# The page-2 cursor is carried structurally for checkpointing (plan #704).
|
||||
assert result.cursor == "CUR2"
|
||||
assert result.posts_processed == 2
|
||||
assert result.run_stats["downloaded_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -245,7 +280,7 @@ async def test_backfill_budget_cut_returns_partial_with_progress(
|
||||
assert result.return_code == -1
|
||||
assert result.files_downloaded == 1 # post1 downloaded before the cut
|
||||
assert downloader.download_calls == 1 # post2's body never ran
|
||||
assert "Cursor: CUR1" in result.stdout
|
||||
assert result.cursor == "CUR1" # structured checkpoint (plan #704)
|
||||
|
||||
|
||||
# --- recovery -------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user