feat(patreon): phase-2 ingester integration — build step 3 (plan #697)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 31s
CI / integration (push) Successful in 2m59s

Branch download_service phase 2 by platform: Patreon now routes to the
native PatreonIngester (zero per-file HEADs, native cursor/resume, loud
drift detection) instead of gallery-dl; the other 5 platforms are
unchanged. The ingester returns a DownloadResult-shaped object so phase 1
(DB setup) and phase 3 (import → pHash → thumbs → ML) are untouched.

Three modes wired from config_overrides state:
  - tick: skip seen (tier-1 ledger + tier-2 disk), early-out after N
    contiguous already-have-it items.
  - backfill: full-history time-boxed chunk, cursor checkpoint via
    gallery-dl-style "Cursor: <token>" lines in stdout (reuses the #693
    lifecycle + parse_last_cursor verbatim).
  - recovery: backfill that BYPASSES the tier-1 seen-ledger so
    dropped-and-deleted near-dups get re-fetched and re-evaluated under
    the current pHash threshold. Rides the #693 state machine via a
    _backfill_bypass_seen flag, cleared on completion / stop.

The seen-ledger uses short-lived sync sessions (injected sessionmaker),
never held across the walk (avoids the connection-reaping trap). Campaign
id resolves from override, an id: URL, or a vanity lookup; unresolvable =
loud NOT_FOUND, never a silent empty success.

Tests: new test_patreon_ingester.py (modes, ledger skip/idempotency,
budget→PARTIAL, recovery bypass, tier-2 disk, drift). The patreon-oriented
download_service tests now drive the ingester branch via a stub; the
gallery-dl campaign-retry test is replaced by resolution/caching coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:38:42 -04:00
parent 2ec7d86a3b
commit 96c30eba13
6 changed files with 984 additions and 109 deletions
+177 -69
View File
@@ -94,6 +94,23 @@ def _fake_gdl_with_result(result):
return fake
def _stub_patreon_ingester(svc, result, resolved_campaign_id=None):
"""Route the Patreon phase-2 branch (plan #697 native ingester) to a canned
DownloadResult so these tests exercise download_service's
phase-2-result→phase-3 handling (status mapping, backfill lifecycle, health)
without the real ingester's network/DB. The ingester itself is covered by
test_patreon_ingester.py. Returns a list capturing (ctx, source_config, mode)
per call so a test can assert mode / resume_cursor threading."""
calls = []
async def fake(ctx, source_config, mode):
calls.append({"ctx": ctx, "source_config": source_config, "mode": mode})
return result, resolved_campaign_id
svc._run_patreon_ingester = fake
return calls
@pytest.mark.asyncio
async def test_download_source_attaches_written_files(
db, db_sync, tmp_path, seed_artist_and_source,
@@ -112,12 +129,13 @@ async def test_download_source_attaches_written_files(
_make_jpg(f1, split="h")
_make_jpg(f2, split="v")
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
result = _make_fake_dl_result(
success=True,
written_paths=[str(f1), str(f2)],
files_downloaded=2,
stdout=f"{f1}\n{f2}\n",
))
)
fake_gdl = _fake_gdl_with_result(result)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
@@ -137,6 +155,7 @@ async def test_download_source_attaches_written_files(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, result)
event_id = await svc.download_source(source.id)
ev = (await db.execute(
@@ -214,40 +233,19 @@ async def test_in_flight_idempotency_returns_existing_event(
@pytest.mark.asyncio
async def test_patreon_retry_caches_campaign_id(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
async def test_patreon_resolved_campaign_id_is_cached(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""plan #697: the native ingester resolves a Patreon vanity → campaign id
on the fly (replacing gallery-dl's reactive campaign-id retry). When phase 2
reports a freshly-resolved id, phase 3 caches it on the source so later runs
skip the lookup. Stub the ingester to report a resolved id; assert it lands
in config_overrides."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
failed = _make_fake_dl_result(
success=False, written_paths=[], stdout="",
stderr="[patreon][error] Failed to extract campaign ID for alice\n",
)
succeeded = _make_fake_dl_result(success=True, written_paths=[])
download_calls = []
async def fake_download(url, **k):
download_calls.append(url)
return failed if len(download_calls) == 1 else succeeded
fake_gdl = MagicMock()
fake_gdl.download = fake_download
fake_gdl._compute_run_stats = lambda *a, **k: {
"exit_code": 0, "downloaded_count": 0, "skipped_count": 0,
"per_item_failures": 0, "warning_count": 0, "tier_gated_count": 0,
}
fake_gdl._extract_errors_warnings = lambda *a, **k: ""
fake_gdl._truncate_log = lambda x, **k: x
monkeypatch.setattr(
"backend.app.services.download_service.resolve_campaign_id",
AsyncMock(return_value="99"),
)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
@@ -261,19 +259,102 @@ async def test_patreon_retry_caches_campaign_id(
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(
svc, _make_fake_dl_result(success=True, written_paths=[]),
resolved_campaign_id="99",
)
await svc.download_source(source.id)
assert len(download_calls) == 2
assert "id:99" in download_calls[1]
overrides = db_sync.execute(
select(Source.config_overrides).where(Source.id == source.id)
).scalar_one()
assert overrides.get("patreon_campaign_id") == "99"
@pytest.mark.asyncio
async def test_run_patreon_ingester_resolves_vanity_and_runs(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""_run_patreon_ingester: with no cached campaign id, resolve the vanity and
pass the resolved id straight to the ingester; report it back so phase 3 can
cache it."""
from backend.app.services import download_service as dl_mod
from backend.app.services.download_service import DownloadService
from backend.app.services.gallery_dl import SourceConfig
_artist, source = seed_artist_and_source
monkeypatch.setattr(
dl_mod, "resolve_campaign_id", AsyncMock(return_value="4242"),
)
run_kwargs = {}
class _FakeIngester:
def __init__(self, **kw):
pass
def run(self, **kw):
run_kwargs.update(kw)
return _make_fake_dl_result(success=True, written_paths=[])
monkeypatch.setattr(dl_mod, "PatreonIngester", _FakeIngester)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
importer=MagicMock(), cred_service=MagicMock(),
sync_session_factory=MagicMock(),
)
ctx = {
"source_id": source.id, "url": "https://patreon.com/alice",
"artist_slug": "alice", "cookies_path": None,
"config_overrides": {},
}
result, resolved = await svc._run_patreon_ingester(
ctx, SourceConfig.from_dict({}), "tick",
)
assert result.success is True
assert resolved == "4242"
assert run_kwargs["campaign_id"] == "4242"
assert run_kwargs["mode"] == "tick"
@pytest.mark.asyncio
async def test_run_patreon_ingester_unresolvable_fails_loud(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""A campaign id we can't resolve is a loud NOT_FOUND failure, never a
silent empty success."""
from backend.app.services import download_service as dl_mod
from backend.app.services.download_service import DownloadService
from backend.app.services.gallery_dl import ErrorType, SourceConfig
_artist, source = seed_artist_and_source
monkeypatch.setattr(
dl_mod, "resolve_campaign_id", AsyncMock(return_value=None),
)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
sync_session_factory=MagicMock(),
)
ctx = {
"source_id": source.id, "url": "https://patreon.com/alice",
"artist_slug": "alice", "cookies_path": None,
"config_overrides": {},
}
result, resolved = await svc._run_patreon_ingester(
ctx, SourceConfig.from_dict({}), "tick",
)
assert result.success is False
assert result.error_type == ErrorType.NOT_FOUND
assert resolved is None
# --- FC-3d: finalize hook updates Source health columns -------------------
@@ -376,8 +457,12 @@ async def test_finalize_skipped_preserves_failures_clears_error(db):
def _backfill_svc(db, db_sync, tmp_path, result):
"""DownloadService wired with a fake gdl returning `result` + a real
importer (so an empty written_paths just attaches nothing)."""
"""DownloadService wired with a real importer (so empty written_paths just
attaches nothing) whose Patreon phase-2 branch is stubbed to return `result`.
The seeded source is Patreon, so phase 2 routes to the native ingester
(plan #697); the stub returns the canned result + captures the per-call
(ctx, source_config, mode). Returns (svc, ingester_calls)."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
@@ -395,7 +480,8 @@ def _backfill_svc(db, db_sync, tmp_path, result):
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
return svc, fake_gdl
calls = _stub_patreon_ingester(svc, result)
return svc, calls
@pytest.mark.asyncio
@@ -430,28 +516,25 @@ async def test_backfill_chunk_progress_advances_cursor(
async def test_backfill_state_running_selects_backfill_mode_and_resumes(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""state=='running' drives backfill mode (skip:True + chunk budget) and
threads the stored cursor to gallery-dl as the resume point."""
from backend.app.services.gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
)
"""state=='running' drives backfill mode (chunk budget) and threads the
stored cursor to the native ingester as the resume point."""
from backend.app.services.gallery_dl import BACKFILL_CHUNK_SECONDS
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:RESUME:here"}
source.backfill_runs_remaining = 5
await db.commit()
svc, fake_gdl = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
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",
))
await svc.download_source(source.id)
kwargs = fake_gdl.download.call_args.kwargs
assert kwargs["skip_value"] is BACKFILL_SKIP_VALUE
assert kwargs["source_config"].resume_cursor == "03:RESUME:here"
assert kwargs["source_config"].timeout == BACKFILL_CHUNK_SECONDS
assert len(calls) == 1
assert calls[0]["mode"] == "backfill"
assert calls[0]["source_config"].resume_cursor == "03:RESUME:here"
assert calls[0]["source_config"].timeout == BACKFILL_CHUNK_SECONDS
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
@@ -605,6 +688,32 @@ async def test_tick_mode_when_not_running_leaves_state_untouched(
assert (co or {}).get("_backfill_state") is None
@pytest.mark.asyncio
async def test_recovery_mode_selected_and_flag_cleared_on_complete(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""plan #697: `_backfill_bypass_seen` alongside a running backfill selects
recovery mode (ingester bypasses the seen-ledger). A clean rc=0 walk
completes the shared lifecycle AND clears the bypass flag so the next tick
honors the ledger again."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_bypass_seen": True}
source.backfill_runs_remaining = 5
await db.commit()
svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
))
await svc.download_source(source.id)
assert calls[0]["mode"] == "recovery"
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_state") == "complete"
assert "_backfill_bypass_seen" not in co
@pytest.mark.asyncio
async def test_partial_error_type_maps_to_ok_status(
db, db_sync, tmp_path, seed_artist_and_source,
@@ -642,6 +751,7 @@ async def test_partial_error_type_maps_to_ok_status(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, fake_result)
event_id = await svc.download_source(source.id)
ev = (await db.execute(
@@ -677,10 +787,11 @@ async def test_download_enqueues_thumbnail_and_ml_per_attached_image(
_make_jpg(f1, split="h")
_make_jpg(f2, split="v")
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
result = _make_fake_dl_result(
success=True, written_paths=[str(f1), str(f2)],
files_downloaded=2, stdout=f"{f1}\n{f2}\n",
))
)
fake_gdl = _fake_gdl_with_result(result)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
@@ -713,6 +824,7 @@ async def test_download_enqueues_thumbnail_and_ml_per_attached_image(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, result)
await svc.download_source(source.id)
# Two files attached → two thumbnail enqueues + two ML enqueues, IDs
@@ -727,11 +839,11 @@ async def test_download_enqueues_thumbnail_and_ml_per_attached_image(
async def test_releases_db_connections_before_subprocess(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""Phase 1's DB connections must be released BEFORE the (up to ~19.5-min
in backfill) gallery-dl subprocess, so they don't idle-die and strand
phase 3 with ConnectionDoesNotExistError (Anduo #40014). Spy on the
async session close and assert it happened before gdl.download runs;
phase 3 must still finalize the event."""
"""Phase 1's DB connections must be released BEFORE the (multi-minute)
phase-2 fetch, so they don't idle-die and strand phase 3 with
ConnectionDoesNotExistError (Anduo #40014). Spy on the async session close
and assert it happened before the phase-2 work (here the native Patreon
ingester) runs; phase 3 must still finalize the event."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
@@ -747,19 +859,9 @@ async def test_releases_db_connections_before_subprocess(
monkeypatch.setattr(db, "close", spy_close)
seen = {}
async def fake_download(url, **k):
seen["closed_before_download"] = closed["async"]
return _make_fake_dl_result(success=True, written_paths=[], stdout="")
fake_gdl = MagicMock()
fake_gdl.download = fake_download
fake_gdl._compute_run_stats = lambda *a, **k: {
"exit_code": 0, "downloaded_count": 0, "skipped_count": 0,
"per_item_failures": 0, "warning_count": 0, "tier_gated_count": 0,
}
fake_gdl._extract_errors_warnings = lambda *a, **k: ""
fake_gdl._truncate_log = lambda x, **k: x
fake_gdl = _fake_gdl_with_result(
_make_fake_dl_result(success=True, written_paths=[], stdout="")
)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
@@ -776,9 +878,15 @@ async def test_releases_db_connections_before_subprocess(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
async def fake_ingest(ctx, source_config, mode):
seen["closed_before_phase2"] = closed["async"]
return _make_fake_dl_result(success=True, written_paths=[], stdout=""), None
svc._run_patreon_ingester = fake_ingest
event_id = await svc.download_source(source.id)
assert seen["closed_before_download"] is True
assert seen["closed_before_phase2"] is True
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()