817a002c2b
Phase-1 steps 2-4 of moving SubscribeStar off gallery-dl onto the native core
ingester. SubscribeStar has no JSON:API, so the client scrapes HTML; the
platform-agnostic core (ingest_core) is unchanged.
- subscribestar_client.py: HTML-scrape read path. iter_posts pages via the
creator page → infinite_scroll-next_page href → JSON {html} fragments
(campaign_id = creator URL; no resolver). extract_media reads the per-post
data-gallery JSON manifest (id/original_filename/type/url). post_record_key,
post_meta, and post_is_gated (best-effort locked-teaser marker, pending a live
locked sample). Loud auth/drift taxonomy (SubscribeStar{API,Auth,Drift}Error).
Parser validated against the real Step-0 fixtures.
- subscribestar_downloader.py: mirrors PatreonDownloader minus the Mux/yt-dlp
branch (SubscribeStar serves files directly via /post_uploads). gallery-dl
on-disk layout so existing downloads dedup on disk at cutover. Post-first:
_post.json owns the body/links; per-media sidecar carries image identity only.
- subscribestar_ingester.py: thin adapter wiring client/downloader/the
SubscribeStar ledgers into the core; ledger_key = filehash else
post_id:media_id; SubscribeStar failure mapping. verify_subscribestar_credential.
- tests: client parsing/pagination/media/gating/record-key/dates, downloader
layout/sidecar/post-record/skip-seen, ingester ledger_key + failure mapping.
Not yet wired into dispatch (Step 5) — these modules are inert until then.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""Native SubscribeStar ingester — the SubscribeStar ADAPTER over the
|
|
platform-agnostic core (`ingest_core.Ingester`).
|
|
|
|
Thin counterpart to patreon_ingester: wires the SubscribeStar client/downloader/
|
|
ledger models/constraints/key into the core and supplies the SubscribeStar
|
|
failure mapping. The three modes (tick / backfill / recovery / recapture), the
|
|
seen + dead-letter ledgers, cursor checkpointing, and the post-first capture all
|
|
live in the core — identical to Patreon. `download_service.download_source`
|
|
drives `SubscribeStarIngester.run` exactly as it drives the Patreon one.
|
|
|
|
`campaign_id` is the creator URL (the client derives host + slug from it), so no
|
|
campaign-id resolver is needed. FC runs on a plain-HTTP homelab; nothing here
|
|
uses a secure-context Web API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
from ..models import SubscribeStarFailedMedia, SubscribeStarSeenMedia
|
|
from .gallery_dl import DownloadResult, ErrorType
|
|
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
|
|
from .subscribestar_client import (
|
|
MediaItem,
|
|
SubscribeStarAPIError,
|
|
SubscribeStarAuthError,
|
|
SubscribeStarClient,
|
|
SubscribeStarDriftError,
|
|
)
|
|
from .subscribestar_downloader import SubscribeStarDownloader
|
|
|
|
__all__ = [
|
|
"DEAD_LETTER_THRESHOLD",
|
|
"SubscribeStarIngester",
|
|
"_ledger_key",
|
|
"verify_subscribestar_credential",
|
|
]
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_LEDGER_KEY_MAX = 128
|
|
|
|
|
|
def _ledger_key(media: MediaItem) -> str:
|
|
"""Stable per-media identity for the cross-run seen-ledger. SubscribeStar's
|
|
full-res URL is an opaque `/post_uploads?payload=...` (no content hash), so
|
|
`media.filehash` is normally None and the stable proxy is the gallery item id
|
|
scoped to its post: `<post_id>:<media_id>`. Bounded to the column width."""
|
|
if media.filehash:
|
|
return media.filehash
|
|
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
|
|
|
|
|
|
class SubscribeStarIngester(Ingester):
|
|
"""Walk a SubscribeStar creator's posts, download unseen media, return a
|
|
`DownloadResult`. A thin adapter over `ingest_core.Ingester`; `client` /
|
|
`downloader` are injectable seams so unit tests run without network."""
|
|
|
|
def __init__(
|
|
self,
|
|
images_root: Path,
|
|
cookies_path: str | None,
|
|
session_factory: Callable[[], object],
|
|
*,
|
|
validate: bool = True,
|
|
rate_limit: float = 0.0,
|
|
request_sleep: float = 0.0,
|
|
client: SubscribeStarClient | None = None,
|
|
downloader: SubscribeStarDownloader | None = None,
|
|
):
|
|
self.images_root = Path(images_root)
|
|
self.cookies_path = str(cookies_path) if cookies_path else None
|
|
resolved_client = (
|
|
client
|
|
if client is not None
|
|
else SubscribeStarClient(cookies_path, request_sleep=request_sleep)
|
|
)
|
|
resolved_downloader = (
|
|
downloader
|
|
if downloader is not None
|
|
else SubscribeStarDownloader(
|
|
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit,
|
|
)
|
|
)
|
|
super().__init__(
|
|
client=resolved_client,
|
|
downloader=resolved_downloader,
|
|
session_factory=session_factory,
|
|
seen_model=SubscribeStarSeenMedia,
|
|
failed_model=SubscribeStarFailedMedia,
|
|
seen_constraint="uq_subscribestar_seen_media_source_id",
|
|
failed_constraint="uq_subscribestar_failed_media_source_id",
|
|
ledger_key=_ledger_key,
|
|
platform="subscribestar",
|
|
error_base=SubscribeStarAPIError,
|
|
)
|
|
|
|
# -- failure mapping (SubscribeStar exception taxonomy) ----------------
|
|
|
|
def _failure_result(self, exc: Exception, _result) -> DownloadResult:
|
|
"""Map a client-level exception to a loud, typed failed DownloadResult —
|
|
never a silent zero-download "success". SubscribeStarAuthError and
|
|
SubscribeStarDriftError both subclass SubscribeStarAPIError, so match them
|
|
before the generic HTTP/transport fallthrough."""
|
|
message = str(exc)
|
|
if isinstance(exc, SubscribeStarAuthError):
|
|
error_type = ErrorType.AUTH_ERROR
|
|
elif isinstance(exc, SubscribeStarDriftError):
|
|
error_type = ErrorType.API_DRIFT
|
|
message = f"SubscribeStar markup changed — scraper needs update: {message}"
|
|
else:
|
|
status = getattr(exc, "status_code", None)
|
|
if status == 429:
|
|
error_type = ErrorType.RATE_LIMITED
|
|
elif status == 404:
|
|
error_type = ErrorType.NOT_FOUND
|
|
elif status is not None:
|
|
error_type = ErrorType.HTTP_ERROR
|
|
else:
|
|
error_type = ErrorType.NETWORK_ERROR
|
|
log.warning("SubscribeStar ingest failed (%s): %s", error_type.value, message)
|
|
result = _result(
|
|
success=False, return_code=1,
|
|
error_type=error_type, error_message=message,
|
|
)
|
|
if error_type == ErrorType.RATE_LIMITED:
|
|
result.retry_after_seconds = getattr(exc, "retry_after", None)
|
|
return result
|
|
|
|
|
|
async def verify_subscribestar_credential(
|
|
url: str,
|
|
cookies_path: str | None,
|
|
overrides: dict | None,
|
|
) -> tuple[bool | None, str]:
|
|
"""Native SubscribeStar credential probe — fetches ONE feed page via
|
|
SubscribeStarClient.verify_auth. `campaign_id` is just the creator URL (no
|
|
resolver). Returns the uniform `(ok, message)` contract so
|
|
download_backends.verify_credential treats it like the gallery-dl probe."""
|
|
client = SubscribeStarClient(cookies_path)
|
|
loop = asyncio.get_running_loop()
|
|
return await loop.run_in_executor(None, client.verify_auth, url)
|