"""Platform-agnostic native-ingest core (plan #706, build on #697/#703/#704/#705). The orchestration that drives a native subscription walk — page a feed → extract media → tiered skip (seen-ledger / on-disk / dead-letter) → download → mark-seen / record-failures / checkpoint-cursor → return a gallery-dl-shaped `DownloadResult`, across tick/backfill/recovery modes — is identical for every platform. Only four things are platform-specific, and they're INJECTED at construction by a thin adapter (e.g. `PatreonIngester`): - `client` — `.iter_posts(feed_id, cursor)` yielding `(post, included, page_cursor)` + `.extract_media(post, included) -> [media]`. - `downloader`— `.download_post(post, media, artist_slug, is_seen, should_stop) -> [MediaOutcome]` (status in downloaded/ skipped_seen/skipped_disk/quarantined/error; `.path`/`.error`/`.post_id`). `should_stop()` is polled between media so the time-box is honoured mid-post. - ledger — `seen_model` + `failed_model` SQLAlchemy models (+ their on-conflict UNIQUE constraint names) and a `ledger_key(media)`. - failure map — the adapter overrides `_failure_result` (platform exception → DownloadResult.error_type) and supplies `error_base` (the exception type the walk catches) + `platform` (result label). Everything DB touches a SHORT-LIVED sync session from the injected sessionmaker — never held across a network fetch ([[db-connection-held-across-subprocess]]). Plain-HTTP homelab: no secure-context Web API. """ from __future__ import annotations import json import logging import time from collections.abc import Callable from datetime import UTC, datetime, timedelta from sqlalchemy import delete, func, select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from .gallery_dl import ( DownloadResult, ErrorType, classify_tier_gated, make_run_stats, tier_gated_message, ) from .native_ingest_common import NativeAuthError, NativeDriftError log = logging.getLogger(__name__) # Stop a tick after this many CONTIGUOUS already-have-it media (seen-ledger or # on-disk) — the cheap native equivalent of gallery-dl's `exit:20`, now free of # per-file HEADs. Headroom against paywalled/undownloadable items interleaving. _TICK_SEEN_THRESHOLD = 20 # How far back a tick keeps looking even once everything is already-have-it — # the REVISIT WINDOW. Operator, 2026-09-23, holding up a Floppystack post: # *"this post has been updated as he implements hot fixes — any chance we have a # way to scan for or see updated posts so we can update ours to match and pull # the new attachments and pictures etc."* # # A creator who edits a three-day-old post to append a hotfix build was # structurally unreachable: that post sits twenty-odd already-seen items down # the feed, so the count early-out above fired before the walk ever got to it. # Not a bug in the early-out — a COUNT cannot express "recent". # # So the early-out now needs BOTH conditions: the run of already-seen items AND # a post published before the horizon. Strictly a widening. Two properties this # shape has and a plain "walk the last N days" would not: # # * window 0 is exactly the old behaviour, so the feature has an off switch # that costs nothing to reason about; # * no window can make a tick stop EARLIER than it used to. A source paused # for months has an unseen backlog stretching well past any horizon, and # the walk still runs to the end of it — the horizon is a FLOOR on how far # to look, never a ceiling. # # The live value is `ImportSettings.download_revisit_days` (rule 25 — an # operator tuning how far back their creators edit should not need a redeploy). # This is the fallback for a caller that passes none. DEFAULT_REVISIT_DAYS = 30 # plan #705 #7: after this many failed download/validate attempts a media is # "dead-lettered" and skipped on routine tick/backfill walks (recovery still # re-attempts it). Stops a permanently-broken media re-erroring forever. DEAD_LETTER_THRESHOLD = 3 # last_error is Text but bound it so a giant traceback doesn't bloat the row. _ERROR_MAX = 1000 # plan #709: throttle the live-progress write to the running DownloadEvent to one # every ~5s — a steady cadence for the Downloads view regardless of how big/slow a # page is (page boundaries can be minutes apart on image-dense backfills, so a # page-tied update would lurch). Trivial churn (~one single-row UPDATE / 5s). _LIVE_PROGRESS_INTERVAL = 5.0 # Post-body schema-drift canary (#862). Patreon's body lives in # content/content_json_string with NO post_type gate, so a field rename (as # content→content_json_string already was) zeroes EVERY body at once — across # every artist, every walk. If a native walk records at least this many posts # and extracts a body from NONE of them, treat it as that break (fail the run # API_DRIFT) rather than silently archiving empties. A *fraction* threshold would # false-positive on gallery/art creators who legitimately post images with no # caption, so the gate is "zero across a minimum sample": a real creator nearly # always has SOME text across this many posts, a broken parser has none. Set high # enough that a small tick (a few new posts) can't trip it — only a backfill / # recapture (the operator's schema-test flow) reaches the sample. _CANARY_MIN_SAMPLE = 30 def _parse_published(raw: object) -> datetime | None: """An ISO-8601 post date from either native client, as aware UTC. Patreon's `published_at` is tz-aware with a `Z` or `+00:00` offset; SubscribeStar's is NAIVE (`_parse_ss_datetime` renders a parsed local timestamp with no zone). A naive value is read as UTC — the alternative is discarding it, and a post whose date we refuse to read is a post the revisit window can never reach. Anything unparseable returns None, which reads downstream as "not provably recent" and leaves the walk on its pre-revisit behaviour. Never raises: a date we cannot read must not fail a walk that is otherwise working. """ if not isinstance(raw, str) or not raw.strip(): return None try: parsed = datetime.fromisoformat(raw.strip().replace("Z", "+00:00")) except ValueError: return None return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) class Ingester: """Generic native-ingest orchestration. Subclass with a platform adapter (see the module docstring) — or construct directly with the keyword seams.""" def __init__( self, *, client, downloader, session_factory: Callable[[], object], seen_model, failed_model, seen_constraint: str, failed_constraint: str, ledger_key: Callable[[object], str], platform: str, error_base: type[Exception], drift_label: str | None = None, body_canary: bool = True, ): self.client = client self.downloader = downloader self.session_factory = session_factory self._seen_model = seen_model self._failed_model = failed_model self._seen_constraint = seen_constraint self._failed_constraint = failed_constraint self._ledger_key = ledger_key self._platform = platform self._error_base = error_base # Human label for the API_DRIFT message ("