feat(artist): pixiv display-name at add-time + identity-by-source (#130 steps 2+3)
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:
@@ -84,11 +84,15 @@ async def quick_add_source():
|
|||||||
if not isinstance(url, str) or not url.strip():
|
if not isinstance(url, str) or not url.strip():
|
||||||
return _bad("invalid_body", detail="url is required")
|
return _bad("invalid_body", detail="url is required")
|
||||||
|
|
||||||
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
if not await _ext_key_required(session):
|
if not await _ext_key_required(session):
|
||||||
return _bad("unauthorized", status=401)
|
return _bad("unauthorized", status=401)
|
||||||
try:
|
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:
|
except UnknownPlatformError as exc:
|
||||||
return _bad(
|
return _bad(
|
||||||
"unknown_platform",
|
"unknown_platform",
|
||||||
|
|||||||
@@ -61,15 +61,38 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionService:
|
class ExtensionService:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession, crypto=None) -> None:
|
||||||
self.session = session
|
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:
|
async def quick_add_source(self, url: str) -> dict:
|
||||||
platform, raw_slug = self._derive(url)
|
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(
|
source, created_source = await self._find_or_create_source(
|
||||||
artist_id=artist.id, platform=platform, url=url,
|
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 {
|
return {
|
||||||
"source": {
|
"source": {
|
||||||
"id": source.id,
|
"id": source.id,
|
||||||
@@ -87,6 +110,27 @@ class ExtensionService:
|
|||||||
"created_artist": created_artist,
|
"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:
|
async def probe(self, url: str) -> dict:
|
||||||
"""Read-only resolution of a creator-page URL against the FC DB.
|
"""Read-only resolution of a creator-page URL against the FC DB.
|
||||||
Returns one of:
|
Returns one of:
|
||||||
|
|||||||
@@ -540,6 +540,19 @@ class PixivClient:
|
|||||||
return
|
return
|
||||||
current = str(next_url).rpartition("?")[2]
|
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 --------------------------------------------------------------
|
# -- verify --------------------------------------------------------------
|
||||||
|
|
||||||
def verify_auth(self) -> tuple[bool | None, str]:
|
def verify_auth(self) -> tuple[bool | None, str]:
|
||||||
|
|||||||
@@ -65,6 +65,47 @@ async def test_quick_add_source_idempotent(client, ext_key):
|
|||||||
assert body2["created_artist"] is False
|
assert body2["created_artist"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_reuses_source_artist_after_rename(client, ext_key):
|
||||||
|
# #130 identity-by-source: after renaming the artist (slug frozen ≠ new
|
||||||
|
# name-slug), re-adding the SAME source must reuse it — a slug-based lookup
|
||||||
|
# would miss and duplicate the artist.
|
||||||
|
url = "https://www.subscribestar.com/renamed-creator"
|
||||||
|
h = {"X-Extension-Key": ext_key}
|
||||||
|
a1 = (await (await client.post(
|
||||||
|
"/api/extension/quick-add-source", json={"url": url}, headers=h
|
||||||
|
)).get_json())["artist"]
|
||||||
|
await client.patch(f"/api/artists/{a1['id']}", json={"name": "Totally Different"})
|
||||||
|
b2 = await (await client.post(
|
||||||
|
"/api/extension/quick-add-source", json={"url": url}, headers=h
|
||||||
|
)).get_json()
|
||||||
|
assert b2["created_artist"] is False
|
||||||
|
assert b2["artist"]["id"] == a1["id"]
|
||||||
|
assert b2["artist"]["name"] == "Totally Different"
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
from backend.app.services.credential_service import CredentialService
|
||||||
|
from backend.app.services.extension_service import ExtensionService
|
||||||
|
from backend.app.services.pixiv_client import PixivClient
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("url,platform,slug", [
|
@pytest.mark.parametrize("url,platform,slug", [
|
||||||
("https://www.patreon.com/maewix", "patreon", "maewix"),
|
("https://www.patreon.com/maewix", "patreon", "maewix"),
|
||||||
("https://patreon.com/maewix", "patreon", "maewix"),
|
("https://patreon.com/maewix", "patreon", "maewix"),
|
||||||
|
|||||||
@@ -390,6 +390,24 @@ def test_verify_auth_bad_token():
|
|||||||
assert "rotate" in message.lower() or "rejected" in message.lower()
|
assert "rotate" in message.lower() or "rejected" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_display_name(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
client, "_call",
|
||||||
|
lambda e, p: {"user": {"name": "Kurotsuchi Machi", "id": p["user_id"]}},
|
||||||
|
)
|
||||||
|
assert client.resolve_display_name("99") == "Kurotsuchi Machi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_display_name_none_on_failure(client, monkeypatch):
|
||||||
|
def boom(endpoint, params):
|
||||||
|
raise PixivAPIError("nope", status_code=404)
|
||||||
|
monkeypatch.setattr(client, "_call", boom)
|
||||||
|
assert client.resolve_display_name("99") is None
|
||||||
|
# Empty/whitespace name → None (caller falls back to the id).
|
||||||
|
monkeypatch.setattr(client, "_call", lambda e, p: {"user": {"name": " "}})
|
||||||
|
assert client.resolve_display_name("99") is None
|
||||||
|
|
||||||
|
|
||||||
# -- rating ------------------------------------------------------------------------
|
# -- rating ------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_rating_label():
|
def test_rating_label():
|
||||||
|
|||||||
Reference in New Issue
Block a user