feat(artist): resolve patreon + subscribestar display names at add-time (#130 step 5)
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:
+30
-13
@@ -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", [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = (
|
||||
'<head><meta property="og:title" content="Sabu & Friends">'
|
||||
'<title>Sabu | SubscribeStar</title></head>'
|
||||
)
|
||||
assert _extract_creator_name(html) == "Sabu & Friends"
|
||||
|
||||
|
||||
def test_extract_creator_name_title_fallback_strips_suffix():
|
||||
assert _extract_creator_name(
|
||||
"<head><title>Elasid on SubscribeStar</title></head>"
|
||||
) == "Elasid"
|
||||
assert _extract_creator_name(
|
||||
"<head><title>Cheun | SubscribeStar — adult</title></head>"
|
||||
) == "Cheun"
|
||||
|
||||
|
||||
def test_extract_creator_name_none_when_absent():
|
||||
assert _extract_creator_name("<html><body>no title</body></html>") is None
|
||||
|
||||
|
||||
def test_date_parses_when_wrapped_in_permalink_anchor():
|
||||
# Image posts wrap the date in an <a> permalink; a plain regex missed them
|
||||
# → null dates (cheunart 2026-06-17). gallery-dl's method handles both.
|
||||
|
||||
Reference in New Issue
Block a user