diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index ce03206..56b3078 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -95,7 +95,9 @@ async def probe_source(): return _bad("unauthorized", status=401) # crypto lets a Discord probe name the server and channel with the # 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) @@ -116,6 +118,9 @@ async def quick_add_source(): artist_name = body.get("artist_name") if artist_name is not None and not isinstance(artist_name, str): 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 @@ -127,6 +132,7 @@ async def quick_add_source(): # stored credential (else it falls back to the URL handle). #130. result = await ExtensionService(session, _get_crypto()).quick_add_source( url, artist_id=artist_id, artist_name=artist_name, + use_platform_name=use_platform_name, ) except UnknownArtistError as exc: return _bad("not_found", detail=str(exc), status=404) diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index f2d7f5f..1c2168a 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -109,12 +109,20 @@ class ExtensionService: *, artist_id: int | None = None, artist_name: str | None = None, + use_platform_name: bool = False, ) -> dict: """Add `url` as a source. `artist_id` connects it to an existing 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) url = canonical_source_url(platform, url, raw_slug) + renamed_from = None # 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), and even when the @@ -134,6 +142,8 @@ class ExtensionService: if artist is None: raise UnknownArtistError(f"no artist with id {artist_id}") created_artist = False + if use_platform_name and platform == "patreon": + renamed_from = await self._adopt_patreon_name(artist, raw_slug, url) else: name = (artist_name or "").strip() if not name: @@ -144,7 +154,21 @@ class ExtensionService: 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) + 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: """The source this URL already is, whichever artist owns it. Discord @@ -193,8 +217,18 @@ class ExtensionService: server_id = raw_slug.split("/", 1)[0] names = await self._discord_names(server_id, None) 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"): - return raw_slug + return None import asyncio from .credential_service import CredentialService @@ -204,7 +238,7 @@ class ExtensionService: if platform == "patreon": cookies = await cred.get_cookies_path("patreon") from .patreon_resolver import resolve_display_name - name = await loop.run_in_executor( + call = loop.run_in_executor( None, resolve_display_name, raw_slug, str(cookies) if cookies else None, ) @@ -212,15 +246,14 @@ class ExtensionService: cookies = await cred.get_cookies_path("subscribestar") from .subscribestar_client import SubscribeStarClient client = SubscribeStarClient(str(cookies) if cookies else None) - name = await loop.run_in_executor( - None, client.resolve_display_name, url - ) + call = loop.run_in_executor(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 log.warning("artist display-name resolution failed (%s): %s", platform, exc) - return raw_slug - return name or raw_slug + return None + 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. Returns one of: - {state: 'unknown_platform'} — URL didn't match any @@ -236,7 +269,11 @@ class ExtensionService: — exact (artist, platform, 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: platform, raw_slug = self._derive(url) @@ -246,13 +283,16 @@ class ExtensionService: return await self._probe_discord(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( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if artist is None: - return {"state": "new", "platform": platform, "slug": slug} - - artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug} + return {"state": "new", **result} source = (await self.session.execute( select(Source).where( @@ -262,25 +302,13 @@ class ExtensionService: ) )).scalar_one_or_none() if source is None: - return { - "state": "artist_match", - "platform": platform, - "slug": slug, - "artist": artist_payload, - } + return {"state": "artist_match", **result, "artist": self._artist_payload(artist)} return { "state": "source_match", - "platform": platform, - "slug": slug, - "artist": artist_payload, - "source": { - "id": source.id, - "artist_id": source.artist_id, - "platform": source.platform, - "url": source.url, - "enabled": source.enabled, - }, + **result, + "artist": self._artist_payload(artist), + "source": self._source_payload(source), } async def _probe_discord(self, raw_slug: str) -> dict: diff --git a/extension/background/background.js b/extension/background/background.js index 7a5f36c..d7e82c5 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -270,6 +270,7 @@ browser.runtime.onMessage.addListener(async (msg) => { return await api.quickAddSource(msg.url, { artistId: msg.artistId ?? null, artistName: msg.artistName ?? null, + usePlatformName: msg.usePlatformName === true, }); } catch (e) { return { error: e.message }; @@ -284,7 +285,7 @@ browser.runtime.onMessage.addListener(async (msg) => { case 'PROBE_SOURCE': try { - return await api.probeSource(msg.url); + return await api.probeSource(msg.url, { names: msg.names === true }); } catch (e) { return { error: e.message }; } diff --git a/extension/content/content-script.css b/extension/content/content-script.css index 629e3bd..86eaa64 100644 --- a/extension/content/content-script.css +++ b/extension/content/content-script.css @@ -97,3 +97,6 @@ .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__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; } diff --git a/extension/content/content-script.js b/extension/content/content-script.js index 293e163..6f3bacf 100644 --- a/extension/content/content-script.js +++ b/extension/content/content-script.js @@ -84,13 +84,14 @@ await openArtist(btn, probe.artist?.slug); return; } - // A Discord URL names a channel, not a creator — ask which artist. - if (probe?.platform === 'discord') { - if (document.getElementById('fc-discord-panel')) closePanel(); - else openDiscordPanel(probe); + // Every add goes through the panel, so the operator can match the page to + // an artist FabledCurator already has (the same creator is often spelled + // differently per platform) instead of minting a duplicate. + if (document.getElementById('fc-add-panel')) { + closePanel(); return; } - await add(btn, { url: window.location.href }); + await openAddPanel(btn, probe); } async function openArtist(btn, slug) { @@ -121,7 +122,8 @@ return false; } 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. evaluate(); return true; @@ -134,10 +136,13 @@ } } - // ---- Discord Add panel ---- - // Where: this channel or the whole server. Who: the suggested artist, one - // found by search, or a new one by name. Built with createElement only — - // server, channel and artist names are other people's text. + // ---- Add panel ---- + // Who: the suggested artist, one found by search, or a new one by name — + // on every platform. Where (Discord only): this channel or the whole + // 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 = []) { const node = document.createElement(tag); @@ -150,13 +155,35 @@ } function closePanel() { - document.getElementById('fc-discord-panel')?.remove(); + document.getElementById('fc-add-panel')?.remove(); } - function openDiscordPanel(probe) { + async function openAddPanel(btn, probe) { 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 choice = discordPanelDefaults(probe); + const discord = probe.platform === 'discord'; + const choice = panelDefaults(probe, window.location.href); const scopeRow = (value, label, disabled) => { const input = el('input', { @@ -186,14 +213,27 @@ 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 panel = el('div', { id: 'fc-discord-panel', class: 'fc-panel' }, [ - el('div', { class: 'fc-panel__title', text: 'Add Discord source' }), - el('div', { class: 'fc-panel__sub', text: serverLabel(d) }), - 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), + // Patreon is canon: joining an artist known by another name takes the + // Patreon name unless this is unticked. Shown only when it would rename. + const renameBox = el('input', { type: 'checkbox', checked: choice.adoptPlatformName }); + const renameText = el('span'); + const renameRow = el('label', { class: 'fc-panel__radio fc-panel__rename' }, [renameBox, renameText]); + 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__combo' }, [nameInput, results]), + renameRow, hint, el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]), ]); @@ -205,12 +245,16 @@ let listOpen = false; function refresh() { - const req = discordAddRequest(choice); + const req = addRequest(choice); addBtn.disabled = !req; 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 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); + const offer = renameOffer(choice); + renameRow.hidden = !offer; + if (offer) renameText.textContent = `Rename “${offer.from}” to the Patreon name “${offer.to}”`; } function pick(artist) { @@ -346,7 +390,7 @@ cancelBtn.addEventListener('click', closePanel); addBtn.addEventListener('click', async () => { - const req = discordAddRequest(choice); + const req = addRequest(choice); if (!req) return; const btn = document.getElementById('fc-add-source-btn'); addBtn.disabled = true; diff --git a/extension/lib/api.js b/extension/lib/api.js index 4ae2f19..aa9fe0b 100644 --- a/extension/lib/api.js +++ b/extension/lib/api.js @@ -104,16 +104,23 @@ class FabledCuratorAPI { // 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 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 }; if (artistId != null) body.artist_id = artistId; else if (artistName) body.artist_name = artistName; + if (usePlatformName) body.use_platform_name = true; 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 - // color/copy BEFORE the operator clicks Add. - const qs = new URLSearchParams({ url }).toString(); + // color/copy BEFORE the operator clicks Add. `names` also reads the + // 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}`); } // Latest published extension version on this instance — drives the in-app diff --git a/extension/lib/chip.js b/extension/lib/chip.js index b698970..1a7f0f3 100644 --- a/extension/lib/chip.js +++ b/extension/lib/chip.js @@ -1,7 +1,7 @@ /** * 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, - * 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 * 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 - * 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 - * suggestion (the owner of another source on this server), else a new artist - * named after the server. + * What the Add panel opens with, on any platform. + * + * Discord: the channel is the default scope 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 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 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 { - 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, serverUrl: d.server_url || 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 * 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) { - const url = choice.scope === 'server' ? choice.serverUrl : choice.channelUrl; +function addRequest(choice) { + let url = choice.pageUrl; + if (choice.scope === 'server') url = choice.serverUrl; + else if (choice.scope === 'channel') url = choice.channelUrl; if (!url) return null; 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(); - return name ? { url, artistName: name } : null; + if (!name) return null; + if (choice.nameIsHandle && name === choice.handle) return { url }; + return { url, artistName: name }; } /** diff --git a/extension/test/chip.spec.js b/extension/test/chip.spec.js index 14812c7..2edfa43 100644 --- a/extension/test/chip.spec.js +++ b/extension/test/chip.spec.js @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest' import { loadLib } from './helpers/loadLib.js' -const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [ +const { chipState, chipLabel, panelDefaults, addRequest, renameOffer } = loadLib('chip.js', [ 'chipState', 'chipLabel', - 'discordPanelDefaults', - 'discordAddRequest' + 'panelDefaults', + 'addRequest', + 'renameOffer' ]) 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', () => { - 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.artist).toEqual({ id: 7, name: 'Tamada' }) expect(d.artistName).toBe('Tamada') }) 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.artistName).toBe('Studio') }) it('sends a picked artist by id', () => { - const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } })) - expect(discordAddRequest(choice)).toEqual({ + const choice = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } })) + expect(addRequest(choice)).toEqual({ url: 'https://discord.com/channels/111/222', artistId: 7 }) }) 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' - expect(discordAddRequest(choice)).toEqual({ + expect(addRequest(choice)).toEqual({ url: 'https://discord.com/channels/111/222', artistName: 'Tamada Alt' }) }) 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' - 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', () => { - const choice = discordPanelDefaults(discord({ state: 'new' })) + const choice = panelDefaults(discord({ state: 'new' })) 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) }) }) + +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') + }) +}) diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index 03eeef2..f1dfee5 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -526,6 +526,121 @@ async def test_probe_a_discord_dm_is_not_a_source(client, ext_key): 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 ---------------------------------------