refactor(downloads): unify phase-2 dispatch in download_backends — A5 (plan #707)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 29s
CI / frontend-build (push) Successful in 31s
CI / integration (push) Successful in 2m59s

Mirror verify_source_credential: download_backends.run_download is now the single
download entry, so that module is the ONE registry of how each platform both
downloads AND verifies — the seam that makes adding a platform a bounded job
(write its adapter construction next to its verify). The native-ingester
construction + campaign-id resolution moves out of download_service into
download_backends._run_native_ingester.

download_service.download_source drops its `if uses_native_ingester ... else
gdl.download` branch and calls one `self._run_download(...)` (a thin delegate to
run_download passing the service's gdl + sync sessionmaker). mode (tick/backfill/
recovery) is still chosen there from the backfill state machine and threaded
through. Removed the now-unused PatreonIngester / resolve_campaign_id_for_source
imports from download_service.

Tests: the phase-2 stub seam moves from svc._run_patreon_ingester to
svc._run_download (helper + the db-release test); the two native-construction
tests repoint to download_backends.run_download (patching
download_backends.resolve_campaign_id_for_source / PatreonIngester).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:22:48 -04:00
parent b211900390
commit e47fa0cf4b
3 changed files with 167 additions and 134 deletions
+110
View File
@@ -20,8 +20,13 @@ function regardless of platform:
from __future__ import annotations
import asyncio
from pathlib import Path
from .gallery_dl import DownloadResult, ErrorType
from .patreon_ingester import PatreonIngester
from .patreon_resolver import resolve_campaign_id_for_source
# Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves every other platform (subscribestar,
# hentaifoundry, discord, pixiv, deviantart) unchanged.
@@ -34,6 +39,111 @@ def uses_native_ingester(platform: str) -> bool:
return platform in NATIVE_INGESTER_PLATFORMS
async def run_download(
*,
ctx: dict,
source_config,
skip_value: bool | str,
mode: str | None,
gdl,
sync_session_factory,
) -> tuple[DownloadResult, str | None]:
"""Uniform download across backends — the download counterpart to
`verify_source_credential`, so this module is the ONE place that knows how
each platform both downloads AND verifies (the seam that makes adding a
platform a bounded job).
Returns `(DownloadResult, resolved_campaign_id)`; `resolved_campaign_id` is
non-None only when a native vanity lookup ran this call (so phase 3 caches
it). Native platforms route through their ingester in `mode`
(tick/backfill/recovery); gallery-dl platforms run the subprocess. The caller
(download_service) prepares `source_config`/`skip_value`/`mode` from the
backfill state machine and owns phase 3.
"""
platform = ctx["platform"]
if uses_native_ingester(platform):
return await _run_native_ingester(
ctx, source_config, mode, gdl, sync_session_factory
)
result = await gdl.download(
url=ctx["url"],
artist_slug=ctx["artist_slug"],
platform=platform,
source_config=source_config,
cookies_path=ctx["cookies_path"],
auth_token=ctx["auth_token"],
skip_value=skip_value,
)
return result, None
async def _run_native_ingester(
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
) -> tuple[DownloadResult, str | None]:
"""Patreon (today the only native platform): resolve the campaign id, then run
the native ingester in a worker thread (it is sync requests/subprocess).
`resolved_campaign_id` is non-None only when we had to look it up from the
vanity URL this run, so phase 3 caches it the way the old gallery-dl retry
did. A campaign id we cannot resolve is a loud NOT_FOUND — never a silent
empty success.
"""
overrides = ctx["config_overrides"] or {}
campaign_id, resolved_campaign_id = await resolve_campaign_id_for_source(
ctx["url"], ctx["cookies_path"], overrides
)
if not campaign_id:
return (
DownloadResult(
success=False,
url=ctx["url"],
artist_slug=ctx["artist_slug"],
platform="patreon",
error_type=ErrorType.NOT_FOUND,
error_message=(
"Could not resolve Patreon campaign id from the source URL "
"(vanity lookup failed — cookies expired or creator moved?)"
),
),
None,
)
# Honor the operator's existing rate-limit knobs on the native path (plan
# #703): the global download_rate_limit_seconds (gallery-dl's `rate_limit`,
# here on the gdl service) paces media downloads; page fetches use the
# per-source sleep_request override, else `max(0.5, rate_limit/4)` — the same
# API-pacing default gallery-dl applied as its `sleep-request`.
rate_limit = gdl._rate_limit
request_sleep = (
source_config.sleep_request
if source_config.sleep_request is not None
else max(0.5, rate_limit / 4)
)
ingester = PatreonIngester(
images_root=gdl.images_root,
cookies_path=ctx["cookies_path"],
session_factory=sync_session_factory,
validate=gdl._validate_files,
rate_limit=rate_limit,
request_sleep=request_sleep,
)
loop = asyncio.get_running_loop()
dl_result = await loop.run_in_executor(
None,
lambda: ingester.run(
source_id=ctx["source_id"],
campaign_id=campaign_id,
artist_slug=ctx["artist_slug"],
url=ctx["url"],
mode=mode,
resume_cursor=source_config.resume_cursor,
time_budget_seconds=source_config.timeout,
posts_base=int(overrides.get("_backfill_posts", 0)),
),
)
return dl_result, resolved_campaign_id
async def verify_source_credential(
*,
platform: str,
+20 -95
View File
@@ -24,7 +24,7 @@ from sqlalchemy.orm import joinedload
from ..models import Artist, DownloadEvent, Source
from .credential_service import CredentialService
from .download_backends import uses_native_ingester
from .download_backends import run_download, uses_native_ingester
from .gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
@@ -37,8 +37,6 @@ from .gallery_dl import (
truncate_log,
)
from .importer import Importer
from .patreon_ingester import PatreonIngester
from .patreon_resolver import resolve_campaign_id_for_source
from .platforms import auth_type_for
from .scheduler_service import set_platform_cooldown
@@ -122,32 +120,23 @@ class DownloadService:
else:
skip_value = TICK_SKIP_VALUE
resolved_campaign_id: str | None = None
# Phase 2 dispatch is uniform across backends (download_backends.
# run_download — the download counterpart to verify_source_credential):
# native platforms run their ingester in `mode` (zero per-file HEADs,
# native cursor/resume, loud drift detection); gallery-dl platforms run
# the subprocess. Either returns a DownloadResult-shaped object so phase 3
# is untouched. `mode` is None for gallery-dl (run_download ignores it).
mode: str | None = None
if uses_native_ingester(ctx["platform"]):
# Native ingester (plan #697) fully replaces gallery-dl for Patreon
# in phase 2 — zero per-file HEADs, native cursor/resume, loud drift
# detection. Returns a DownloadResult-shaped object so phase 3 is
# untouched. The gallery-dl Patreon path + its campaign-id retry are
# removed at the step-5 cutover.
if in_backfill and bypass_seen:
mode = "recovery"
elif in_backfill:
mode = "backfill"
else:
mode = "tick"
dl_result, resolved_campaign_id = await self._run_patreon_ingester(
ctx, source_config, mode,
)
else:
dl_result = await self.gdl.download(
url=ctx["url"],
artist_slug=ctx["artist_slug"],
platform=ctx["platform"],
source_config=source_config,
cookies_path=ctx["cookies_path"],
auth_token=ctx["auth_token"],
skip_value=skip_value,
)
dl_result, resolved_campaign_id = await self._run_download(
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
)
# A backfill chunk that hit its time-box but made forward progress is
# NORMAL, not a failure — reclassify TIMEOUT → PARTIAL so it reads as
@@ -171,82 +160,18 @@ class DownloadService:
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
)
async def _run_patreon_ingester(
self, ctx: dict, source_config, mode: str,
async def _run_download(
self, *, ctx: dict, source_config, skip_value, mode: str | None,
) -> tuple[DownloadResult, str | None]:
"""Phase-2 Patreon branch: resolve the campaign id, then run the native
ingester in a worker thread (it is sync requests/subprocess).
Returns (DownloadResult, resolved_campaign_id). `resolved_campaign_id` is
non-None only when we had to look it up from the vanity URL this run, so
phase 3 caches it on the source the same way the old gallery-dl retry did.
A campaign id we cannot resolve is a loud failure (NOT_FOUND) — never a
silent empty success.
"""
overrides = ctx["config_overrides"] or {}
# Shared resolution path (override / id: URL / vanity lookup) — the same
# helper the credential-verify probe uses. resolved_campaign_id is
# non-None only when a vanity lookup ran, so phase 3 caches it.
campaign_id, resolved_campaign_id = await resolve_campaign_id_for_source(
ctx["url"], ctx["cookies_path"], overrides
"""Phase-2 dispatch → `download_backends.run_download`, passing this
service's gdl + sync sessionmaker. Kept as a thin instance method so tests
can stub the whole phase-2 dispatch on the service, and so the per-backend
construction lives in download_backends (the backend registry)."""
return await run_download(
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
gdl=self.gdl, sync_session_factory=self.sync_session_factory,
)
if not campaign_id:
return (
DownloadResult(
success=False,
url=ctx["url"],
artist_slug=ctx["artist_slug"],
platform="patreon",
error_type=ErrorType.NOT_FOUND,
error_message=(
"Could not resolve Patreon campaign id from the source "
"URL (vanity lookup failed — cookies expired or creator "
"moved?)"
),
),
None,
)
# Honor the operator's existing rate-limit knobs on the native path
# (plan #703): the global download_rate_limit_seconds (gallery-dl's
# `rate_limit`, here on self.gdl) paces media downloads. Page fetches use
# the per-source sleep_request override, else `max(0.5, rate_limit/4)` —
# the SAME API-pacing default gallery-dl applied as its `sleep-request`
# (gallery_dl._get_default_config), so the rate-limited /api/posts
# endpoint stays politely paced without over-throttling.
rate_limit = self.gdl._rate_limit
request_sleep = (
source_config.sleep_request
if source_config.sleep_request is not None
else max(0.5, rate_limit / 4)
)
ingester = PatreonIngester(
images_root=self.gdl.images_root,
cookies_path=ctx["cookies_path"],
session_factory=self.sync_session_factory,
validate=self.gdl._validate_files,
rate_limit=rate_limit,
request_sleep=request_sleep,
)
loop = asyncio.get_running_loop()
dl_result = await loop.run_in_executor(
None,
lambda: ingester.run(
source_id=ctx["source_id"],
campaign_id=campaign_id,
artist_slug=ctx["artist_slug"],
url=ctx["url"],
mode=mode,
resume_cursor=source_config.resume_cursor,
time_budget_seconds=source_config.timeout,
# plan #704 #5: seed the live posts badge from prior chunks so the
# ingester writes a monotonic absolute mid-walk (it owns the key).
posts_base=int(overrides.get("_backfill_posts", 0)),
),
)
return dl_result, resolved_campaign_id
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
source = (await self.async_session.execute(
select(Source).options(joinedload(Source.artist)).where(Source.id == source_id)
+37 -39
View File
@@ -89,7 +89,7 @@ def _make_fake_dl_result(
def _fake_gdl_with_result(result):
fake = MagicMock()
fake.download = AsyncMock(return_value=result)
# Real values for the attrs _run_patreon_ingester reads off the gdl service
# Real values for the attrs run_download's native branch reads off the gdl
# (the native pacing config, plan #703) — a MagicMock would break the
# arithmetic (max(0.5, rate_limit/4)).
fake._rate_limit = 3.0
@@ -105,19 +105,24 @@ def _fake_gdl_with_result(result):
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."""
"""Route the phase-2 dispatch (download_backends.run_download via
DownloadService._run_download, plan #707 A5) 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; the
native construction/resolution by the run_download tests below. Returns a list
capturing (ctx, source_config, skip_value, 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})
async def fake(*, ctx, source_config, skip_value, mode):
calls.append({
"ctx": ctx, "source_config": source_config,
"skip_value": skip_value, "mode": mode,
})
return result, resolved_campaign_id
svc._run_patreon_ingester = fake
svc._run_download = fake
return calls
@@ -285,20 +290,19 @@ async def test_patreon_resolved_campaign_id_is_cached(
@pytest.mark.asyncio
async def test_run_patreon_ingester_resolves_vanity_and_runs(
async def test_run_download_native_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
"""run_download (native branch, plan #707 A5): 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_backends as db_mod
from backend.app.services.gallery_dl import SourceConfig
_artist, source = seed_artist_and_source
monkeypatch.setattr(
dl_mod, "resolve_campaign_id_for_source",
db_mod, "resolve_campaign_id_for_source",
AsyncMock(return_value=("4242", "4242")),
)
run_kwargs = {}
@@ -311,21 +315,19 @@ async def test_run_patreon_ingester_resolves_vanity_and_runs(
run_kwargs.update(kw)
return _make_fake_dl_result(success=True, written_paths=[])
monkeypatch.setattr(dl_mod, "PatreonIngester", _FakeIngester)
monkeypatch.setattr(db_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 = {
"platform": "patreon",
"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",
result, resolved = await db_mod.run_download(
ctx=ctx, source_config=SourceConfig.from_dict({}), skip_value=True,
mode="tick",
gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
sync_session_factory=MagicMock(),
)
assert result.success is True
assert resolved == "4242"
@@ -334,33 +336,29 @@ async def test_run_patreon_ingester_resolves_vanity_and_runs(
@pytest.mark.asyncio
async def test_run_patreon_ingester_unresolvable_fails_loud(
async def test_run_download_native_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 import download_backends as db_mod
from backend.app.services.gallery_dl import ErrorType, SourceConfig
_artist, source = seed_artist_and_source
monkeypatch.setattr(
dl_mod, "resolve_campaign_id_for_source",
db_mod, "resolve_campaign_id_for_source",
AsyncMock(return_value=(None, None)),
)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
sync_session_factory=MagicMock(),
)
ctx = {
"platform": "patreon",
"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",
result, resolved = await db_mod.run_download(
ctx=ctx, source_config=SourceConfig.from_dict({}), skip_value=True,
mode="tick", gdl=MagicMock(), sync_session_factory=MagicMock(),
)
assert result.success is False
assert result.error_type == ErrorType.NOT_FOUND
@@ -926,11 +924,11 @@ async def test_releases_db_connections_before_subprocess(
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
async def fake_ingest(ctx, source_config, mode):
async def fake_ingest(*, ctx, source_config, skip_value, mode):
seen["closed_before_phase2"] = closed["async"]
return _make_fake_dl_result(success=True, written_paths=[], stdout=""), None
svc._run_patreon_ingester = fake_ingest
svc._run_download = fake_ingest
event_id = await svc.download_source(source.id)
assert seen["closed_before_phase2"] is True