feat(patreon): add campaign-ID resolver service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user