feat(artist): resolve patreon + subscribestar display names at add-time (#130 step 5)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m41s

Parity with pixiv (operator ask): the extension add now resolves the real
display name for our other native platforms too, not just the URL handle.
patreon_resolver.resolve_display_name reads the campaigns API's
attributes.name; SubscribeStarClient.resolve_display_name pulls the creator
name off the profile page (og:title, else the <title> stripped of the
SubscribeStar suffix). extension_service._resolve_artist_name dispatches per
platform (pixiv=token, patreon/subscribestar=cookies via get_cookies_path),
best-effort in an executor, falling back to the readable URL handle on any
failure. Still all curator core — the extension is unchanged (sends only the
URL). gallery-dl platforms keep the handle (readable, no native client).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-04 22:37:50 -04:00
parent 0c4b8aef8c
commit 0963bf0db3
6 changed files with 187 additions and 30 deletions
+43 -17
View File
@@ -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: