From ce8ba538b7e9987acda1100b70dadaa829bf7a3d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 18 Apr 2026 13:22:36 -0400 Subject: [PATCH] feat(patreon): add campaign-ID resolver service Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/patreon_resolver.py | 119 ++++++++++++++++++ backend/tests/services/__init__.py | 0 .../tests/services/test_patreon_resolver.py | 86 +++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 backend/app/services/patreon_resolver.py create mode 100644 backend/tests/services/__init__.py create mode 100644 backend/tests/services/test_patreon_resolver.py diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py new file mode 100644 index 0000000..d1ab402 --- /dev/null +++ b/backend/app/services/patreon_resolver.py @@ -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:) 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 diff --git a/backend/tests/services/__init__.py b/backend/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/services/test_patreon_resolver.py b/backend/tests/services/test_patreon_resolver.py new file mode 100644 index 0000000..c7a31e5 --- /dev/null +++ b/backend/tests/services/test_patreon_resolver.py @@ -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="not json", 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"