feat: the artist picker on Patreon and SubscribeStar too — and the Patreon name is canon (milestone 429)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 6s
CI and images / sign-extension (push) Successful in 4m48s
CI and images / build-web (push) Failing after 6s
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 6s
CI and images / sign-extension (push) Successful in 4m48s
CI and images / build-web (push) Failing after 6s
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
Operator: "yes add the artist picker to patreon and subscribestar too. but generally we treat the patreon name as the canon" - Every add now goes through the panel. On Patreon/SubscribeStar it opens on the creator's display name (read only when the panel opens: probe?names=1), searches FC's artists with it, and auto-picks a match. An untouched URL handle sends no name, so the server still resolves it. - Patreon is canon: joining a Patreon source to an artist known by another name offers "Rename “x” to the Patreon name “X”", ticked by default. quick-add's use_platform_name renames server-side from the name it reads itself; name only, the slug never moves (#130); never to a URL handle when the name can't be read; ignored on SubscribeStar and Discord. - _platform_display_name returns None rather than the handle, bounded by the same 6s lookup budget as Discord's names. - panelDefaults / addRequest / renameOffer replace the Discord-only helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -95,7 +95,9 @@ async def probe_source():
|
|||||||
return _bad("unauthorized", status=401)
|
return _bad("unauthorized", status=401)
|
||||||
# crypto lets a Discord probe name the server and channel with the
|
# crypto lets a Discord probe name the server and channel with the
|
||||||
# stored token; every other platform ignores it.
|
# stored token; every other platform ignores it.
|
||||||
result = await ExtensionService(session, _get_crypto()).probe(url)
|
result = await ExtensionService(session, _get_crypto()).probe(
|
||||||
|
url, names=request.args.get("names") in ("1", "true"),
|
||||||
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +118,9 @@ async def quick_add_source():
|
|||||||
artist_name = body.get("artist_name")
|
artist_name = body.get("artist_name")
|
||||||
if artist_name is not None and not isinstance(artist_name, str):
|
if artist_name is not None and not isinstance(artist_name, str):
|
||||||
return _bad("invalid_body", detail="artist_name must be a string")
|
return _bad("invalid_body", detail="artist_name must be a string")
|
||||||
|
# Patreon is canon: adding a Patreon source to an existing artist can take
|
||||||
|
# the creator's Patreon display name (name only; the slug never moves).
|
||||||
|
use_platform_name = body.get("use_platform_name") is True
|
||||||
|
|
||||||
from .credentials import _get_crypto
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
@@ -127,6 +132,7 @@ async def quick_add_source():
|
|||||||
# stored credential (else it falls back to the URL handle). #130.
|
# stored credential (else it falls back to the URL handle). #130.
|
||||||
result = await ExtensionService(session, _get_crypto()).quick_add_source(
|
result = await ExtensionService(session, _get_crypto()).quick_add_source(
|
||||||
url, artist_id=artist_id, artist_name=artist_name,
|
url, artist_id=artist_id, artist_name=artist_name,
|
||||||
|
use_platform_name=use_platform_name,
|
||||||
)
|
)
|
||||||
except UnknownArtistError as exc:
|
except UnknownArtistError as exc:
|
||||||
return _bad("not_found", detail=str(exc), status=404)
|
return _bad("not_found", detail=str(exc), status=404)
|
||||||
|
|||||||
@@ -109,12 +109,20 @@ class ExtensionService:
|
|||||||
*,
|
*,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
artist_name: str | None = None,
|
artist_name: str | None = None,
|
||||||
|
use_platform_name: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add `url` as a source. `artist_id` connects it to an existing
|
"""Add `url` as a source. `artist_id` connects it to an existing
|
||||||
artist, `artist_name` to that artist (created if new); with neither,
|
artist, `artist_name` to that artist (created if new); with neither,
|
||||||
the artist is resolved from the platform as before."""
|
the artist is resolved from the platform as before.
|
||||||
|
|
||||||
|
`use_platform_name` applies the operator's convention that the Patreon
|
||||||
|
name is canon: a Patreon source added to an existing artist renames
|
||||||
|
that artist to the creator's Patreon display name. Name only — the
|
||||||
|
slug, and every path keyed off it, never moves (#130). Ignored on every
|
||||||
|
other platform, and when the name can't be read."""
|
||||||
platform, raw_slug = self._derive(url)
|
platform, raw_slug = self._derive(url)
|
||||||
url = canonical_source_url(platform, url, raw_slug)
|
url = canonical_source_url(platform, url, raw_slug)
|
||||||
|
renamed_from = None
|
||||||
# Identity by SOURCE handle (#130): an existing (platform, url) source
|
# Identity by SOURCE handle (#130): an existing (platform, url) source
|
||||||
# keeps its artist on re-add — even if that artist was since renamed (its
|
# keeps its artist on re-add — even if that artist was since renamed (its
|
||||||
# frozen slug no longer matches the current name), and even when the
|
# frozen slug no longer matches the current name), and even when the
|
||||||
@@ -134,6 +142,8 @@ class ExtensionService:
|
|||||||
if artist is None:
|
if artist is None:
|
||||||
raise UnknownArtistError(f"no artist with id {artist_id}")
|
raise UnknownArtistError(f"no artist with id {artist_id}")
|
||||||
created_artist = False
|
created_artist = False
|
||||||
|
if use_platform_name and platform == "patreon":
|
||||||
|
renamed_from = await self._adopt_patreon_name(artist, raw_slug, url)
|
||||||
else:
|
else:
|
||||||
name = (artist_name or "").strip()
|
name = (artist_name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -144,7 +154,21 @@ class ExtensionService:
|
|||||||
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)
|
shaped = self._shape(source, artist, created_source, created_artist)
|
||||||
|
if renamed_from is not None:
|
||||||
|
shaped["renamed_from"] = renamed_from
|
||||||
|
return shaped
|
||||||
|
|
||||||
|
async def _adopt_patreon_name(self, artist, raw_slug: str, url: str) -> str | None:
|
||||||
|
"""Rename `artist` to the Patreon display name; the old name when it
|
||||||
|
changed, else None. Unreadable name → no rename, never the handle."""
|
||||||
|
name = await self._platform_display_name("patreon", raw_slug, url)
|
||||||
|
if not name or name == artist.name:
|
||||||
|
return None
|
||||||
|
old = artist.name
|
||||||
|
artist.name = name
|
||||||
|
await self.session.commit()
|
||||||
|
return old
|
||||||
|
|
||||||
async def _existing_source(self, platform: str, url: str) -> Source | None:
|
async def _existing_source(self, platform: str, url: str) -> Source | None:
|
||||||
"""The source this URL already is, whichever artist owns it. Discord
|
"""The source this URL already is, whichever artist owns it. Discord
|
||||||
@@ -193,8 +217,18 @@ class ExtensionService:
|
|||||||
server_id = raw_slug.split("/", 1)[0]
|
server_id = raw_slug.split("/", 1)[0]
|
||||||
names = await self._discord_names(server_id, None)
|
names = await self._discord_names(server_id, None)
|
||||||
return names.get("server") or f"Discord {server_id}"
|
return names.get("server") or f"Discord {server_id}"
|
||||||
|
return await self._platform_display_name(platform, raw_slug, url) or raw_slug
|
||||||
|
|
||||||
|
async def _platform_display_name(
|
||||||
|
self, platform: str, raw_slug: str, url: str
|
||||||
|
) -> str | None:
|
||||||
|
"""The creator's display name as Patreon or SubscribeStar shows it, read
|
||||||
|
with the stored cookies; None when it can't be read (no credential, a
|
||||||
|
network error, a slow answer, any other platform). None, not the handle,
|
||||||
|
so a caller can tell a real name from a fallback — a rename to the
|
||||||
|
Patreon name must never rename to a URL handle instead."""
|
||||||
if self._crypto is None or platform not in ("patreon", "subscribestar"):
|
if self._crypto is None or platform not in ("patreon", "subscribestar"):
|
||||||
return raw_slug
|
return None
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from .credential_service import CredentialService
|
from .credential_service import CredentialService
|
||||||
@@ -204,7 +238,7 @@ class ExtensionService:
|
|||||||
if platform == "patreon":
|
if platform == "patreon":
|
||||||
cookies = await cred.get_cookies_path("patreon")
|
cookies = await cred.get_cookies_path("patreon")
|
||||||
from .patreon_resolver import resolve_display_name
|
from .patreon_resolver import resolve_display_name
|
||||||
name = await loop.run_in_executor(
|
call = loop.run_in_executor(
|
||||||
None, resolve_display_name, raw_slug,
|
None, resolve_display_name, raw_slug,
|
||||||
str(cookies) if cookies else None,
|
str(cookies) if cookies else None,
|
||||||
)
|
)
|
||||||
@@ -212,15 +246,14 @@ class ExtensionService:
|
|||||||
cookies = await cred.get_cookies_path("subscribestar")
|
cookies = await cred.get_cookies_path("subscribestar")
|
||||||
from .subscribestar_client import SubscribeStarClient
|
from .subscribestar_client import SubscribeStarClient
|
||||||
client = SubscribeStarClient(str(cookies) if cookies else None)
|
client = SubscribeStarClient(str(cookies) if cookies else None)
|
||||||
name = await loop.run_in_executor(
|
call = loop.run_in_executor(None, client.resolve_display_name, url)
|
||||||
None, client.resolve_display_name, url
|
name = await asyncio.wait_for(call, timeout=_NAME_LOOKUP_SECONDS)
|
||||||
)
|
|
||||||
except Exception as exc: # resolution is best-effort — never block the add
|
except Exception as exc: # resolution is best-effort — never block the add
|
||||||
log.warning("artist display-name resolution failed (%s): %s", platform, exc)
|
log.warning("artist display-name resolution failed (%s): %s", platform, exc)
|
||||||
return raw_slug
|
return None
|
||||||
return name or raw_slug
|
return (name or "").strip() or None
|
||||||
|
|
||||||
async def probe(self, url: str) -> dict:
|
async def probe(self, url: str, *, names: bool = False) -> 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:
|
||||||
- {state: 'unknown_platform'} — URL didn't match any
|
- {state: 'unknown_platform'} — URL didn't match any
|
||||||
@@ -236,7 +269,11 @@ class ExtensionService:
|
|||||||
— exact (artist, platform,
|
— exact (artist, platform,
|
||||||
url) Source already exists
|
url) Source already exists
|
||||||
|
|
||||||
Side-effect-free: two SELECTs at most.
|
`names` (the Add panel asks, the chip does not) adds `display_name`:
|
||||||
|
the creator's name as Patreon/SubscribeStar shows it, or None. It costs
|
||||||
|
a request to the platform, so a plain page view never pays it.
|
||||||
|
|
||||||
|
Side-effect-free: two SELECTs at most, plus that one lookup.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
platform, raw_slug = self._derive(url)
|
platform, raw_slug = self._derive(url)
|
||||||
@@ -246,13 +283,16 @@ class ExtensionService:
|
|||||||
return await self._probe_discord(raw_slug)
|
return await self._probe_discord(raw_slug)
|
||||||
|
|
||||||
slug = slugify(raw_slug)
|
slug = slugify(raw_slug)
|
||||||
|
result: dict = {"platform": platform, "slug": slug}
|
||||||
|
if names:
|
||||||
|
result["display_name"] = await self._platform_display_name(
|
||||||
|
platform, raw_slug, url,
|
||||||
|
)
|
||||||
artist = (await self.session.execute(
|
artist = (await self.session.execute(
|
||||||
select(Artist).where(Artist.slug == slug)
|
select(Artist).where(Artist.slug == slug)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if artist is None:
|
if artist is None:
|
||||||
return {"state": "new", "platform": platform, "slug": slug}
|
return {"state": "new", **result}
|
||||||
|
|
||||||
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
|
|
||||||
|
|
||||||
source = (await self.session.execute(
|
source = (await self.session.execute(
|
||||||
select(Source).where(
|
select(Source).where(
|
||||||
@@ -262,25 +302,13 @@ class ExtensionService:
|
|||||||
)
|
)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if source is None:
|
if source is None:
|
||||||
return {
|
return {"state": "artist_match", **result, "artist": self._artist_payload(artist)}
|
||||||
"state": "artist_match",
|
|
||||||
"platform": platform,
|
|
||||||
"slug": slug,
|
|
||||||
"artist": artist_payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"state": "source_match",
|
"state": "source_match",
|
||||||
"platform": platform,
|
**result,
|
||||||
"slug": slug,
|
"artist": self._artist_payload(artist),
|
||||||
"artist": artist_payload,
|
"source": self._source_payload(source),
|
||||||
"source": {
|
|
||||||
"id": source.id,
|
|
||||||
"artist_id": source.artist_id,
|
|
||||||
"platform": source.platform,
|
|
||||||
"url": source.url,
|
|
||||||
"enabled": source.enabled,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _probe_discord(self, raw_slug: str) -> dict:
|
async def _probe_discord(self, raw_slug: str) -> dict:
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
return await api.quickAddSource(msg.url, {
|
return await api.quickAddSource(msg.url, {
|
||||||
artistId: msg.artistId ?? null,
|
artistId: msg.artistId ?? null,
|
||||||
artistName: msg.artistName ?? null,
|
artistName: msg.artistName ?? null,
|
||||||
|
usePlatformName: msg.usePlatformName === true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e.message };
|
return { error: e.message };
|
||||||
@@ -284,7 +285,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
|
|
||||||
case 'PROBE_SOURCE':
|
case 'PROBE_SOURCE':
|
||||||
try {
|
try {
|
||||||
return await api.probeSource(msg.url);
|
return await api.probeSource(msg.url, { names: msg.names === true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e.message };
|
return { error: e.message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,3 +97,6 @@
|
|||||||
.fc-panel__result-tag { font-size: 11px; color: rgb(140, 220, 160); }
|
.fc-panel__result-tag { font-size: 11px; color: rgb(140, 220, 160); }
|
||||||
.fc-panel__empty { padding: 6px 9px; font-size: 12px; color: rgb(170, 166, 156); }
|
.fc-panel__empty { padding: 6px 9px; font-size: 12px; color: rgb(170, 166, 156); }
|
||||||
.fc-panel__hint--match { color: rgb(140, 220, 160); }
|
.fc-panel__hint--match { color: rgb(140, 220, 160); }
|
||||||
|
/* The panel's own [hidden] — its rows are display:flex, which beats the UA's. */
|
||||||
|
.fc-panel [hidden] { display: none !important; }
|
||||||
|
.fc-panel__rename { margin-top: 8px; font-size: 13px; }
|
||||||
|
|||||||
@@ -84,13 +84,14 @@
|
|||||||
await openArtist(btn, probe.artist?.slug);
|
await openArtist(btn, probe.artist?.slug);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A Discord URL names a channel, not a creator — ask which artist.
|
// Every add goes through the panel, so the operator can match the page to
|
||||||
if (probe?.platform === 'discord') {
|
// an artist FabledCurator already has (the same creator is often spelled
|
||||||
if (document.getElementById('fc-discord-panel')) closePanel();
|
// differently per platform) instead of minting a duplicate.
|
||||||
else openDiscordPanel(probe);
|
if (document.getElementById('fc-add-panel')) {
|
||||||
|
closePanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await add(btn, { url: window.location.href });
|
await openAddPanel(btn, probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openArtist(btn, slug) {
|
async function openArtist(btn, slug) {
|
||||||
@@ -121,7 +122,8 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const verb = r.created_source ? 'Added to' : 'Already a source for';
|
const verb = r.created_source ? 'Added to' : 'Already a source for';
|
||||||
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
const renamed = r.renamed_from ? ` — renamed from “${r.renamed_from}”` : '';
|
||||||
|
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})${renamed}`, 'success');
|
||||||
// Re-probe so the chip flips green without waiting for a navigation.
|
// Re-probe so the chip flips green without waiting for a navigation.
|
||||||
evaluate();
|
evaluate();
|
||||||
return true;
|
return true;
|
||||||
@@ -134,10 +136,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Discord Add panel ----
|
// ---- Add panel ----
|
||||||
// Where: this channel or the whole server. Who: the suggested artist, one
|
// Who: the suggested artist, one found by search, or a new one by name —
|
||||||
// found by search, or a new one by name. Built with createElement only —
|
// on every platform. Where (Discord only): this channel or the whole
|
||||||
// server, channel and artist names are other people's text.
|
// server. On Patreon, joining an artist known by another name offers the
|
||||||
|
// Patreon name, which the operator treats as canon. Built with
|
||||||
|
// createElement only — server, channel and artist names are other people's
|
||||||
|
// text.
|
||||||
|
|
||||||
function el(tag, props = {}, children = []) {
|
function el(tag, props = {}, children = []) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
@@ -150,13 +155,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
document.getElementById('fc-discord-panel')?.remove();
|
document.getElementById('fc-add-panel')?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDiscordPanel(probe) {
|
async function openAddPanel(btn, probe) {
|
||||||
closePanel();
|
closePanel();
|
||||||
|
const platformName = PLATFORMS[probe.platform]?.name || probe.platform;
|
||||||
|
// Discord's probe already carries its names. Patreon/SubscribeStar read the
|
||||||
|
// creator's display name only now, when the panel needs it — a request to
|
||||||
|
// the platform the chip's own probe deliberately doesn't make.
|
||||||
|
if (probe.platform !== 'discord') {
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = `Reading the ${platformName} name…`;
|
||||||
|
try {
|
||||||
|
const named = await browser.runtime.sendMessage({
|
||||||
|
type: 'PROBE_SOURCE', url: window.location.href, names: true,
|
||||||
|
});
|
||||||
|
if (named && !named.error) probe = named;
|
||||||
|
} catch { /* fall back to the chip's probe: the URL handle */ }
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = original;
|
||||||
|
if (probe.state === 'source_match') {
|
||||||
|
renderButton(probe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const d = probe.discord || {};
|
const d = probe.discord || {};
|
||||||
const choice = discordPanelDefaults(probe);
|
const discord = probe.platform === 'discord';
|
||||||
|
const choice = panelDefaults(probe, window.location.href);
|
||||||
|
|
||||||
const scopeRow = (value, label, disabled) => {
|
const scopeRow = (value, label, disabled) => {
|
||||||
const input = el('input', {
|
const input = el('input', {
|
||||||
@@ -186,14 +213,27 @@
|
|||||||
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
|
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
|
||||||
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
|
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
|
||||||
|
|
||||||
const panel = el('div', { id: 'fc-discord-panel', class: 'fc-panel' }, [
|
// Patreon is canon: joining an artist known by another name takes the
|
||||||
el('div', { class: 'fc-panel__title', text: 'Add Discord source' }),
|
// Patreon name unless this is unticked. Shown only when it would rename.
|
||||||
el('div', { class: 'fc-panel__sub', text: serverLabel(d) }),
|
const renameBox = el('input', { type: 'checkbox', checked: choice.adoptPlatformName });
|
||||||
el('div', { class: 'fc-panel__label', text: 'Follow' }),
|
const renameText = el('span');
|
||||||
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
|
const renameRow = el('label', { class: 'fc-panel__radio fc-panel__rename' }, [renameBox, renameText]);
|
||||||
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
|
renameBox.addEventListener('change', () => { choice.adoptPlatformName = renameBox.checked; refresh(); });
|
||||||
|
|
||||||
|
const sub = discord
|
||||||
|
? serverLabel(d)
|
||||||
|
: [probe.display_name, probe.slug].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ');
|
||||||
|
const panel = el('div', { id: 'fc-add-panel', class: 'fc-panel' }, [
|
||||||
|
el('div', { class: 'fc-panel__title', text: `Add ${platformName} source` }),
|
||||||
|
el('div', { class: 'fc-panel__sub', text: sub }),
|
||||||
|
...(discord ? [
|
||||||
|
el('div', { class: 'fc-panel__label', text: 'Follow' }),
|
||||||
|
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
|
||||||
|
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
|
||||||
|
] : []),
|
||||||
el('div', { class: 'fc-panel__label', text: 'Artist' }),
|
el('div', { class: 'fc-panel__label', text: 'Artist' }),
|
||||||
el('div', { class: 'fc-panel__combo' }, [nameInput, results]),
|
el('div', { class: 'fc-panel__combo' }, [nameInput, results]),
|
||||||
|
renameRow,
|
||||||
hint,
|
hint,
|
||||||
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
|
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
|
||||||
]);
|
]);
|
||||||
@@ -205,12 +245,16 @@
|
|||||||
let listOpen = false;
|
let listOpen = false;
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
const req = discordAddRequest(choice);
|
const req = addRequest(choice);
|
||||||
addBtn.disabled = !req;
|
addBtn.disabled = !req;
|
||||||
if (!req) hint.textContent = 'Pick an artist or type a name.';
|
if (!req) hint.textContent = 'Pick an artist or type a name.';
|
||||||
else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`;
|
else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`;
|
||||||
else hint.textContent = `Creates a new artist “${req.artistName}”.`;
|
else if (req.artistName) hint.textContent = `Creates a new artist “${req.artistName}”.`;
|
||||||
|
else hint.textContent = `Creates a new artist, named from the ${platformName} page.`;
|
||||||
hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null);
|
hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null);
|
||||||
|
const offer = renameOffer(choice);
|
||||||
|
renameRow.hidden = !offer;
|
||||||
|
if (offer) renameText.textContent = `Rename “${offer.from}” to the Patreon name “${offer.to}”`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(artist) {
|
function pick(artist) {
|
||||||
@@ -346,7 +390,7 @@
|
|||||||
|
|
||||||
cancelBtn.addEventListener('click', closePanel);
|
cancelBtn.addEventListener('click', closePanel);
|
||||||
addBtn.addEventListener('click', async () => {
|
addBtn.addEventListener('click', async () => {
|
||||||
const req = discordAddRequest(choice);
|
const req = addRequest(choice);
|
||||||
if (!req) return;
|
if (!req) return;
|
||||||
const btn = document.getElementById('fc-add-source-btn');
|
const btn = document.getElementById('fc-add-source-btn');
|
||||||
addBtn.disabled = true;
|
addBtn.disabled = true;
|
||||||
|
|||||||
+11
-4
@@ -104,16 +104,23 @@ class FabledCuratorAPI {
|
|||||||
// artistId connects the source to an existing artist, artistName to the
|
// artistId connects the source to an existing artist, artistName to the
|
||||||
// artist of that name (created if new); with neither the server derives the
|
// artist of that name (created if new); with neither the server derives the
|
||||||
// artist from the URL. A Discord channel always sends one.
|
// artist from the URL. A Discord channel always sends one.
|
||||||
quickAddSource(url, { artistId = null, artistName = null } = {}) {
|
// usePlatformName: a Patreon source joining an existing artist renames it
|
||||||
|
// to the Patreon display name (Patreon is canon; name only, never the slug).
|
||||||
|
quickAddSource(url, { artistId = null, artistName = null, usePlatformName = false } = {}) {
|
||||||
const body = { url };
|
const body = { url };
|
||||||
if (artistId != null) body.artist_id = artistId;
|
if (artistId != null) body.artist_id = artistId;
|
||||||
else if (artistName) body.artist_name = artistName;
|
else if (artistName) body.artist_name = artistName;
|
||||||
|
if (usePlatformName) body.use_platform_name = true;
|
||||||
return this.request('POST', '/extension/quick-add-source', body);
|
return this.request('POST', '/extension/quick-add-source', body);
|
||||||
}
|
}
|
||||||
probeSource(url) {
|
probeSource(url, { names = false } = {}) {
|
||||||
// Read-only existence check. Drives the content-script chip's
|
// Read-only existence check. Drives the content-script chip's
|
||||||
// color/copy BEFORE the operator clicks Add.
|
// color/copy BEFORE the operator clicks Add. `names` also reads the
|
||||||
const qs = new URLSearchParams({ url }).toString();
|
// creator's display name from the platform — the Add panel asks for it,
|
||||||
|
// the chip doesn't, so a plain page view never costs a platform request.
|
||||||
|
const params = { url };
|
||||||
|
if (names) params.names = '1';
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
return this.request('GET', `/extension/probe?${qs}`);
|
return this.request('GET', `/extension/probe?${qs}`);
|
||||||
}
|
}
|
||||||
// Latest published extension version on this instance — drives the in-app
|
// Latest published extension version on this instance — drives the in-app
|
||||||
|
|||||||
+53
-14
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* The content script's decisions, kept apart from its DOM so the specs can
|
* The content script's decisions, kept apart from its DOM so the specs can
|
||||||
* load them (test/chip.spec.js): which state the chip shows, what it says,
|
* load them (test/chip.spec.js): which state the chip shows, what it says,
|
||||||
* and what the Discord Add panel starts out proposing.
|
* and what the Add panel starts out proposing and finally sends.
|
||||||
*
|
*
|
||||||
* `probe` is /api/extension/probe's answer; `platformName` is the display
|
* `probe` is /api/extension/probe's answer; `platformName` is the display
|
||||||
* name (PLATFORMS[key].name), passed in so this file needs no other lib.
|
* name (PLATFORMS[key].name), passed in so this file needs no other lib.
|
||||||
@@ -46,37 +46,76 @@ function serverLabel(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the Discord Add panel opens with. The channel is the default scope
|
* What the Add panel opens with, on any platform.
|
||||||
* when there is one: a server source walks every channel the token can read,
|
*
|
||||||
* which is rarely what a single art channel wants. The artist is the probe's
|
* Discord: the channel is the default scope when there is one — a server
|
||||||
* suggestion (the owner of another source on this server), else a new artist
|
* source walks every channel the token can read, which is rarely what a
|
||||||
* named after the server.
|
* single art channel wants. The artist is the probe's suggestion (the owner
|
||||||
|
* of another source on this server), else a new one named after the server.
|
||||||
|
*
|
||||||
|
* Patreon / SubscribeStar: the page URL is the source. The artist is the one
|
||||||
|
* whose slug the URL already names (artist_match), else a new one under the
|
||||||
|
* creator's display name — `probe.display_name`, from the probe the panel
|
||||||
|
* makes with names=1 — falling back to the URL handle, which the server
|
||||||
|
* resolves on its own when it is left untouched (`nameIsHandle`).
|
||||||
*/
|
*/
|
||||||
function discordPanelDefaults(probe) {
|
function panelDefaults(probe, pageUrl) {
|
||||||
const d = probe?.discord || {};
|
const d = probe?.discord || {};
|
||||||
const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null;
|
const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null;
|
||||||
|
const discord = probe?.platform === 'discord';
|
||||||
|
const shown = probe?.display_name || null;
|
||||||
|
let artistName = '';
|
||||||
|
if (suggested) artistName = suggested.name;
|
||||||
|
else if (discord) artistName = d.server_name || '';
|
||||||
|
else artistName = shown || probe?.slug || '';
|
||||||
return {
|
return {
|
||||||
scope: d.channel_id ? 'channel' : 'server',
|
platform: probe?.platform || null,
|
||||||
|
scope: discord ? (d.channel_id ? 'channel' : 'server') : 'page',
|
||||||
|
pageUrl: pageUrl || null,
|
||||||
channelUrl: d.channel_url || null,
|
channelUrl: d.channel_url || null,
|
||||||
serverUrl: d.server_url || null,
|
serverUrl: d.server_url || null,
|
||||||
artist: suggested ? { id: suggested.id, name: suggested.name } : null,
|
artist: suggested ? { id: suggested.id, name: suggested.name } : null,
|
||||||
artistName: suggested ? suggested.name : (d.server_name || ''),
|
artistName,
|
||||||
|
nameIsHandle: !discord && !suggested && !shown,
|
||||||
|
handle: probe?.slug || '',
|
||||||
|
displayName: shown,
|
||||||
|
// Patreon is canon: joining an artist known by another name takes the
|
||||||
|
// Patreon name, unless the operator unticks it.
|
||||||
|
adoptPlatformName: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rename the panel offers, or null: only on Patreon (the canon name),
|
||||||
|
* only when joining an existing artist, only with a name actually read from
|
||||||
|
* Patreon, and only when it differs from what the artist is called now.
|
||||||
|
*/
|
||||||
|
function renameOffer(choice) {
|
||||||
|
if (choice.platform !== 'patreon' || !choice.artist || !choice.displayName) return null;
|
||||||
|
if (choice.artist.name === choice.displayName) return null;
|
||||||
|
return { from: choice.artist.name, to: choice.displayName };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The quick-add body for the panel's current choice. A picked artist goes by
|
* The quick-add body for the panel's current choice. A picked artist goes by
|
||||||
* id — names can collide once slugified — and a typed name creates (or
|
* id — names can collide once slugified — and a typed name creates (or
|
||||||
* finds) that artist. null when there is nothing valid to send.
|
* finds) that artist. The URL handle left untouched sends no name, so the
|
||||||
|
* server resolves the display name itself. null when there is nothing valid.
|
||||||
*/
|
*/
|
||||||
function discordAddRequest(choice) {
|
function addRequest(choice) {
|
||||||
const url = choice.scope === 'server' ? choice.serverUrl : choice.channelUrl;
|
let url = choice.pageUrl;
|
||||||
|
if (choice.scope === 'server') url = choice.serverUrl;
|
||||||
|
else if (choice.scope === 'channel') url = choice.channelUrl;
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
if (choice.artist && choice.artist.id != null && choice.artist.name === choice.artistName) {
|
if (choice.artist && choice.artist.id != null && choice.artist.name === choice.artistName) {
|
||||||
return { url, artistId: choice.artist.id };
|
const req = { url, artistId: choice.artist.id };
|
||||||
|
if (choice.adoptPlatformName && renameOffer(choice)) req.usePlatformName = true;
|
||||||
|
return req;
|
||||||
}
|
}
|
||||||
const name = (choice.artistName || '').trim();
|
const name = (choice.artistName || '').trim();
|
||||||
return name ? { url, artistName: name } : null;
|
if (!name) return null;
|
||||||
|
if (choice.nameIsHandle && name === choice.handle) return { url };
|
||||||
|
return { url, artistName: name };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+71
-14
@@ -1,11 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { loadLib } from './helpers/loadLib.js'
|
import { loadLib } from './helpers/loadLib.js'
|
||||||
|
|
||||||
const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [
|
const { chipState, chipLabel, panelDefaults, addRequest, renameOffer } = loadLib('chip.js', [
|
||||||
'chipState',
|
'chipState',
|
||||||
'chipLabel',
|
'chipLabel',
|
||||||
'discordPanelDefaults',
|
'panelDefaults',
|
||||||
'discordAddRequest'
|
'addRequest',
|
||||||
|
'renameOffer'
|
||||||
])
|
])
|
||||||
|
|
||||||
const discord = (extra = {}) => ({
|
const discord = (extra = {}) => ({
|
||||||
@@ -63,47 +64,47 @@ describe('chip state and label', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Discord Add panel', () => {
|
describe('Add panel on Discord', () => {
|
||||||
it('opens on the channel with the suggested artist preselected', () => {
|
it('opens on the channel with the suggested artist preselected', () => {
|
||||||
const d = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
|
const d = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
|
||||||
expect(d.scope).toBe('channel')
|
expect(d.scope).toBe('channel')
|
||||||
expect(d.artist).toEqual({ id: 7, name: 'Tamada' })
|
expect(d.artist).toEqual({ id: 7, name: 'Tamada' })
|
||||||
expect(d.artistName).toBe('Tamada')
|
expect(d.artistName).toBe('Tamada')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('proposes a new artist named after the server when nothing is suggested', () => {
|
it('proposes a new artist named after the server when nothing is suggested', () => {
|
||||||
const d = discordPanelDefaults(discord({ state: 'new' }))
|
const d = panelDefaults(discord({ state: 'new' }))
|
||||||
expect(d.artist).toBe(null)
|
expect(d.artist).toBe(null)
|
||||||
expect(d.artistName).toBe('Studio')
|
expect(d.artistName).toBe('Studio')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends a picked artist by id', () => {
|
it('sends a picked artist by id', () => {
|
||||||
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
|
const choice = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
|
||||||
expect(discordAddRequest(choice)).toEqual({
|
expect(addRequest(choice)).toEqual({
|
||||||
url: 'https://discord.com/channels/111/222',
|
url: 'https://discord.com/channels/111/222',
|
||||||
artistId: 7
|
artistId: 7
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends a typed name once the picked artist has been edited away', () => {
|
it('sends a typed name once the picked artist has been edited away', () => {
|
||||||
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
|
const choice = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
|
||||||
choice.artistName = 'Tamada Alt'
|
choice.artistName = 'Tamada Alt'
|
||||||
expect(discordAddRequest(choice)).toEqual({
|
expect(addRequest(choice)).toEqual({
|
||||||
url: 'https://discord.com/channels/111/222',
|
url: 'https://discord.com/channels/111/222',
|
||||||
artistName: 'Tamada Alt'
|
artistName: 'Tamada Alt'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds the whole server when the operator picks it', () => {
|
it('adds the whole server when the operator picks it', () => {
|
||||||
const choice = discordPanelDefaults(discord({ state: 'new' }))
|
const choice = panelDefaults(discord({ state: 'new' }))
|
||||||
choice.scope = 'server'
|
choice.scope = 'server'
|
||||||
expect(discordAddRequest(choice).url).toBe('https://discord.com/channels/111')
|
expect(addRequest(choice).url).toBe('https://discord.com/channels/111')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has nothing to send without an artist', () => {
|
it('has nothing to send without an artist', () => {
|
||||||
const choice = discordPanelDefaults(discord({ state: 'new' }))
|
const choice = panelDefaults(discord({ state: 'new' }))
|
||||||
choice.artistName = ' '
|
choice.artistName = ' '
|
||||||
expect(discordAddRequest(choice)).toBe(null)
|
expect(addRequest(choice)).toBe(null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,3 +142,59 @@ describe('artist matching for the Add panel', () => {
|
|||||||
expect(inlineCompletion('', results)).toBe(null)
|
expect(inlineCompletion('', results)).toBe(null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Add panel on Patreon and SubscribeStar', () => {
|
||||||
|
const PAGE = 'https://www.patreon.com/cw/tamadaheijun'
|
||||||
|
const patreon = (extra = {}) => ({ platform: 'patreon', slug: 'tamadaheijun', ...extra })
|
||||||
|
|
||||||
|
it('opens on the Patreon display name as a new artist', () => {
|
||||||
|
const c = panelDefaults(patreon({ state: 'new', display_name: 'Tamada Heijun' }), PAGE)
|
||||||
|
expect(c.scope).toBe('page')
|
||||||
|
expect(c.artistName).toBe('Tamada Heijun')
|
||||||
|
expect(addRequest(c)).toEqual({ url: PAGE, artistName: 'Tamada Heijun' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an untouched URL handle to the server to resolve', () => {
|
||||||
|
const c = panelDefaults(patreon({ state: 'new' }), PAGE)
|
||||||
|
expect(c.artistName).toBe('tamadaheijun')
|
||||||
|
expect(addRequest(c)).toEqual({ url: PAGE })
|
||||||
|
c.artistName = 'Someone Else'
|
||||||
|
expect(addRequest(c)).toEqual({ url: PAGE, artistName: 'Someone Else' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the Patreon name when joining an artist known by another name', () => {
|
||||||
|
const c = panelDefaults(patreon({ state: 'new', display_name: 'Tamada Heijun' }), PAGE)
|
||||||
|
c.artist = { id: 4, name: 'tamada' }
|
||||||
|
c.artistName = 'tamada'
|
||||||
|
expect(renameOffer(c)).toEqual({ from: 'tamada', to: 'Tamada Heijun' })
|
||||||
|
expect(addRequest(c)).toEqual({ url: PAGE, artistId: 4, usePlatformName: true })
|
||||||
|
c.adoptPlatformName = false
|
||||||
|
expect(addRequest(c)).toEqual({ url: PAGE, artistId: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers no rename when the names agree or the name was not read', () => {
|
||||||
|
const same = panelDefaults(patreon({ state: 'new', display_name: 'Tamada Heijun' }), PAGE)
|
||||||
|
same.artist = { id: 4, name: 'Tamada Heijun' }
|
||||||
|
expect(renameOffer(same)).toBe(null)
|
||||||
|
const unread = panelDefaults(patreon({ state: 'new' }), PAGE)
|
||||||
|
unread.artist = { id: 4, name: 'tamada' }
|
||||||
|
expect(renameOffer(unread)).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never renames from SubscribeStar or Discord — Patreon is the canon', () => {
|
||||||
|
const ss = panelDefaults(
|
||||||
|
{ platform: 'subscribestar', slug: 'tamada', state: 'new', display_name: 'SS Tamada' },
|
||||||
|
'https://subscribestar.adult/tamada'
|
||||||
|
)
|
||||||
|
ss.artist = { id: 4, name: 'Tamada Heijun' }
|
||||||
|
ss.artistName = 'Tamada Heijun'
|
||||||
|
expect(renameOffer(ss)).toBe(null)
|
||||||
|
expect(addRequest(ss)).toEqual({ url: 'https://subscribestar.adult/tamada', artistId: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preselects the artist the URL already names', () => {
|
||||||
|
const c = panelDefaults(patreon({ state: 'artist_match', artist: { id: 9, name: 'Tamada' } }), PAGE)
|
||||||
|
expect(c.artist).toEqual({ id: 9, name: 'Tamada' })
|
||||||
|
expect(c.artistName).toBe('Tamada')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -526,6 +526,121 @@ async def test_probe_a_discord_dm_is_not_a_source(client, ext_key):
|
|||||||
assert body["state"] == "unknown_platform"
|
assert body["state"] == "unknown_platform"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Patreon is canon: the Add panel's names and the rename (milestone 429) ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def platform_names(monkeypatch):
|
||||||
|
"""Stub both platforms' display-name lookups (no network in tests)."""
|
||||||
|
from backend.app.services import patreon_resolver
|
||||||
|
from backend.app.services.credential_service import CredentialService
|
||||||
|
from backend.app.services.subscribestar_client import SubscribeStarClient
|
||||||
|
|
||||||
|
async def _cookies(self, platform):
|
||||||
|
return "/tmp/cookies.txt"
|
||||||
|
|
||||||
|
names = {"patreon": "Tamada Heijun", "subscribestar": "SS Tamada"}
|
||||||
|
monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
patreon_resolver, "resolve_display_name", lambda v, c: names["patreon"],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SubscribeStarClient, "resolve_display_name", lambda self, u: names["subscribestar"],
|
||||||
|
)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_with_names_reads_the_patreon_display_name(client, ext_key, platform_names):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/tamadaheijun", "names": "1"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["state"] == "new"
|
||||||
|
assert body["display_name"] == "Tamada Heijun"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_plain_probe_does_not_look_the_name_up(client, ext_key, platform_names):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/tamadaheijun"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert "display_name" not in await resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _artist(db, name, slug):
|
||||||
|
artist = Artist(name=name, slug=slug, is_subscription=True)
|
||||||
|
db.add(artist)
|
||||||
|
await db.commit()
|
||||||
|
return artist
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_patreon_source_renames_the_artist_it_joins_to_the_patreon_name(
|
||||||
|
client, ext_key, db, db_sync, platform_names,
|
||||||
|
):
|
||||||
|
artist = await _artist(db, "tamada", "tamada")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/tamadaheijun",
|
||||||
|
"artist_id": artist.id, "use_platform_name": True},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["artist"]["name"] == "Tamada Heijun"
|
||||||
|
assert body["renamed_from"] == "tamada"
|
||||||
|
# Name only: the slug, and every path keyed off it, stays.
|
||||||
|
row = db_sync.execute(select(Artist.name, Artist.slug).where(Artist.id == artist.id)).one()
|
||||||
|
assert tuple(row) == ("Tamada Heijun", "tamada")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_rename_without_the_flag(client, ext_key, db, platform_names):
|
||||||
|
artist = await _artist(db, "tamada", "tamada")
|
||||||
|
body = await (await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/tamadaheijun", "artist_id": artist.id},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)).get_json()
|
||||||
|
assert body["artist"]["name"] == "tamada"
|
||||||
|
assert "renamed_from" not in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unreadable_patreon_name_never_renames_to_the_handle(
|
||||||
|
client, ext_key, db, platform_names,
|
||||||
|
):
|
||||||
|
platform_names["patreon"] = None
|
||||||
|
artist = await _artist(db, "Tamada", "tamada")
|
||||||
|
body = await (await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/tamadaheijun",
|
||||||
|
"artist_id": artist.id, "use_platform_name": True},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)).get_json()
|
||||||
|
assert body["artist"]["name"] == "Tamada"
|
||||||
|
assert "renamed_from" not in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_patreon_names_are_canon(client, ext_key, db, platform_names):
|
||||||
|
"""A SubscribeStar source joins the picked artist under the name it has."""
|
||||||
|
artist = await _artist(db, "Tamada Heijun", "tamada-heijun")
|
||||||
|
body = await (await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://subscribestar.adult/tamada",
|
||||||
|
"artist_id": artist.id, "use_platform_name": True},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)).get_json()
|
||||||
|
assert body["artist"]["name"] == "Tamada Heijun"
|
||||||
|
assert "renamed_from" not in body
|
||||||
|
|
||||||
|
|
||||||
# --- /api/extension/manifest ---------------------------------------
|
# --- /api/extension/manifest ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user