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