feat(subscribestar): flip dispatch to the native ingester (#893, Step 5)
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:
@@ -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)
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user