CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 23s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 53s
CI and images / integration (push) Successful in 2m50s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Patreon, SubscribeStar and Discord each carried their own copy of the same per-post download loop, file record and seen-ledger key, and every copy was another place a fix could be missed. - BaseNativeDownloader runs one post's files: the seen and on-disk skips, the mid-post time-box, pacing, validation and the per-file sidecar. Each platform overrides only where its layout differs: Discord's channel folder, file names and message-id sidecar, and Patreon's Mux video through yt-dlp. - One MediaItem and one ledger_key in native_ingest_common replace the three per-client copies. Keys are unchanged, except that a SubscribeStar file with no gallery id is now keyed by its filename instead of an empty id. - DownloadResult, ErrorType and the run-stats and tier-gated helpers move from gallery_dl.py, which now serves only HentaiFoundry, to download_result.py. The unused delegator methods on GalleryDLService go. - The download lock's platform list is the native-ingester list, not a copy. - A walk that used a credential and finished with no error type stamps that credential's last-verified time, so the credentials page shows when the account last worked. A tier-limited walk does not count: an expired session hides paid posts the same way. - The extension no longer clears a retired pixiv token on startup. - Two comments that still described every download as gallery-dl now name both backends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.9 KiB
Python
112 lines
4.9 KiB
Python
"""Native SubscribeStar media downloader — the SubscribeStar counterpart to
|
|
patreon_downloader.
|
|
|
|
Given a SubscribeStar post and its resolved `MediaItem`s
|
|
(subscribestar_client.extract_media), download the media to gallery-dl's on-disk
|
|
layout (so existing gallery-dl downloads are recognized on disk and not
|
|
re-fetched at cutover), write the post-first sidecars the importer consumes, and
|
|
report per-media outcomes.
|
|
|
|
Simpler than the Patreon downloader: SubscribeStar serves every upload as a
|
|
direct file via `/post_uploads?payload=...` (plain GET — no Mux/HLS, no yt-dlp),
|
|
and the full post body is already present in the feed HTML (no detail-endpoint
|
|
enrichment). PURE: no DB; the seen-skip is an injected predicate.
|
|
|
|
On-disk layout (matches gallery-dl's subscribestar config
|
|
`{date:%Y-%m-%d}_{id}_{title[:40]}` / `{num:>02}_{filename}`): SubscribeStar posts
|
|
have no title, so the directory is `<YYYY-MM-DD>_<post_id>_` — the display title is
|
|
synthesized by the importer from the body, so the bare dir name is on-disk only.
|
|
|
|
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
from .native_ingest_common import (
|
|
BaseNativeDownloader,
|
|
PostRecordOutcome,
|
|
post_dir_name,
|
|
)
|
|
|
|
|
|
class SubscribeStarDownloader(BaseNativeDownloader):
|
|
"""Download resolved SubscribeStar media to gallery-dl's on-disk layout.
|
|
The download loop, per-file layout and sidecars are BaseNativeDownloader's
|
|
defaults, unchanged: there is no video branch (SubscribeStar serves files
|
|
directly via /post_uploads) and no detail-fetch (the body is already in the
|
|
feed HTML). This class adds only the post record. PURE: no DB."""
|
|
|
|
def __init__(
|
|
self,
|
|
images_root: Path,
|
|
cookies_path: str | None = None,
|
|
*,
|
|
validate: bool = True,
|
|
rate_limit: float = 0.0,
|
|
session: requests.Session | None = None,
|
|
):
|
|
super().__init__(
|
|
images_root, cookies_path, platform="subscribestar",
|
|
validate=validate, rate_limit=rate_limit, session=session,
|
|
)
|
|
|
|
def _write_post_record_file(self, post: dict, sidecar_path: Path) -> Path:
|
|
"""Serialize the full post record: body, title and date."""
|
|
attrs = post.get("attributes") or {}
|
|
content = attrs.get("content")
|
|
# SubscribeStar synthesizes the post permalink from the id (matches the
|
|
# platforms/subscribestar.py derive_post_url helper).
|
|
pid = str(post.get("id") or "")
|
|
data = {
|
|
"category": "subscribestar",
|
|
"id": pid,
|
|
"post_id": pid, # ensures derive_post_url has its key on the native path
|
|
"title": attrs.get("title") if isinstance(attrs.get("title"), str) else "",
|
|
"content": content if isinstance(content, str) else "",
|
|
"published_at": attrs.get("published_at"),
|
|
}
|
|
sidecar_path.write_text(json.dumps(data, indent=2))
|
|
return sidecar_path
|
|
|
|
def write_post_record(
|
|
self, post: dict, artist_slug: str, *, revisit: bool = False,
|
|
) -> PostRecordOutcome:
|
|
"""Write the post-first `_post.json` (body/links/metadata) — the sole
|
|
writer of the post record on the native path. SubscribeStar's body is
|
|
already in the feed HTML, so no detail-fetch is needed.
|
|
|
|
`revisit=True` is the tick re-reading a post it already captured
|
|
(ingest_core's revisit window). The no-detail-fetch half of that
|
|
contract is free here — there is no detail endpoint — but the
|
|
don't-blank-a-stored-body half still applies: a chunk that parsed with
|
|
no content must not overwrite a body we already have. Same guarantee as
|
|
the Patreon downloader, for the same reason, so a walk behaves the same
|
|
on both platforms."""
|
|
attrs = post.get("attributes") or {}
|
|
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None
|
|
post_type = attrs.get("post_type") if isinstance(attrs.get("post_type"), str) else None
|
|
pid = str(post.get("id") or "")
|
|
if not pid:
|
|
return PostRecordOutcome(
|
|
path=None, post_type=post_type, title=title, body_chars=0,
|
|
)
|
|
if revisit:
|
|
body = attrs.get("content")
|
|
if not (isinstance(body, str) and body.strip()):
|
|
return PostRecordOutcome(
|
|
path=None, post_type=post_type, title=title, body_chars=0,
|
|
)
|
|
post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post)
|
|
post_dir.mkdir(parents=True, exist_ok=True)
|
|
path = self._write_post_record_file(post, post_dir / "_post.json")
|
|
body = attrs.get("content")
|
|
body_chars = len(body) if isinstance(body, str) else 0
|
|
return PostRecordOutcome(
|
|
path=path, post_type=post_type, title=title, body_chars=body_chars,
|
|
)
|