Files
FabledCurator/backend/app/services/subscribestar_downloader.py
T
bvandeusen 7ac5c7e522
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m20s
refactor(native-ingest): extract native_ingest_common + BaseNativeDownloader (#899 DRY 1/3)
DRY pass commit 1 (process #594). Consolidate the helpers + download plumbing
the Patreon and SubscribeStar adapters had duplicated (SubscribeStar was
importing patreon privates — wrong owner). New backend/app/services/
native_ingest_common.py is the neutral home for:
- make_session (was _load_session ×2), retry_after_seconds + 429 constants,
  sanitize_segment, basename_from_url, post_dir_name, MediaOutcome /
  PostRecordOutcome.
- BaseNativeDownloader: the shared streaming GET (transient-retry + Range-resume)
  and validation/quarantine. Patreon + SubscribeStar downloaders now subclass it;
  each keeps only what differs (Patreon's Mux/yt-dlp video branch + detail-fetch
  enrichment; SubscribeStar nothing extra). Behavior preserved exactly; the
  divergence-bug risk (a fix to one _fetch_to_file not reaching the other) is gone.
- Folds in #899 L2: a quarantine now log.warning's path+reason (was counted only).

post_dir_name merges both date handlers (accepts trailing-Z and pre-parsed ISO).
Tests repointed to the single source at every consumer (rule 93 / §8b parity):
patreon_client/downloader, subscribestar_native. Exception-trio consolidation +
base _failure_result (2/3) and the remaining ingest_core logging (3/3) follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:32:28 -04:00

207 lines
8.3 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
import logging
import time
from collections.abc import Callable
from pathlib import Path
import requests
from .native_ingest_common import (
BaseNativeDownloader,
MediaOutcome,
PostRecordOutcome,
post_dir_name,
sanitize_segment,
)
log = logging.getLogger(__name__)
class SubscribeStarDownloader(BaseNativeDownloader):
"""Download resolved SubscribeStar media to gallery-dl's on-disk layout.
Subclasses BaseNativeDownloader for the shared streaming GET (transient-retry +
Range-resume) and validation/quarantine. No video branch (SubscribeStar serves
files directly via /post_uploads) and no detail-fetch (the body is already in
the feed HTML). 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,
)
# -- public ------------------------------------------------------------
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Download every media item of one post; return per-item outcomes.
Mirrors PatreonDownloader.download_post (two-tier skip, mid-post time-box,
recapture surfacing) minus the video branch."""
post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post)
outcomes: list[MediaOutcome] = []
for i, media in enumerate(media_items, start=1):
if should_stop():
break
try:
outcomes.append(
self._download_one(
post, media, post_dir, artist_slug, i, is_seen,
recapture=recapture,
)
)
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"SubscribeStar media failed (post %s, item %d): %s",
post.get("id"), i, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
# -- per-item ----------------------------------------------------------
def _download_one(
self,
post: dict,
media,
post_dir: Path,
artist_slug: str,
index: int,
is_seen: Callable[[object], bool],
*,
recapture: bool = False,
) -> MediaOutcome:
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
nn = f"{index:02d}"
media_path = post_dir / sanitize_segment(f"{nn}_{media.filename}")
if media_path.exists(): # tier-2: already on disk
return MediaOutcome(
media=media, status="skipped_disk", path=media_path, error=None
)
# recapture: a seen item not on disk is NOT re-downloaded (recovery's job).
if seen:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
post_dir.mkdir(parents=True, exist_ok=True)
if self._rate_limit > 0:
time.sleep(self._rate_limit)
out_path = self._fetch_get(media.url, media_path)
reason, quarantine_dest = self._validate_path(out_path, artist_slug, media.url)
if reason is not None:
return MediaOutcome(
media=media, status="quarantined", path=quarantine_dest, error=reason,
)
self._write_sidecar(post, out_path, source_url=media.url)
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
# The plain-GET streaming path (_fetch_get / _fetch_to_file) and
# _validate_path are inherited from BaseNativeDownloader.
# -- sidecar -----------------------------------------------------------
def _write_sidecar(
self, post: dict, media_path: Path, *, source_url: str | None = None
) -> Path:
"""Per-media sidecar — post-first (#856): image identity ONLY
(category/id/source_url). The post body/links live solely in _post.json."""
return self._write_sidecar_data(
post, media_path.with_suffix(".json"), source_url=source_url, minimal=True,
)
def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
minimal: bool = False,
) -> Path:
"""Serialize the post's metadata. minimal=True → per-media sidecar (image
identity only); else the full post record (body/title/date/url)."""
if minimal:
data = {"category": "subscribestar", "id": str(post.get("id") or "")}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
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"),
}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
def write_post_record(self, post: dict, artist_slug: str) -> 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."""
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,
)
post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True)
path = self._write_sidecar_data(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,
)