feat(artist): pixiv display-name at add-time + identity-by-source (#130 steps 2+3)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m32s

Final piece of the artist decoupling. (1) Identity-by-source: quick_add_source
resolves the artist by an existing (platform, url) Source first, so a re-add
reuses the artist even after it was renamed (its frozen slug no longer matches
the name) — a slug-based lookup would have duplicated it. (2) Pixiv naming: a new
pixiv source resolves the real display name via the app API (PixivClient
.resolve_display_name → /v1/user/detail) using the stored token, so the artist is
'Kurotsuchi Machi' not '12345678' — and its name-derived slug matches what a
native download produces, unifying them. Falls back to the numeric id when no
token/crypto. ExtensionService gains the crypto seam; the endpoint passes it.

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:24:25 -04:00
parent a69bd1baa8
commit 0c4b8aef8c
5 changed files with 123 additions and 3 deletions
+5 -1
View File
@@ -84,11 +84,15 @@ async def quick_add_source():
if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
result = await ExtensionService(session).quick_add_source(url)
# crypto lets a pixiv add resolve the artist's display name via the
# stored OAuth token (else it falls back to the numeric id). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
except UnknownPlatformError as exc:
return _bad(
"unknown_platform",
+46 -2
View File
@@ -61,15 +61,38 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
class ExtensionService:
def __init__(self, session: AsyncSession) -> None:
def __init__(self, session: AsyncSession, crypto=None) -> None:
self.session = session
# Optional decryptor for resolving a token-auth platform's display name
# (pixiv) at add-time. None → skip resolution, fall back to the handle.
self._crypto = crypto
async def quick_add_source(self, url: str) -> dict:
platform, raw_slug = self._derive(url)
artist, created_artist = await self._find_or_create_artist(raw_slug)
# Identity by SOURCE handle (#130): an existing (platform, url) source
# keeps its artist on re-add — even if that artist was since renamed (its
# frozen slug no longer matches the current name). Only a genuinely new
# source resolves/creates an artist.
existing = (await self.session.execute(
select(Source).where(Source.platform == platform, Source.url == url)
)).scalar_one_or_none()
if existing is not None:
artist = (await self.session.execute(
select(Artist).where(Artist.id == existing.artist_id)
)).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)
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,
)
return self._shape(source, artist, created_source, created_artist)
@staticmethod
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
return {
"source": {
"id": source.id,
@@ -87,6 +110,27 @@ 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:
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
loop = asyncio.get_running_loop()
name = await loop.run_in_executor(
None, PixivClient(token).resolve_display_name, raw_slug
)
return name or raw_slug
async def probe(self, url: str) -> dict:
"""Read-only resolution of a creator-page URL against the FC DB.
Returns one of:
+13
View File
@@ -540,6 +540,19 @@ class PixivClient:
return
current = str(next_url).rpartition("?")[2]
# -- user detail ---------------------------------------------------------
def resolve_display_name(self, user_id: str) -> str | None:
"""The pixiv user's display name via `/v1/user/detail` (gallery-dl's
user_detail) — used to name the Artist when a source is added by numeric
id. None on any failure (the caller falls back to the id)."""
try:
body = self._call("/v1/user/detail", {"user_id": str(user_id)})
except PixivAPIError:
return None
name = (body.get("user") or {}).get("name") if isinstance(body, dict) else None
return name if isinstance(name, str) and name.strip() else None
# -- verify --------------------------------------------------------------
def verify_auth(self) -> tuple[bool | None, str]: