docs: add patreon campaign-ID resolver design spec
Auto-heal "Failed to extract campaign ID" failures by resolving via Patreon's campaigns API, caching the ID in Source.metadata_, and retrying once within the same task run. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
# Patreon Campaign-ID Auto-Resolver — Design Spec
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Scope:** Automatically recover from gallery-dl's "Failed to extract campaign ID" errors on Patreon sources by resolving the campaign ID via Patreon's campaigns API, caching it on the source, and rewriting the URL at download time.
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
Gallery-dl 1.31.10's Patreon extractor fails for some creators with:
|
||||
|
||||
```
|
||||
[patreon][error] Failed to extract campaign ID
|
||||
```
|
||||
|
||||
The failure happens during the vanity-URL → campaign-ID lookup, when Patreon serves an HTML page whose bootstrap JSON is empty or missing. Both gallery-dl's primary JSON parse and its Next.js regex fallback miss, and the extraction aborts.
|
||||
|
||||
The upstream-recommended workaround is to use `https://www.patreon.com/id:<campaign_id>` as the source URL, which bypasses the vanity-resolution path entirely. Campaign IDs are retrievable from Patreon's `/api/campaigns?filter[vanity]=<vanity>` endpoint.
|
||||
|
||||
This spec automates that workaround: on failure, look up the campaign ID, cache it on the source, and retry the download once in the same task run. Subsequent downloads use the cached ID-URL directly. The user's original URL is preserved for display.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Components
|
||||
|
||||
#### 1. `backend/app/services/patreon_resolver.py` — new
|
||||
|
||||
Single async function:
|
||||
|
||||
```python
|
||||
async def resolve_campaign_id(vanity: str, cookies_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve a Patreon vanity (creator slug) to a campaign ID.
|
||||
|
||||
Returns the campaign ID as a string, or None on any failure (network
|
||||
error, auth error, empty response, unexpected shape). Never raises.
|
||||
"""
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
- Load cookies with `http.cookiejar.MozillaCookieJar(cookies_path).load(ignore_discard=True, ignore_expires=True)`, then convert to an `aiohttp.CookieJar` by iterating and calling `update_cookies`. (aiohttp doesn't read Netscape format natively.)
|
||||
- `GET https://www.patreon.com/api/campaigns?filter[vanity]={vanity}&fields[campaign]=name` with header `User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36` (a stable desktop UA — Patreon rejects requests with suspicious UAs like `python-requests/*`).
|
||||
- Parse the JSON response. Success path: `data[0].id` (string). Log at `logging.INFO`.
|
||||
- Any failure — non-200 status, JSON parse error, empty `data`, missing `id` field, `aiohttp.ClientError`, `asyncio.TimeoutError` — returns `None` and logs at `logging.WARNING` with the vanity and the specific reason (HTTP status, exception class, or shape description) for debuggability.
|
||||
- 10-second total timeout.
|
||||
- If `cookies_path` is `None` or the file doesn't exist, proceed without cookies (the endpoint may still work; the resolver logs a debug note).
|
||||
|
||||
#### 2. `backend/app/tasks/downloads.py` — retry hook + URL rewrite
|
||||
|
||||
**URL rewrite (outbound path, before every download):**
|
||||
|
||||
Before calling `gallery_dl_service.download()`, compute the effective URL:
|
||||
|
||||
```python
|
||||
def _effective_patreon_url(source: Source) -> str:
|
||||
if source.platform == "patreon":
|
||||
campaign_id = (source.metadata_ or {}).get("patreon_campaign_id")
|
||||
if campaign_id:
|
||||
return f"https://www.patreon.com/id:{campaign_id}"
|
||||
return source.url
|
||||
```
|
||||
|
||||
Called in `_run_single_download` (or whichever function assembles the `download()` call arguments). All Patreon downloads with a cached ID use the id-URL. Non-Patreon platforms and Patreon sources without a cached ID use `source.url` unchanged.
|
||||
|
||||
**Retry hook (on first failure, inline):**
|
||||
|
||||
After `dl_result = await gallery_dl_service.download(...)`, before the DB-write block:
|
||||
|
||||
```python
|
||||
if (
|
||||
not dl_result.success
|
||||
and source.platform == "patreon"
|
||||
and _looks_like_campaign_id_failure(dl_result)
|
||||
and "patreon_campaign_id" not in (source.metadata_ or {})
|
||||
):
|
||||
vanity = _extract_vanity_from_url(source.url)
|
||||
if vanity:
|
||||
logger.info(f"Attempting campaign-ID resolution for {subscription_name}/patreon ({vanity})")
|
||||
campaign_id = await resolve_campaign_id(vanity, cookies_path)
|
||||
if campaign_id:
|
||||
source.metadata_ = {
|
||||
**(source.metadata_ or {}),
|
||||
"patreon_campaign_id": campaign_id,
|
||||
}
|
||||
# Retry once with the id URL; overwrite dl_result
|
||||
dl_result = await gallery_dl_service.download(
|
||||
url=f"https://www.patreon.com/id:{campaign_id}",
|
||||
subscription_name=subscription_name,
|
||||
platform="patreon",
|
||||
source_config=source_config,
|
||||
cookies_path=cookies_path,
|
||||
auth_token=auth_token,
|
||||
)
|
||||
```
|
||||
|
||||
Helper functions in the same module:
|
||||
|
||||
```python
|
||||
def _looks_like_campaign_id_failure(dl_result) -> bool:
|
||||
haystack = f"{dl_result.stdout or ''}\n{dl_result.stderr or ''}".lower()
|
||||
return "failed to extract campaign id" in haystack
|
||||
|
||||
def _extract_vanity_from_url(url: str) -> Optional[str]:
|
||||
# Matches patreon.com/<vanity> and patreon.com/c/<vanity>/... and patreon.com/c/<vanity>
|
||||
# Returns None for id:<N> URLs and for anything that doesn't match.
|
||||
m = re.match(
|
||||
r"https?://(?:www\.)?patreon\.com/(?:c/)?(?!id:)([^/?#]+)",
|
||||
url,
|
||||
)
|
||||
return m.group(1) if m else None
|
||||
```
|
||||
|
||||
The retry's `dl_result` flows into the existing DB-write block unchanged. `error_count` resets to 0 on retry success, or increments once (not twice) on retry failure — the original failure is discarded.
|
||||
|
||||
#### 3. `backend/app/services/gallery_dl.py` — no change
|
||||
|
||||
The caller (`downloads.py`) supplies the already-effective URL. The service remains platform-agnostic.
|
||||
|
||||
#### 4. Data model — no change
|
||||
|
||||
`Source.metadata_` is already a JSONB dict column. We write one new string key, `patreon_campaign_id`. No migration.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
### First-time failure → auto-heal
|
||||
|
||||
1. Scheduled check calls `_run_single_download` for source `{platform: patreon, url: https://www.patreon.com/mstsu, metadata_: {}}`.
|
||||
2. `_effective_patreon_url(source)` returns `source.url` (no cache yet).
|
||||
3. `gallery_dl_service.download(url=source.url, ...)` returns `DownloadResult(success=False, stderr="... Failed to extract campaign ID ...")`.
|
||||
4. Retry hook fires: matches `patreon` platform + error pattern + empty cache.
|
||||
5. `_extract_vanity_from_url(source.url)` returns `"mstsu"`.
|
||||
6. `resolve_campaign_id("mstsu", cookies_path)` hits the campaigns API, returns `"12345"`.
|
||||
7. Task writes `source.metadata_['patreon_campaign_id'] = "12345"` into the in-memory ORM instance (commit happens in the normal DB-write block).
|
||||
8. `gallery_dl_service.download(url="https://www.patreon.com/id:12345", ...)` runs; succeeds (typical case).
|
||||
9. DB-write block sees `dl_result.success=True`, resets `error_count=0`, commits the metadata update along with it, publishes `download.completed`.
|
||||
|
||||
### Subsequent runs
|
||||
|
||||
1. `_effective_patreon_url(source)` sees `patreon_campaign_id=12345`, returns `https://www.patreon.com/id:12345`.
|
||||
2. Gallery-dl gets the id-URL directly; no vanity lookup, no resolver involvement.
|
||||
3. Normal download path.
|
||||
|
||||
### Resolver failure
|
||||
|
||||
- `resolve_campaign_id` returns `None` (API 401, network error, empty `data[]`, etc.).
|
||||
- No metadata written, no retry.
|
||||
- Original `dl_result` flows through the normal error path: `error_count += 1`, `download.failed` event, etc.
|
||||
- A warning log entry records the vanity and the reason. The user sees the same "Failed to extract campaign ID" error they would have seen without the feature.
|
||||
|
||||
### Resolver succeeds but retry still fails
|
||||
|
||||
- Metadata is still written (the ID is valid; the resolver was not the problem).
|
||||
- The retry's failure is recorded normally. Next scheduled run uses the id-URL and hits whatever the real issue is (likely auth).
|
||||
- No further resolver runs for this source.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
| Situation | Behavior |
|
||||
|---|---|
|
||||
| Cookies file missing | Resolver proceeds without cookies; may still succeed on public endpoint |
|
||||
| Cookies expired (API 401/403) | Resolver returns None, logs warning, original failure surfaced |
|
||||
| Vanity regex doesn't match | Retry hook skipped, original failure surfaced |
|
||||
| `data[]` empty in response | Resolver returns None, logs warning |
|
||||
| Unexpected JSON shape | Resolver returns None, logs shape at warning |
|
||||
| Network error / timeout | Resolver returns None, logs exception class |
|
||||
| Resolver + retry both succeed | Normal success path, metadata cached |
|
||||
| Resolver succeeds, retry fails | Metadata cached (ID is valid), retry's failure recorded |
|
||||
| Source already has `patreon_campaign_id` but retry fails | No second resolver run (guard on metadata presence) |
|
||||
| Non-Patreon source fails with any error | Retry hook skipped (platform gate) |
|
||||
| Patreon source fails with a different error (e.g., auth) | Retry hook skipped (pattern gate) |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit — `patreon_resolver.py`
|
||||
|
||||
- `resolve_campaign_id` happy path: mock `aiohttp` response with `{"data": [{"id": "12345", "type": "campaign"}]}` → returns `"12345"`.
|
||||
- Empty `data[]` → returns `None`, warning logged.
|
||||
- Missing `data` key → returns `None`, warning logged.
|
||||
- HTTP 401 → returns `None`, warning logged with status.
|
||||
- `aiohttp.ClientError` raised → returns `None`, warning logged.
|
||||
- `asyncio.TimeoutError` → returns `None`, warning logged.
|
||||
- `cookies_path=None` → call proceeds, no cookie jar loaded.
|
||||
|
||||
Use `aioresponses` or `aiohttp.test_utils` to mock; no real network calls.
|
||||
|
||||
### Unit — `downloads.py` helpers
|
||||
|
||||
- `_looks_like_campaign_id_failure`: `DownloadResult` with the pattern in stderr → True; pattern in stdout → True; neither → False; case-insensitive match.
|
||||
- `_extract_vanity_from_url`: `patreon.com/mstsu` → `"mstsu"`; `patreon.com/c/mstsu/posts` → `"mstsu"`; `patreon.com/id:12345` → `None`; `patreon.com/` → `None`; unrelated URL → `None`.
|
||||
|
||||
### Integration — retry hook
|
||||
|
||||
Patch `app.tasks.downloads.gallery_dl_service.download` and `app.tasks.downloads.resolve_campaign_id` (after importing the symbol into the module, patching the local binding).
|
||||
|
||||
- **Happy-path heal:** First `download()` returns campaign-ID failure, resolver returns `"12345"`, second `download()` returns success. Verify: resolver called with correct vanity; `source.metadata_['patreon_campaign_id'] == "12345"` after commit; second `download()` called with `patreon.com/id:12345` URL; `source.error_count == 0`; `download.completed` event published once (for the retry).
|
||||
- **Resolver returns None:** First `download()` returns campaign-ID failure, resolver returns `None`. Verify: second `download()` not called; `source.metadata_` unchanged (no `patreon_campaign_id` key); `source.error_count == 1`; `download.failed` event published with the original error.
|
||||
- **Already cached:** Source has `metadata_['patreon_campaign_id'] = "12345"` before run. Verify: `_effective_patreon_url` returns id-URL; first `download()` called with id-URL; no resolver call regardless of result.
|
||||
- **Non-patreon:** Source is `subscribestar` with similar-looking error text. Verify: retry hook skipped entirely.
|
||||
|
||||
### Integration — URL-rewrite outbound
|
||||
|
||||
- Source with `patreon_campaign_id` cached → `download()` called with id-URL.
|
||||
- Source without cached ID → `download()` called with `source.url`.
|
||||
- Non-Patreon source with `patreon_campaign_id` somehow set (data corruption) → `download()` called with `source.url` (platform gate wins).
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `backend/app/services/patreon_resolver.py` | Create: single `resolve_campaign_id(vanity, cookies_path)` async function |
|
||||
| `backend/app/tasks/downloads.py` | Add `_effective_patreon_url`, `_looks_like_campaign_id_failure`, `_extract_vanity_from_url` helpers; insert retry hook before DB-write block in `_run_single_download`; use effective URL when calling `gallery_dl_service.download()` |
|
||||
| `tests/services/test_patreon_resolver.py` | Create: unit tests for resolver |
|
||||
| `tests/tasks/test_downloads_patreon_retry.py` | Create: integration tests for retry hook and URL-rewrite |
|
||||
|
||||
No frontend changes. No schema migration. No new API endpoints. No settings changes.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No one-time migration to pre-resolve all existing Patreon sources. Sources auto-heal on their next scheduled check (≤ `check_interval`, default 8h).
|
||||
- No UI surface for the campaign ID. It lives in `Source.metadata_` and is visible only via the existing metadata inspection path (JSON blob in the DB).
|
||||
- No retry on the id-URL if the retry itself fails. One resolve + one retry per task run, total.
|
||||
- No resolver re-run if cached campaign ID ever becomes stale. Manual fix: delete the key from `source.metadata_`.
|
||||
- No change to `gallery_dl.py` — it stays platform-agnostic. The Patreon URL quirk is contained in `downloads.py`.
|
||||
- No change to the error-categorization logic in `_categorize_error`. The "Failed to extract campaign ID" failure is still categorized as `UNKNOWN_ERROR` if the resolver can't heal it; this is fine because the primary recovery path runs before categorization matters.
|
||||
Reference in New Issue
Block a user