feat(download): cursor-paged Patreon backfill for large catalogs
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 25s
CI / integration (push) Successful in 2m57s

Large Patreon creators (Anduo: weekly 50-120-image Reports back months =
thousands of files) couldn't backfill: each run re-walked newest→oldest
from the top, and gallery-dl's polite ~0.75s/request HEAD walk alone
exceeded the 1170s subprocess budget, so the run died during enumeration
with 0 files written and NO forward progress — re-stranding every time
(event #40411).

Checkpoint gallery-dl's pagination cursor so each backfill window advances
the frontier:

- gallery_dl.py: SourceConfig.resume_cursor; _build_config_for_source sets
  extractor.patreon.cursor=<resume> (PLATFORM_DEFAULTS leave log-only True
  for a fresh run); parse_last_cursor() pulls the last emitted
  'Cursor: <token>' from stdout+stderr — survives a timed-out run since the
  TimeoutExpired path returns partial output.
- download_service.py: phase2 stays in BACKFILL mode while a cursor is
  pending (even after the run budget drains) and threads resume_cursor;
  _apply_backfill_lifecycle() checkpoints the advancing cursor each
  non-completing run, completes on a clean rc=0 finish (walk reached
  bottom), and a stuck-guard clears the cursor after 2 non-advancing runs
  so a wedged walk can't re-strand forever.

patreon-only (sole platform with a resumable cursor); other platforms keep
the simple counter semantics. Cursor state lives in config_overrides JSON
(patreon_campaign_id precedent) — no migration. Time-budget ladder
(1170/1350/1500) unchanged.

Plan #689.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 23:44:31 -04:00
parent 86efbf7f2c
commit 593f65c9cc
4 changed files with 297 additions and 40 deletions
+35
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
import subprocess
import sys
import tempfile
@@ -98,6 +99,15 @@ class SourceConfig:
Source.backfill_runs_remaining column at the download_service layer
and is passed to _build_config_for_source as `skip_value`. Same for
the per-run subprocess timeout.
`resume_cursor` is RUNTIME state, not persisted operator config: the
cursor-paged backfill checkpoint (see parse_last_cursor + the
download_service backfill lifecycle). When set on a patreon backfill
run, gallery-dl resumes its newest→oldest walk from that pagination
cursor instead of restarting from the top. It is threaded by
download_service from config_overrides["_backfill_cursor"]; SourceConfig
deliberately does NOT read it in from_dict (config_overrides also holds
operator config, and resume_cursor must only apply in backfill mode).
"""
content_types: list[str] = field(default_factory=lambda: ["all"])
sleep: float | None = None
@@ -106,6 +116,7 @@ class SourceConfig:
filename_pattern: str | None = None
save_metadata: bool = True
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
resume_cursor: str | None = None
@classmethod
def from_dict(cls, data: dict) -> SourceConfig:
@@ -154,6 +165,21 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
# gallery-dl logs its pagination cursor (when extractor.*.cursor is truthy)
# as `Cursor: <token>` — once per fetched page, at debug verbosity. The LAST
# such line is the furthest-progressed page, i.e. the resume point. We scan
# both streams (the debug log lands on stderr, but be defensive) and take the
# final match; passing it back as extractor.patreon.cursor resumes the walk.
# Survives a timed-out run too: the TimeoutExpired path returns the partial
# stdout/stderr, so an interrupted backfill still yields its last cursor.
_CURSOR_RE = re.compile(r"Cursor:\s*(\S+)")
def parse_last_cursor(stdout: str, stderr: str) -> str | None:
matches = _CURSOR_RE.findall(f"{stdout or ''}\n{stderr or ''}")
return matches[-1] if matches else None
class GalleryDLService:
"""Service for executing gallery-dl downloads."""
@@ -349,6 +375,15 @@ class GalleryDLService:
]
else:
platform_section["files"] = source_config.content_types
# Cursor-paged backfill: resume the newest→oldest walk from the
# checkpoint saved by the previous run instead of restarting from
# the top. Without this, a large catalog (Anduo's weekly
# image-dense Reports) never reaches its frontier inside the
# subprocess budget — the HEAD walk alone times out, 0 files
# written, no progress (event #40411). The PLATFORM_DEFAULTS leave
# `cursor: True` (log-only) for the first/fresh run.
if source_config.resume_cursor:
platform_section["cursor"] = source_config.resume_cursor
elif platform == "hentaifoundry":
if "pictures" in source_config.content_types or "all" in source_config.content_types:
platform_section["include"] = "all"