diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index b917882..20b1b30 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -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", diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index a5a932f..737cabb 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -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: diff --git a/backend/app/services/pixiv_client.py b/backend/app/services/pixiv_client.py index e6b5fea..6335827 100644 --- a/backend/app/services/pixiv_client.py +++ b/backend/app/services/pixiv_client.py @@ -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]: diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index 98c7dec..714ce49 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -65,6 +65,47 @@ async def test_quick_add_source_idempotent(client, ext_key): 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", [ ("https://www.patreon.com/maewix", "patreon", "maewix"), ("https://patreon.com/maewix", "patreon", "maewix"), diff --git a/tests/test_pixiv_client.py b/tests/test_pixiv_client.py index 1c60443..805c409 100644 --- a/tests/test_pixiv_client.py +++ b/tests/test_pixiv_client.py @@ -390,6 +390,24 @@ def test_verify_auth_bad_token(): 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 ------------------------------------------------------------------------ def test_rating_label():