feat(downloads): native Patreon verify + uniform backend dispatch (plan #697)
The credential Verify button still ran gallery-dl --simulate for Patreon after the cutover — testing the wrong path (and prone to the vanity "Failed to extract campaign ID" the native resolver fixes). Wire it to the native ingester, behind a DRY dispatch so callers never branch on platform. - services/download_backends.py (new): the ONE place that knows which platforms are native vs gallery-dl. `uses_native_ingester(platform)` is the shared predicate; `verify_source_credential(...)` is the uniform probe (same (ok|None, message) contract for both backends). As a platform migrates, it moves into NATIVE_INGESTER_PLATFORMS here and BOTH download routing and verify switch together. - PatreonClient.verify_auth(campaign_id): one authenticated /api/posts fetch → True (valid) / False (401/403/HTML-login) / None (drift or network — inconclusive, not a credential verdict). - patreon_ingester.verify_patreon_credential(): resolve campaign id, then verify_auth — the verify counterpart to the download path. - patreon_resolver.resolve_campaign_id_for_source(): extracted the override / id:-URL / vanity resolution into ONE helper now shared by the download ingester and verify (download_service no longer carries its own copy + regex; −`import re`). - download_service: routes on uses_native_ingester() instead of inline `== "patreon"` (3 sites); uses the shared resolver. - api/credentials: calls verify_source_credential — no platform branch. Tests: verify_auth mapping, resolve_campaign_id_for_source (override/id:/ vanity/none), the dispatch predicate, verify_patreon_credential glue, credentials endpoint proves Patreon uses the native path (gallery-dl verify asserted not-called); repointed the gallery-dl verify test to subscribestar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,12 +19,22 @@ 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"
|
||||
|
||||
# A source URL of the form `.../id:<digits>` already carries the campaign id
|
||||
# (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the
|
||||
# two paths don't overlap.
|
||||
_ID_URL_RE = re.compile(r"/id:(\d+)")
|
||||
_VANITY_RE = re.compile(
|
||||
r"^https?://(?:www\.)?patreon\.com/(?:c/)?(?!id:)([^/?#]+)",
|
||||
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"
|
||||
@@ -100,3 +110,38 @@ async def resolve_campaign_id(
|
||||
Never raises."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _sync_lookup, vanity, 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:<digits>` URL → a
|
||||
vanity lookup against the campaigns API. Returns
|
||||
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None ONLY when
|
||||
a vanity lookup actually ran, so the caller knows to cache 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
|
||||
vanity = extract_vanity(url)
|
||||
if vanity:
|
||||
resolved = await resolve_campaign_id(vanity, cookies_path)
|
||||
return resolved, resolved
|
||||
return None, None
|
||||
|
||||
Reference in New Issue
Block a user