feat(subscribestar): flip dispatch to the native ingester (#893, Step 5)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Failing after 3m18s

SubscribeStar now downloads + verifies through the native core ingester instead
of gallery-dl — the go-live switch for milestone #71.

- download_backends: subscribestar added to NATIVE_INGESTER_PLATFORMS; a
  _NATIVE_INGESTERS registry + _resolve_native_campaign_id make _run_native_ingester
  / preview_source / verify_source_credential platform-aware. SubscribeStar's
  campaign_id IS the creator URL (no resolver); Patreon still resolves the vanity.
  preview now catches the shared NativeIngestError (covers both platforms).
- platform_lock: subscribestar serialized (one paced walk at a time).
- gallery_dl: subscribestar entry removed from PLATFORM_DEFAULTS (rule 22 — no
  fallback once native works).
- frontend SourceActions: isPatreon → isNative (patreon|subscribestar) so the
  recover/recapture actions show for subscribestar; download_service's
  cursor/mode/post_first + the preview endpoint already key on
  uses_native_ingester, so backfill/recovery/recapture/preview light up for free.
- tests: download_backends (subscribestar native), platform_lock (serialized),
  and three gallery-dl-sample tests repointed to hentaifoundry (api_credentials
  verify, gallery_dl_service skip-value, api_sources arm-no-preflight).

post_is_gated stays best-effort (can't cause junk downloads); not gating this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:03:48 -04:00
parent 82551a89d1
commit d8d8ecd78f
9 changed files with 83 additions and 48 deletions
+46 -20
View File
@@ -24,13 +24,22 @@ import asyncio
from pathlib import Path from pathlib import Path
from .gallery_dl import DownloadResult, ErrorType from .gallery_dl import DownloadResult, ErrorType
from .native_ingest_common import NativeIngestError
from .patreon_ingester import PatreonIngester from .patreon_ingester import PatreonIngester
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
from .subscribestar_ingester import SubscribeStarIngester
# Platforms whose download + verify go through the native ingester rather than # Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves every other platform (subscribestar, # gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord, pixiv,
# hentaifoundry, discord, pixiv, deviantart) unchanged. # deviantart) until they migrate too.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon"}) NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar"})
# Native ingester classes keyed by platform (uniform constructor signature, so
# _run_native_ingester / preview build whichever the source needs).
_NATIVE_INGESTERS = {
"patreon": PatreonIngester,
"subscribestar": SubscribeStarIngester,
}
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure # Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
# messages so the operator sees the exact lookup endpoint that was hit. # messages so the operator sees the exact lookup endpoint that was hit.
@@ -81,22 +90,36 @@ async def run_download(
return result, None return result, None
async def _resolve_native_campaign_id(
platform: str, url: str, cookies_path: str | None, overrides: dict,
) -> tuple[str | None, str | None]:
"""`(campaign_id, resolved_campaign_id)` for a native source. SubscribeStar's
feed id IS the creator URL (no lookup → resolved None). Patreon resolves the
campaign id from the vanity URL (resolved non-None when a lookup actually ran,
so phase 3 caches it)."""
if platform == "subscribestar":
return url, None
return await resolve_campaign_id_for_source(url, cookies_path, overrides)
async def _run_native_ingester( async def _run_native_ingester(
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory, ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
) -> tuple[DownloadResult, str | None]: ) -> tuple[DownloadResult, str | None]:
"""Patreon (today the only native platform): resolve the campaign id, then run """Run the native ingester for a native platform in a worker thread (sync
the native ingester in a worker thread (it is sync requests/subprocess). requests/subprocess). Patreon resolves a campaign id from the vanity URL;
SubscribeStar's feed id is the creator URL itself. A campaign id we cannot
resolve is a loud NOT_FOUND — never a silent empty success.
`resolved_campaign_id` is non-None only when we had to look it up from the `resolved_campaign_id` is non-None only when a lookup ran this call, so phase
vanity URL this run, so phase 3 caches it the way the old gallery-dl retry 3 caches it the way the old gallery-dl retry did.
did. A campaign id we cannot resolve is a loud NOT_FOUND — never a silent
empty success.
""" """
platform = ctx["platform"]
overrides = ctx["config_overrides"] or {} overrides = ctx["config_overrides"] or {}
campaign_id, resolved_campaign_id = await resolve_campaign_id_for_source( campaign_id, resolved_campaign_id = await _resolve_native_campaign_id(
ctx["url"], ctx["cookies_path"], overrides platform, ctx["url"], ctx["cookies_path"], overrides
) )
if not campaign_id: if not campaign_id:
# Only reachable for Patreon (SubscribeStar's campaign id is the URL).
url = ctx["url"] url = ctx["url"]
vanity = extract_vanity(url) vanity = extract_vanity(url)
return ( return (
@@ -104,7 +127,7 @@ async def _run_native_ingester(
success=False, success=False,
url=url, url=url,
artist_slug=ctx["artist_slug"], artist_slug=ctx["artist_slug"],
platform="patreon", platform=platform,
error_type=ErrorType.NOT_FOUND, error_type=ErrorType.NOT_FOUND,
error_message=( error_message=(
f"Could not resolve Patreon campaign id. source_url={url!r}; " f"Could not resolve Patreon campaign id. source_url={url!r}; "
@@ -127,7 +150,7 @@ async def _run_native_ingester(
if source_config.sleep_request is not None if source_config.sleep_request is not None
else max(0.5, rate_limit / 4) else max(0.5, rate_limit / 4)
) )
ingester = PatreonIngester( ingester = _NATIVE_INGESTERS[platform](
images_root=gdl.images_root, images_root=gdl.images_root,
cookies_path=ctx["cookies_path"], cookies_path=ctx["cookies_path"],
session_factory=sync_session_factory, session_factory=sync_session_factory,
@@ -174,10 +197,8 @@ async def preview_source(
""" """
import asyncio import asyncio
from .patreon_client import PatreonAPIError campaign_id, _ = await _resolve_native_campaign_id(
platform, url, cookies_path, config_overrides or {}
campaign_id, _ = await resolve_campaign_id_for_source(
url, cookies_path, config_overrides or {}
) )
if not campaign_id: if not campaign_id:
vanity = extract_vanity(url) vanity = extract_vanity(url)
@@ -188,7 +209,7 @@ async def preview_source(
"(cookies expired, or the creator moved/renamed?)." "(cookies expired, or the creator moved/renamed?)."
) )
} }
ingester = PatreonIngester( ingester = _NATIVE_INGESTERS[platform](
images_root=images_root, images_root=images_root,
cookies_path=cookies_path, cookies_path=cookies_path,
session_factory=sync_session_factory, session_factory=sync_session_factory,
@@ -199,7 +220,7 @@ async def preview_source(
None, None,
lambda: ingester.preview(source_id, campaign_id, page_limit=page_limit), lambda: ingester.preview(source_id, campaign_id, page_limit=page_limit),
) )
except PatreonAPIError as exc: except NativeIngestError as exc:
return {"error": f"Couldn't preview: {exc}"} return {"error": f"Couldn't preview: {exc}"}
return result return result
@@ -221,7 +242,12 @@ async def verify_source_credential(
""" """
if uses_native_ingester(platform): if uses_native_ingester(platform):
# Native ingester platforms verify via their own lightweight auth probe # Native ingester platforms verify via their own lightweight auth probe
# (resolve campaign id + one authenticated API page). Patreon today. # (one authenticated feed fetch). SubscribeStar's probe takes the creator
# URL directly; Patreon's resolves the campaign id first.
if platform == "subscribestar":
from .subscribestar_ingester import verify_subscribestar_credential
return await verify_subscribestar_credential(url, cookies_path, config_overrides)
from .patreon_ingester import verify_patreon_credential from .patreon_ingester import verify_patreon_credential
return await verify_patreon_credential(url, cookies_path, config_overrides) return await verify_patreon_credential(url, cookies_path, config_overrides)
+2 -5
View File
@@ -298,11 +298,8 @@ class GalleryDLService:
# removed at the plan-#697 cutover — it now uses the native ingester # removed at the plan-#697 cutover — it now uses the native ingester
# (services/patreon_ingester.py), not gallery-dl. # (services/patreon_ingester.py), not gallery-dl.
PLATFORM_DEFAULTS = { PLATFORM_DEFAULTS = {
"subscribestar": { # subscribestar removed — it's a native-ingester platform now (#71); the
"content_types": ["all"], # remaining entries are the gallery-dl platforms not yet migrated.
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
"filename": "{num:>02}_{filename}.{extension}",
},
"hentaifoundry": { "hentaifoundry": {
"content_types": ["all"], "content_types": ["all"],
"directory": [], "directory": [],
+4 -3
View File
@@ -21,9 +21,10 @@ from ..config import get_config
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT # Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT
# here: each runs as a self-pacing subprocess and they're lower-volume. Add a # here: each runs as a self-pacing subprocess and they're lower-volume. The
# platform here to cap it to a single concurrent walk. # native-ingester platforms are serialized (one paced scrape/API walk at a time).
SERIALIZED_PLATFORMS = frozenset({"patreon"}) # Add a platform here to cap it to a single concurrent walk.
SERIALIZED_PLATFORMS = frozenset({"patreon", "subscribestar"})
_LOCK_PREFIX = "fc:download_lock:" _LOCK_PREFIX = "fc:download_lock:"
@@ -28,7 +28,7 @@
<KebabMenu size="small" :min-width="260" label="More actions"> <KebabMenu size="small" :min-width="260" label="More actions">
<v-list-item <v-list-item
v-if="isPatreon && !running" v-if="isNative && !running"
prepend-icon="mdi-backup-restore" prepend-icon="mdi-backup-restore"
@click="emit('recover', source)" @click="emit('recover', source)"
> >
@@ -39,7 +39,7 @@
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
<v-list-item <v-list-item
v-if="isPatreon && !running" v-if="isNative && !running"
prepend-icon="mdi-text-box-search-outline" prepend-icon="mdi-text-box-search-outline"
@click="emit('recapture', source)" @click="emit('recapture', source)"
> >
@@ -49,7 +49,7 @@
already on disk without re-downloading media already on disk without re-downloading media
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
<v-divider v-if="isPatreon && !running" /> <v-divider v-if="isNative && !running" />
<v-list-item <v-list-item
base-color="error" base-color="error"
prepend-icon="mdi-delete-outline" prepend-icon="mdi-delete-outline"
@@ -74,7 +74,10 @@ const emit = defineEmits(['check', 'backfill', 'recover', 'recapture', 'remove']
const running = computed(() => props.source.backfill_state === 'running') const running = computed(() => props.source.backfill_state === 'running')
const recovering = computed(() => !!props.source.backfill_bypass_seen) const recovering = computed(() => !!props.source.backfill_bypass_seen)
const recapturing = computed(() => !!props.source.backfill_recapture) const recapturing = computed(() => !!props.source.backfill_recapture)
const isPatreon = computed(() => props.source.platform === 'patreon') // Recover / recapture are native-ingester features (ledger-bypass re-walk and
// post-text re-grab), available to every native platform — not just Patreon.
const NATIVE_PLATFORMS = ['patreon', 'subscribestar']
const isNative = computed(() => NATIVE_PLATFORMS.includes(props.source.platform))
</script> </script>
<style scoped> <style scoped>
+6 -5
View File
@@ -165,25 +165,26 @@ async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypa
return (True, "Credentials valid — the feed authenticated.") return (True, "Credentials valid — the feed authenticated.")
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify) monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
# hentaifoundry is a gallery-dl platform (subscribestar migrated to native).
await client.post("/api/credentials", json={ await client.post("/api/credentials", json={
"platform": "subscribestar", "credential_type": "cookies", "data": _NETSCAPE, "platform": "hentaifoundry", "credential_type": "cookies", "data": _NETSCAPE,
}) })
artist = Artist(name="Maewix", slug="maewix") artist = Artist(name="Maewix", slug="maewix")
db.add(artist) db.add(artist)
await db.flush() await db.flush()
db.add(Source( db.add(Source(
artist_id=artist.id, platform="subscribestar", artist_id=artist.id, platform="hentaifoundry",
url="https://www.subscribestar.com/maewix", enabled=True, config_overrides={}, url="https://www.hentai-foundry.com/user/Maewix", enabled=True, config_overrides={},
)) ))
await db.commit() await db.commit()
resp = await client.post("/api/credentials/subscribestar/verify") resp = await client.post("/api/credentials/hentaifoundry/verify")
body = await resp.get_json() body = await resp.get_json()
assert body["valid"] is True assert body["valid"] is True
assert body["last_verified"] is not None assert body["last_verified"] is not None
# The stamp is persisted on the credential record. # The stamp is persisted on the credential record.
rec = await (await client.get("/api/credentials/subscribestar")).get_json() rec = await (await client.get("/api/credentials/hentaifoundry")).get_json()
assert rec["last_verified"] is not None assert rec["last_verified"] is not None
+2 -2
View File
@@ -430,8 +430,8 @@ async def test_gallery_dl_platform_arm_skips_pre_flight(client, artist, db, monk
monkeypatch.setattr(db_mod, "verify_source_credential", _boom) monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
src = Source( src = Source(
artist_id=artist.id, platform="subscribestar", artist_id=artist.id, platform="hentaifoundry",
url="https://www.subscribestar.com/x", enabled=True, url="https://www.hentai-foundry.com/user/x", enabled=True,
) )
db.add(src) db.add(src)
await db.commit() await db.commit()
+7 -6
View File
@@ -7,15 +7,16 @@ from backend.app.services.download_backends import (
) )
def test_patreon_is_native(): def test_native_platforms():
assert uses_native_ingester("patreon") is True for platform in ("patreon", "subscribestar"):
assert "patreon" in NATIVE_INGESTER_PLATFORMS assert uses_native_ingester(platform) is True
assert platform in NATIVE_INGESTER_PLATFORMS
def test_gallery_dl_platforms_are_not_native(): def test_gallery_dl_platforms_are_not_native():
# The five platforms still served by gallery-dl must NOT route to the # The platforms still served by gallery-dl must NOT route to the native
# native ingester — guards an accidental over-broad migration. # ingester — guards an accidental over-broad migration.
for platform in ("subscribestar", "hentaifoundry", "discord", "pixiv", "deviantart"): for platform in ("hentaifoundry", "discord", "pixiv", "deviantart"):
assert uses_native_ingester(platform) is False assert uses_native_ingester(platform) is False
+2 -2
View File
@@ -259,7 +259,7 @@ def test_build_config_emits_tick_skip_value(gdl):
scans once 20 contiguous archived items are seen.""" scans once 20 contiguous archived items are seen."""
from backend.app.services.gallery_dl import TICK_SKIP_VALUE from backend.app.services.gallery_dl import TICK_SKIP_VALUE
cfg = gdl._build_config_for_source( cfg = gdl._build_config_for_source(
platform="subscribestar", platform="hentaifoundry",
source_config=SourceConfig(), source_config=SourceConfig(),
artist_slug="alice", artist_slug="alice",
skip_value=TICK_SKIP_VALUE, skip_value=TICK_SKIP_VALUE,
@@ -272,7 +272,7 @@ def test_build_config_emits_backfill_skip_value(gdl):
full post history.""" full post history."""
from backend.app.services.gallery_dl import BACKFILL_SKIP_VALUE from backend.app.services.gallery_dl import BACKFILL_SKIP_VALUE
cfg = gdl._build_config_for_source( cfg = gdl._build_config_for_source(
platform="subscribestar", platform="hentaifoundry",
source_config=SourceConfig(), source_config=SourceConfig(),
artist_slug="alice", artist_slug="alice",
skip_value=BACKFILL_SKIP_VALUE, skip_value=BACKFILL_SKIP_VALUE,
+7 -1
View File
@@ -9,8 +9,14 @@ pytestmark = pytest.mark.integration
def test_non_serialized_platform_has_no_lock(): def test_non_serialized_platform_has_no_lock():
# gallery-dl platforms aren't capped — they get no lock at all. # gallery-dl platforms aren't capped — they get no lock at all.
assert platform_lock("subscribestar", ttl_seconds=60) is None
assert platform_lock("deviantart", ttl_seconds=60) is None assert platform_lock("deviantart", ttl_seconds=60) is None
assert platform_lock("hentaifoundry", ttl_seconds=60) is None
def test_subscribestar_is_serialized():
# subscribestar is a native-ingester platform now — one paced walk at a time.
lock = platform_lock("subscribestar", ttl_seconds=60)
assert lock is not None
def test_patreon_serializes_to_one_holder(): def test_patreon_serializes_to_one_holder():