feat(download): tick/backfill modes + partial-success classifier (plan #544)
Routine subscription polls walked the entire post history every tick
even when nothing had changed, because gallery-dl's default `skip: True`
continues iterating archived posts. A creator with ~550 archived posts
(Knuxy patreon) saturates the 870s wall-clock cap before completing,
even with zero downloads needed. Plus, a tier-limited run that
downloaded hundreds of files but ran out the clock should be a
warning, not an error.
Two coupled changes, both operator-flagged 2026-06-01:
* **Tick mode (default, cron polls).** New `TICK_SKIP_VALUE = "exit:20"`
asks gallery-dl to exit after 20 contiguous archived items. Fresh
subscriptions + new-content cases still walk normally; established
subscription with zero new content exits in ~30s of HEAD requests
instead of pegging the timeout. 20 (not 5) gives headroom against
paywall warnings interleaving with archived items.
* **Backfill mode (explicit, operator-triggered).** Sticky for N runs
via new `Source.backfill_runs_remaining` (alembic 0031). While > 0,
downloads use `skip: True` + 1800s timeout. Auto-decrements per run
with early-reset to 0 when a clean run finds zero files (queue
drained). N defaults to 3 — multiple runs give the system enough
budget to finish a deep walk across timeout boundaries. New
`POST /api/sources/{id}/backfill` arms the source; "Deep scan"
button on each SourceRow (chip shows remaining count) wires it.
Plus partial-success classifier: non-zero gallery-dl exit + ≥1 file
downloaded + no source-level error fires `ErrorType.PARTIAL`, which
download_service maps to `status=\"ok\"`. The run did real work; the
next tick continues via gallery-dl's archive. No more red events for
"timed out mid-walk after downloading 300 files."
Retires `SourceConfig.skip_existing` — skip value is now derived from
the source state and passed as a separate `skip_value` parameter
through download() / _build_config_for_source(). `GD_DEFAULTS` drops
the now-dead key (was inert data after this refactor).
Tests cover:
* tick + backfill skip-value emission in _build_config_for_source
* PARTIAL classifier branch + TIER_LIMITED-wins-over-PARTIAL ordering
* SourceService.set_backfill_runs validation + persistence
* /api/sources/{id}/backfill 200/400/404 paths
* download_service auto-decrement / auto-reset / tick-mode-no-touch
* PARTIAL → status=ok in the orchestrator (no consecutive_failures bump)
This commit is contained in:
@@ -249,3 +249,69 @@ def test_truncate_log_caps_large_text(gdl):
|
||||
def test_truncate_log_passes_short_text(gdl):
|
||||
text = "small\n"
|
||||
assert gdl._truncate_log(text) == text
|
||||
|
||||
|
||||
# --- Plan #544: tick/backfill skip emission + PARTIAL classifier ------------
|
||||
|
||||
|
||||
def test_build_config_emits_tick_skip_value(gdl):
|
||||
"""Tick mode emits gallery-dl's exit:20 to short-circuit catch-up
|
||||
scans once 20 contiguous archived items are seen."""
|
||||
from backend.app.services.gallery_dl import TICK_SKIP_VALUE
|
||||
cfg = gdl._build_config_for_source(
|
||||
platform="patreon",
|
||||
source_config=SourceConfig(),
|
||||
artist_slug="alice",
|
||||
skip_value=TICK_SKIP_VALUE,
|
||||
)
|
||||
assert cfg["extractor"]["skip"] == "exit:20"
|
||||
|
||||
|
||||
def test_build_config_emits_backfill_skip_value(gdl):
|
||||
"""Backfill mode keeps gallery-dl's default skip=True so it walks the
|
||||
full post history."""
|
||||
from backend.app.services.gallery_dl import BACKFILL_SKIP_VALUE
|
||||
cfg = gdl._build_config_for_source(
|
||||
platform="patreon",
|
||||
source_config=SourceConfig(),
|
||||
artist_slug="alice",
|
||||
skip_value=BACKFILL_SKIP_VALUE,
|
||||
)
|
||||
assert cfg["extractor"]["skip"] is True
|
||||
|
||||
|
||||
def test_categorize_partial_when_files_downloaded_then_nonzero_exit(gdl):
|
||||
"""Non-zero exit + ≥1 file downloaded + no source-level error →
|
||||
PARTIAL. Downstream maps to status=ok because the next tick continues."""
|
||||
# stdout has a single downloaded file (line starting with `/`); no
|
||||
# error-class indicators in stderr; non-zero return code.
|
||||
etype, msg = gdl._categorize_error(
|
||||
return_code=1,
|
||||
stdout="/images/alice/patreon/post1/img.jpg\n",
|
||||
stderr="",
|
||||
)
|
||||
assert etype == ErrorType.PARTIAL
|
||||
assert "Downloaded 1 file" in msg
|
||||
|
||||
|
||||
def test_categorize_unknown_when_no_files_and_no_pattern(gdl):
|
||||
"""PARTIAL only kicks in when files were actually written. A non-zero
|
||||
exit with no downloads and no recognized pattern still falls through
|
||||
to UNKNOWN_ERROR — distinct from the partial-success case."""
|
||||
etype, _ = gdl._categorize_error(
|
||||
return_code=2,
|
||||
stdout="",
|
||||
stderr="some unrecognized noise\n",
|
||||
)
|
||||
assert etype == ErrorType.UNKNOWN_ERROR
|
||||
|
||||
|
||||
def test_categorize_tier_limited_wins_over_partial(gdl):
|
||||
"""A run that hit tier-limited warnings AND downloaded some files
|
||||
classifies as TIER_LIMITED (the more specific category), not PARTIAL."""
|
||||
stdout = "/images/alice/patreon/post1/img.jpg\n"
|
||||
stderr = "[patreon][warning] Not allowed to view post 123\n"
|
||||
etype, _ = gdl._categorize_error(
|
||||
return_code=1, stdout=stdout, stderr=stderr,
|
||||
)
|
||||
assert etype == ErrorType.TIER_LIMITED
|
||||
|
||||
Reference in New Issue
Block a user