"""Patreon vanity → campaign ID resolver. When gallery-dl fails with "Failed to extract campaign ID" on a Patreon source URL, call resolve_campaign_id(vanity, cookies_path) to look up the campaign ID via Patreon's public campaigns API. Caller then retries with `patreon.com/id:` to bypass the broken vanity path. Uses the stdlib `requests` (already a transitive dep via gallery-dl) so we don't add `aiohttp`. The sync call is wrapped in run_in_executor so the calling async code stays non-blocking. Never raises. Returns None on any error (network, auth, parse, missing match). """ from __future__ import annotations import asyncio import http.cookiejar import logging import os import re import requests log = logging.getLogger(__name__) _CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns" _POSTS_API = "https://www.patreon.com/api/posts" # A source URL of the form `.../id:` already carries the campaign id # (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the # two paths don't overlap. # # Patreon serves the creator vanity under several path prefixes — bare # (`patreon.com/Atole`), `c/` (`patreon.com/c/Atole`), and `cw/` # (`patreon.com/cw/Atole`, its current "creator workspace" URL). The optional # prefix group must list `cw/` BEFORE `c/` so the longer prefix wins — otherwise # `cw/Atole` matches the bare branch and yields vanity="cw" (operator-flagged # 2026-06-07: every `/cw/` source failed resolution on vanity="cw"). _ID_URL_RE = re.compile(r"/id:(\d+)") # A single-post permalink — patreon.com/posts/- (or bare # /posts/). The trailing digits are the post id; the creator's # campaign is resolved from the post itself (operator-flagged 2026-06-07: a # /posts/ source resolved vanity="posts"). `posts/` is excluded from the vanity # regex so it never masquerades as a creator slug. _POST_URL_RE = re.compile(r"/posts/(?:[^/?#]*-)?(\d+)(?:[/?#]|$)") _VANITY_PREFIXES = ("cw/", "c/") _VANITY_RE = re.compile( r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!(?:id:|posts/))([^/?#]+)", re.IGNORECASE, ) _USER_AGENT = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" ) _TIMEOUT_SECONDS = 10.0 # Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has # proven unreliable (returns empty `data` for creators that clearly exist — # operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes # the campaign id out of the creator page's bootstrap JSON. We do the same as a # fallback: fetch the creator page HTML and pull the first campaign id out of # any of these embeddings (ordered most- to least-specific). _PAGE_CAMPAIGN_ID_PATTERNS = ( re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'), re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'), re.compile(r"/api/campaigns/(\d+)"), re.compile(r'"campaign_id":\s*"?(\d+)'), ) def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None: if not cookies_path or not os.path.isfile(cookies_path): return None try: jar = http.cookiejar.MozillaCookieJar(cookies_path) jar.load(ignore_discard=True, ignore_expires=True) return jar except (OSError, http.cookiejar.LoadError) as exc: log.debug("Could not load cookies from %s: %s", cookies_path, exc) return None def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None: """Resolve a vanity to a campaign id: try the campaigns API first (cheap, structured), then fall back to scraping the creator page (robust against the API's empty-data failures). Returns None only when both miss.""" return _lookup_via_api(vanity, cookies_path) or _lookup_via_page( vanity, cookies_path ) def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None: jar = _load_cookie_jar(cookies_path) headers = { "User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json", } params = { "filter[vanity]": vanity, "fields[campaign]": "name", } try: resp = requests.get( _CAMPAIGNS_URL, params=params, headers=headers, cookies=jar, timeout=_TIMEOUT_SECONDS, ) except requests.RequestException as exc: log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc) return None if resp.status_code != 200: log.warning( "Patreon campaigns API returned HTTP %d for vanity=%s", resp.status_code, vanity, ) return None try: payload = resp.json() except ValueError as exc: log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc) return None if not isinstance(payload, dict): return None data = payload.get("data") if not isinstance(data, list) or not data: return None first = data[0] if isinstance(data[0], dict) else None campaign_id = first.get("id") if first else None if not isinstance(campaign_id, str) or not campaign_id: return None log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id) return campaign_id def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None: """The Patreon campaign's display name for `vanity` via the campaigns API (`fields[campaign]=name`), used to name the Artist at add-time (#130). None on any failure — the caller falls back to the vanity handle. Sync: call from an executor.""" jar = _load_cookie_jar(cookies_path) try: resp = requests.get( _CAMPAIGNS_URL, params={"filter[vanity]": vanity, "fields[campaign]": "name"}, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}, cookies=jar, timeout=_TIMEOUT_SECONDS, ) if resp.status_code != 200: return None data = resp.json().get("data") except (requests.RequestException, ValueError) as exc: log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc) return None if not isinstance(data, list) or not data or not isinstance(data[0], dict): return None name = (data[0].get("attributes") or {}).get("name") return name.strip() if isinstance(name, str) and name.strip() else None def _scrape_campaign_id(html: str) -> str | None: """First campaign id found in creator-page HTML via the known embeddings.""" if not isinstance(html, str): return None for pat in _PAGE_CAMPAIGN_ID_PATTERNS: m = pat.search(html) if m: return m.group(1) return None def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None: """Fallback: GET the creator page and scrape the campaign id from the page bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths Patreon redirects between. Never raises.""" jar = _load_cookie_jar(cookies_path) headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"} # Try the bare vanity and every known creator-path prefix Patreon # redirects between (c/, cw/) — the one the source used isn't known here # (extract_vanity already stripped it). page_urls = [f"https://www.patreon.com/{vanity}"] page_urls += [f"https://www.patreon.com/{p}{vanity}" for p in _VANITY_PREFIXES] for page_url in page_urls: try: resp = requests.get( page_url, headers=headers, cookies=jar, timeout=_TIMEOUT_SECONDS, allow_redirects=True, ) except requests.RequestException as exc: log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc) continue if resp.status_code != 200: continue campaign_id = _scrape_campaign_id(resp.text) if campaign_id: log.info( "Resolved Patreon vanity=%s → campaign_id=%s via creator page", vanity, campaign_id, ) return campaign_id return None def _lookup_campaign_from_post(post_id: str, cookies_path: str | None) -> str | None: """Resolve the owning campaign id from a single post id via the Patreon post API (`/api/posts/?include=campaign`). A `/posts/` source URL points at one post, but a subscription walks the whole creator — so we follow the post to its campaign. Never raises.""" jar = _load_cookie_jar(cookies_path) headers = {"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"} params = {"include": "campaign", "fields[campaign]": "name"} url = f"{_POSTS_API}/{post_id}" try: resp = requests.get( url, params=params, headers=headers, cookies=jar, timeout=_TIMEOUT_SECONDS, ) except requests.RequestException as exc: log.warning("Patreon post lookup failed for post=%s: %s", post_id, exc) return None if resp.status_code != 200: log.warning("Patreon post API returned HTTP %d for post=%s", resp.status_code, post_id) return None try: payload = resp.json() except ValueError: return None data = payload.get("data") if isinstance(payload, dict) else None if not isinstance(data, dict): return None campaign = ((data.get("relationships") or {}).get("campaign") or {}).get("data") or {} campaign_id = campaign.get("id") if isinstance(campaign_id, str) and campaign_id: log.info("Resolved Patreon post=%s → campaign_id=%s", post_id, campaign_id) return campaign_id return None async def resolve_campaign_id( vanity: str, cookies_path: str | None, ) -> str | None: """Async wrapper. Returns the campaign id string or None on any failure. Never raises.""" loop = asyncio.get_running_loop() return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path) async def resolve_campaign_from_post( post_id: str, cookies_path: str | None, ) -> str | None: """Async wrapper around _lookup_campaign_from_post. Never raises.""" loop = asyncio.get_running_loop() return await loop.run_in_executor( None, _lookup_campaign_from_post, post_id, cookies_path ) def extract_vanity(url: str) -> str | None: """The vanity slug from a Patreon creator URL, or None for an `id:` URL.""" m = _VANITY_RE.match(url or "") return m.group(1) if m else None async def resolve_campaign_id_for_source( url: str, cookies_path: str | None, overrides: dict | None, ) -> tuple[str | None, str | None]: """Resolve a Patreon source to its campaign id — the single resolution path shared by the download ingester and the credential-verify probe. Order: cached `patreon_campaign_id` override → an `id:` URL → a `/posts/` permalink (resolve the owning campaign from the post) → a vanity lookup against the campaigns API. Returns `(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None whenever a lookup actually ran, so the caller caches it on the source (the override/id: paths needed no lookup). `(None, None)` when unresolvable. Never raises. """ overrides = overrides or {} cached = overrides.get("patreon_campaign_id") if cached: return cached, None id_match = _ID_URL_RE.search(url or "") if id_match: return id_match.group(1), None # A single-post URL → follow the post to its creator's campaign so the # source subscribes to the whole feed (a subscription isn't one post). post_match = _POST_URL_RE.search(url or "") if post_match: resolved = await resolve_campaign_from_post(post_match.group(1), cookies_path) return resolved, resolved vanity = extract_vanity(url) if vanity: resolved = await resolve_campaign_id(vanity, cookies_path) return resolved, resolved return None, None