feat(patreon): add campaign-ID resolver service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 13:22:36 -04:00
parent d819a184a1
commit ce8ba538b7
3 changed files with 205 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
"""Patreon campaign-ID resolver.
When gallery-dl fails with "Failed to extract campaign ID" on a Patreon
source, call resolve_campaign_id(vanity, cookies_path) to look up the
campaign ID via Patreon's campaigns API. The caller can then retry with
the id-URL form (patreon.com/id:<id>) to bypass the broken vanity lookup.
"""
import asyncio
import http.cookiejar
import logging
import os
from typing import Optional
import aiohttp
logger = logging.getLogger(__name__)
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
_REQUEST_TIMEOUT_SECONDS = 10.0
def _load_cookie_jar(cookies_path: Optional[str]) -> Optional[aiohttp.CookieJar]:
"""Load a Netscape-format cookie file into an aiohttp CookieJar.
Returns None if the path is None, the file is missing, or parsing fails.
Never raises.
"""
if not cookies_path or not os.path.isfile(cookies_path):
return None
try:
moz = http.cookiejar.MozillaCookieJar(cookies_path)
moz.load(ignore_discard=True, ignore_expires=True)
except (OSError, http.cookiejar.LoadError) as e:
logger.debug(f"Could not load cookies from {cookies_path}: {e}")
return None
jar = aiohttp.CookieJar()
for cookie in moz:
jar.update_cookies(
{cookie.name: cookie.value},
response_url=aiohttp.helpers.URL(
f"https://{cookie.domain.lstrip('.')}/"
),
)
return jar
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.
"""
jar = _load_cookie_jar(cookies_path)
headers = {
"User-Agent": _USER_AGENT,
"Accept": "application/vnd.api+json",
}
params = {
"filter[vanity]": vanity,
"fields[campaign]": "name",
}
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession(
cookie_jar=jar,
headers=headers,
timeout=timeout,
) as session:
async with session.get(_CAMPAIGNS_URL, params=params) as resp:
if resp.status != 200:
logger.warning(
f"Patreon campaigns API returned HTTP {resp.status} for vanity={vanity}"
)
return None
try:
payload = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError) as e:
logger.warning(
f"Patreon campaigns API returned non-JSON for vanity={vanity}: {e}"
)
return None
except asyncio.TimeoutError:
logger.warning(f"Patreon campaigns API timed out for vanity={vanity}")
return None
except aiohttp.ClientError as e:
logger.warning(
f"Patreon campaigns API request failed for vanity={vanity}: "
f"{type(e).__name__}: {e}"
)
return None
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list) or not data:
logger.warning(
f"Patreon campaigns API returned empty/missing data for vanity={vanity}: "
f"payload keys={list(payload) if isinstance(payload, dict) else type(payload).__name__}"
)
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:
logger.warning(
f"Patreon campaigns API response missing 'id' for vanity={vanity}: "
f"first entry keys={list(first) if isinstance(first, dict) else type(first).__name__}"
)
return None
logger.info(f"Resolved Patreon vanity={vanity} → campaign_id={campaign_id}")
return campaign_id
View File
@@ -0,0 +1,86 @@
"""Unit tests for the Patreon campaign-ID resolver."""
import aiohttp
import pytest
from aioresponses import aioresponses
from app.services.patreon_resolver import resolve_campaign_id
CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns?filter%5Bvanity%5D=mstsu&fields%5Bcampaign%5D=name"
async def test_happy_path_returns_campaign_id():
with aioresponses() as mock:
mock.get(
CAMPAIGNS_URL,
payload={"data": [{"id": "12345", "type": "campaign", "attributes": {"name": "MSTSU"}}]},
)
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result == "12345"
async def test_empty_data_returns_none():
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, payload={"data": []})
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_missing_data_key_returns_none():
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, payload={"errors": [{"code": 1}]})
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_missing_id_field_returns_none():
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, payload={"data": [{"type": "campaign"}]})
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_http_401_returns_none():
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, status=401, body="Unauthorized")
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_network_error_returns_none():
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, exception=aiohttp.ClientConnectionError("boom"))
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_timeout_returns_none():
import asyncio
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, exception=asyncio.TimeoutError())
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_invalid_json_returns_none():
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, body="<html>not json</html>", content_type="text/html")
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result is None
async def test_missing_cookies_path_proceeds_without_jar(tmp_path):
"""When cookies_path is None, the request still goes out (cookies are optional)."""
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, payload={"data": [{"id": "99", "type": "campaign"}]})
result = await resolve_campaign_id("mstsu", cookies_path=None)
assert result == "99"
async def test_nonexistent_cookies_path_proceeds_without_jar(tmp_path):
"""When cookies_path points to a missing file, don't crash — just skip the jar."""
ghost = tmp_path / "does-not-exist.txt"
with aioresponses() as mock:
mock.get(CAMPAIGNS_URL, payload={"data": [{"id": "99"}]})
result = await resolve_campaign_id("mstsu", cookies_path=str(ghost))
assert result == "99"