From 0c80c129841642caf7cc3169600e6f77f0a0089a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 20 May 2026 20:39:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(fc3c):=20PatreonResolver=20=E2=80=94=20van?= =?UTF-8?q?ity=20=E2=86=92=20campaign=5Fid=20via=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/patreon_resolver.py | 102 +++++++++++++++++++++++ tests/test_patreon_resolver.py | 88 +++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 backend/app/services/patreon_resolver.py create mode 100644 tests/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..64797c9 --- /dev/null +++ b/backend/app/services/patreon_resolver.py @@ -0,0 +1,102 @@ +"""Patreon vanity → campaign ID resolver. + +When gallery-dl fails with "Failed to extract campaign ID" on a Patreon +source URL, call resolve_campaign_id(vanity, cookies_path) to look up +the campaign ID via Patreon's public campaigns API. Caller then retries +with `patreon.com/id:` to bypass the broken vanity path. + +Uses the stdlib `requests` (already a transitive dep via gallery-dl) so +we don't add `aiohttp`. The sync call is wrapped in run_in_executor so +the calling async code stays non-blocking. + +Never raises. Returns None on any error (network, auth, parse, missing +match). +""" + +from __future__ import annotations + +import asyncio +import http.cookiejar +import logging +import os + +import requests + +log = 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" +) +_TIMEOUT_SECONDS = 10.0 + + +def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None: + if not cookies_path or not os.path.isfile(cookies_path): + return None + try: + jar = http.cookiejar.MozillaCookieJar(cookies_path) + jar.load(ignore_discard=True, ignore_expires=True) + return jar + except (OSError, http.cookiejar.LoadError) as exc: + log.debug("Could not load cookies from %s: %s", cookies_path, exc) + return None + + +def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None: + jar = _load_cookie_jar(cookies_path) + headers = { + "User-Agent": _USER_AGENT, + "Accept": "application/vnd.api+json", + } + params = { + "filter[vanity]": vanity, + "fields[campaign]": "name", + } + try: + resp = requests.get( + _CAMPAIGNS_URL, + params=params, + headers=headers, + cookies=jar, + timeout=_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc) + return None + + if resp.status_code != 200: + log.warning( + "Patreon campaigns API returned HTTP %d for vanity=%s", + resp.status_code, vanity, + ) + return None + + try: + payload = resp.json() + except ValueError as exc: + log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc) + return None + + if not isinstance(payload, dict): + return None + data = payload.get("data") + if not isinstance(data, list) or not data: + 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: + return None + log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id) + return campaign_id + + +async def resolve_campaign_id( + vanity: str, + cookies_path: str | None, +) -> str | None: + """Async wrapper. Returns the campaign id string or None on any failure. + Never raises.""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path) diff --git a/tests/test_patreon_resolver.py b/tests/test_patreon_resolver.py new file mode 100644 index 0000000..ecfe165 --- /dev/null +++ b/tests/test_patreon_resolver.py @@ -0,0 +1,88 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from backend.app.services.patreon_resolver import resolve_campaign_id + + +@pytest.mark.asyncio +async def test_resolves_on_happy_path(): + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = {"data": [{"id": "12345678", "type": "campaign"}]} + with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): + result = await resolve_campaign_id("alice", cookies_path=None) + assert result == "12345678" + + +@pytest.mark.asyncio +async def test_returns_none_on_401(): + fake_response = MagicMock() + fake_response.status_code = 401 + fake_response.json.return_value = {} + with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): + result = await resolve_campaign_id("alice", cookies_path=None) + assert result is None + + +@pytest.mark.asyncio +async def test_returns_none_on_network_error(): + import requests + with patch( + "backend.app.services.patreon_resolver.requests.get", + side_effect=requests.ConnectionError("nope"), + ): + result = await resolve_campaign_id("alice", cookies_path=None) + assert result is None + + +@pytest.mark.asyncio +async def test_returns_none_on_empty_data(): + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = {"data": []} + with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): + result = await resolve_campaign_id("alice", cookies_path=None) + assert result is None + + +@pytest.mark.asyncio +async def test_returns_none_on_malformed_json(): + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.side_effect = ValueError("not json") + with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): + result = await resolve_campaign_id("alice", cookies_path=None) + assert result is None + + +@pytest.mark.asyncio +async def test_missing_cookies_file_does_not_raise(tmp_path): + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = {"data": [{"id": "1", "type": "campaign"}]} + with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): + result = await resolve_campaign_id( + "alice", cookies_path=str(tmp_path / "does-not-exist.txt") + ) + assert result == "1" + + +@pytest.mark.asyncio +async def test_loads_cookies_file_when_present(tmp_path): + cookies = tmp_path / "cookies.txt" + cookies.write_text( + "# Netscape HTTP Cookie File\n" + ".patreon.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc\n" + ) + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = {"data": [{"id": "9", "type": "campaign"}]} + with patch( + "backend.app.services.patreon_resolver.requests.get", + return_value=fake_response, + ) as mock_get: + result = await resolve_campaign_id("alice", cookies_path=str(cookies)) + assert result == "9" + _, kwargs = mock_get.call_args + assert kwargs.get("cookies") is not None