diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py
index 737cabb..f8fea43 100644
--- a/backend/app/services/extension_service.py
+++ b/backend/app/services/extension_service.py
@@ -8,6 +8,7 @@ and returns a JSON-shaped dict for the API layer.
from __future__ import annotations
+import logging
import re
from sqlalchemy import select
@@ -18,6 +19,8 @@ from ..utils.slug import slugify
from .db_helpers import get_or_create
from .source_service import BACKFILL_MAX_CHUNKS
+log = logging.getLogger(__name__)
+
class UnknownPlatformError(Exception):
"""URL didn't match any platform pattern."""
@@ -82,9 +85,9 @@ class ExtensionService:
)).scalar_one()
return self._shape(existing, artist, created_source=False, created_artist=False)
- # New source → name the artist properly. Pixiv's URL yields only the
- # numeric id, so resolve the real display name via the app API.
- name = await self._resolve_artist_name(platform, raw_slug)
+ # New source → name the artist properly by resolving the real display
+ # name from the platform (falls back to the URL handle).
+ name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name)
source, created_source = await self._find_or_create_source(
artist_id=artist.id, platform=platform, url=url,
@@ -110,25 +113,48 @@ class ExtensionService:
"created_artist": created_artist,
}
- async def _resolve_artist_name(self, platform: str, raw_slug: str) -> str:
- """A human display name for a new artist. Pixiv's URL carries only the
- numeric user id, so resolve the real name via the app API (curator holds
- the OAuth token); every other platform's URL handle is already readable.
- Falls back to raw_slug on any failure (no token, API error)."""
- if platform != "pixiv" or self._crypto is None:
+ async def _resolve_artist_name(
+ self, platform: str, raw_slug: str, url: str
+ ) -> str:
+ """The real display name for a new artist, resolved from the platform at
+ add-time (#130). Our native platforms each have a name source — pixiv the
+ app API (token), patreon the campaigns API, subscribestar the profile
+ page (both cookies). Other platforms (and any failure — no credential,
+ network error) fall back to the URL handle, which is already readable.
+ The resolvers are sync, so they run in an executor."""
+ if self._crypto is None or platform not in ("pixiv", "patreon", "subscribestar"):
return raw_slug
import asyncio
from .credential_service import CredentialService
- from .pixiv_client import PixivClient
-
- token = await CredentialService(self.session, self._crypto).get_token("pixiv")
- if not token:
- return raw_slug
+ cred = CredentialService(self.session, self._crypto)
loop = asyncio.get_running_loop()
- name = await loop.run_in_executor(
- None, PixivClient(token).resolve_display_name, raw_slug
- )
+ try:
+ if platform == "pixiv":
+ token = await cred.get_token("pixiv")
+ if not token:
+ return raw_slug
+ from .pixiv_client import PixivClient
+ name = await loop.run_in_executor(
+ None, PixivClient(token).resolve_display_name, raw_slug
+ )
+ elif platform == "patreon":
+ cookies = await cred.get_cookies_path("patreon")
+ from .patreon_resolver import resolve_display_name
+ name = await loop.run_in_executor(
+ None, resolve_display_name, raw_slug,
+ str(cookies) if cookies else None,
+ )
+ else: # subscribestar
+ cookies = await cred.get_cookies_path("subscribestar")
+ from .subscribestar_client import SubscribeStarClient
+ client = SubscribeStarClient(str(cookies) if cookies else None)
+ name = await loop.run_in_executor(
+ None, client.resolve_display_name, url
+ )
+ except Exception as exc: # resolution is best-effort — never block the add
+ log.warning("artist display-name resolution failed (%s): %s", platform, exc)
+ return raw_slug
return name or raw_slug
async def probe(self, url: str) -> dict:
diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py
index 591745c..f4dc3ef 100644
--- a/backend/app/services/patreon_resolver.py
+++ b/backend/app/services/patreon_resolver.py
@@ -139,6 +139,32 @@ def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
return campaign_id
+def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
+ """The Patreon campaign's display name for `vanity` via the campaigns API
+ (`fields[campaign]=name`), used to name the Artist at add-time (#130). None
+ on any failure — the caller falls back to the vanity handle. Sync: call from
+ an executor."""
+ jar = _load_cookie_jar(cookies_path)
+ try:
+ resp = requests.get(
+ _CAMPAIGNS_URL,
+ params={"filter[vanity]": vanity, "fields[campaign]": "name"},
+ headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
+ cookies=jar,
+ timeout=_TIMEOUT_SECONDS,
+ )
+ if resp.status_code != 200:
+ return None
+ data = resp.json().get("data")
+ except (requests.RequestException, ValueError) as exc:
+ log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
+ return None
+ if not isinstance(data, list) or not data or not isinstance(data[0], dict):
+ return None
+ name = (data[0].get("attributes") or {}).get("name")
+ return name.strip() if isinstance(name, str) and name.strip() else None
+
+
def _scrape_campaign_id(html: str) -> str | None:
"""First campaign id found in creator-page HTML via the known embeddings."""
if not isinstance(html, str):
diff --git a/backend/app/services/subscribestar_client.py b/backend/app/services/subscribestar_client.py
index 538e2df..ffdedba 100644
--- a/backend/app/services/subscribestar_client.py
+++ b/backend/app/services/subscribestar_client.py
@@ -273,6 +273,30 @@ def _parse_ss_datetime(text: str) -> str | None:
return None
+_OG_TITLE_RE = re.compile(
+ r']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']',
+ re.IGNORECASE,
+)
+_TITLE_RE = re.compile(r"
]*>(.*?)", re.IGNORECASE | re.DOTALL)
+# Trailing " | SubscribeStar" / " on SubscribeStar" the profile carries.
+_SS_TITLE_SUFFIX_RE = re.compile(
+ r"\s*[|·]\s*SubscribeStar.*$|\s+on\s+SubscribeStar.*$", re.IGNORECASE
+)
+
+
+def _extract_creator_name(html: str) -> str | None:
+ """The creator's display name from a SubscribeStar profile page: prefer the
+ og:title meta (it's the bare creator name), else the with the
+ SubscribeStar suffix stripped. None when neither yields anything (#130)."""
+ m = _OG_TITLE_RE.search(html)
+ name = unescape(m.group(1)).strip() if m else ""
+ if not name:
+ t = _TITLE_RE.search(html)
+ raw = unescape(t.group(1)).strip() if t else ""
+ name = _SS_TITLE_SUFFIX_RE.sub("", raw).strip()
+ return name or None
+
+
class SubscribeStarClient:
"""Synchronous SubscribeStar HTML-scrape read client. Construct with a path
to a Netscape cookies.txt (the same file CredentialService.get_cookies_path
@@ -604,6 +628,23 @@ class SubscribeStarClient:
current = next_href
first_page = False
+ # -- display name -------------------------------------------------------
+
+ def resolve_display_name(self, campaign_id: str) -> str | None:
+ """The creator's display name from their profile page, used to name the
+ Artist at add-time (#130). `campaign_id` is the creator URL. None on any
+ failure — the caller falls back to the URL handle. Sync: run in an
+ executor."""
+ base, slug = _split_creator_url(campaign_id)
+ if not slug:
+ return None
+ self._session.headers["Referer"] = f"{base}/"
+ try:
+ html = self._feed_html(f"{base}/{slug}")
+ except SubscribeStarAPIError:
+ return None
+ return _extract_creator_name(html)
+
# -- verify ------------------------------------------------------------
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py
index 714ce49..d3010e0 100644
--- a/tests/test_api_extension.py
+++ b/tests/test_api_extension.py
@@ -85,25 +85,42 @@ async def test_quick_add_reuses_source_artist_after_rename(client, ext_key):
@pytest.mark.asyncio
-async def test_resolve_artist_name_pixiv_uses_api(db, monkeypatch):
- # #130: a pixiv add resolves the display name via the app API (with a token),
- # not the numeric id; falls back to the id without crypto/token.
+async def test_resolve_artist_name_dispatches_per_platform(db, monkeypatch):
+ # #130: each native platform resolves its real display name at add-time
+ # (pixiv=token API, patreon=campaigns API, subscribestar=profile page);
+ # gallery-dl platforms and any failure fall back to the URL handle.
+ from backend.app.services import patreon_resolver
from backend.app.services.credential_service import CredentialService
from backend.app.services.extension_service import ExtensionService
from backend.app.services.pixiv_client import PixivClient
+ from backend.app.services.subscribestar_client import SubscribeStarClient
async def _tok(self, platform):
- return "tok" if platform == "pixiv" else None
- monkeypatch.setattr(CredentialService, "get_token", _tok)
- monkeypatch.setattr(
- PixivClient, "resolve_display_name", lambda self, uid: "Kurotsuchi Machi"
- )
+ return "tok"
- svc = ExtensionService(db, crypto=object()) # crypto unused (token stubbed)
- assert await svc._resolve_artist_name("pixiv", "555") == "Kurotsuchi Machi"
- assert await svc._resolve_artist_name("patreon", "alice") == "alice" # passthrough
- # No crypto → no resolution attempt → fall back to the raw handle.
- assert await ExtensionService(db)._resolve_artist_name("pixiv", "555") == "555"
+ async def _cookies(self, platform):
+ return "/tmp/cookies.txt"
+
+ monkeypatch.setattr(CredentialService, "get_token", _tok)
+ monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies)
+ monkeypatch.setattr(PixivClient, "resolve_display_name", lambda self, uid: "Pixiv Name")
+ monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: "Patreon Name")
+ monkeypatch.setattr(SubscribeStarClient, "resolve_display_name", lambda self, u: "SS Name")
+
+ svc = ExtensionService(db, crypto=object()) # crypto seam only (calls stubbed)
+ assert await svc._resolve_artist_name(
+ "pixiv", "555", "https://www.pixiv.net/users/555") == "Pixiv Name"
+ assert await svc._resolve_artist_name(
+ "patreon", "maewix", "https://patreon.com/maewix") == "Patreon Name"
+ assert await svc._resolve_artist_name(
+ "subscribestar", "sabu", "https://subscribestar.adult/sabu") == "SS Name"
+ # gallery-dl platform → readable handle passthrough (no resolver).
+ assert await svc._resolve_artist_name("hentaifoundry", "Foo", "u") == "Foo"
+ # No crypto → no resolution attempt → the raw handle.
+ assert await ExtensionService(db)._resolve_artist_name("pixiv", "555", "u") == "555"
+ # Resolver returns None → fall back to the handle.
+ monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: None)
+ assert await svc._resolve_artist_name("patreon", "maewix", "u") == "maewix"
@pytest.mark.parametrize("url,platform,slug", [
diff --git a/tests/test_patreon_resolver.py b/tests/test_patreon_resolver.py
index 4a92b1c..cd86025 100644
--- a/tests/test_patreon_resolver.py
+++ b/tests/test_patreon_resolver.py
@@ -5,9 +5,34 @@ import pytest
from backend.app.services.patreon_resolver import (
resolve_campaign_id,
resolve_campaign_id_for_source,
+ resolve_display_name,
)
+def test_resolve_display_name_from_campaign():
+ fake = MagicMock()
+ fake.status_code = 200
+ fake.json.return_value = {"data": [
+ {"id": "1", "type": "campaign", "attributes": {"name": "Maewix Studios"}},
+ ]}
+ with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake):
+ assert resolve_display_name("maewix", None) == "Maewix Studios"
+
+
+def test_resolve_display_name_none_on_empty_or_error():
+ empty = MagicMock()
+ empty.status_code = 200
+ empty.json.return_value = {"data": []}
+ with patch("backend.app.services.patreon_resolver.requests.get", return_value=empty):
+ assert resolve_display_name("maewix", None) is None
+ import requests as _rq
+ with patch(
+ "backend.app.services.patreon_resolver.requests.get",
+ side_effect=_rq.ConnectionError("x"),
+ ):
+ assert resolve_display_name("maewix", None) is None
+
+
@pytest.mark.asyncio
async def test_resolves_on_happy_path():
fake_response = MagicMock()
diff --git a/tests/test_subscribestar_native.py b/tests/test_subscribestar_native.py
index e360551..0545a7b 100644
--- a/tests/test_subscribestar_native.py
+++ b/tests/test_subscribestar_native.py
@@ -21,6 +21,7 @@ from backend.app.services.subscribestar_client import (
SubscribeStarAuthError,
SubscribeStarClient,
SubscribeStarDriftError,
+ _extract_creator_name,
_parse_ss_datetime,
_split_creator_url,
)
@@ -102,6 +103,27 @@ def test_split_creator_url_leaves_com_and_adult_untouched():
"https://www.subscribestar.com"
+def test_extract_creator_name_prefers_og_title():
+ html = (
+ ''
+ 'Sabu | SubscribeStar'
+ )
+ assert _extract_creator_name(html) == "Sabu & Friends"
+
+
+def test_extract_creator_name_title_fallback_strips_suffix():
+ assert _extract_creator_name(
+ "Elasid on SubscribeStar"
+ ) == "Elasid"
+ assert _extract_creator_name(
+ "Cheun | SubscribeStar — adult"
+ ) == "Cheun"
+
+
+def test_extract_creator_name_none_when_absent():
+ assert _extract_creator_name("no title") is None
+
+
def test_date_parses_when_wrapped_in_permalink_anchor():
# Image posts wrap the date in an permalink; a plain regex missed them
# → null dates (cheunart 2026-06-17). gallery-dl's method handles both.