"""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 sqlalchemy import delete, func, select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from .gallery_dl import DownloadResult, ErrorType, make_run_stats 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 # 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 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 ("