Compare commits

..
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 83e1382812 fix: extension updates install the new build — no 12h-cached "latest" XPI, and the popup's Update opens FC's install page
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m23s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2m29s
CI and images / build-web (push) Successful in 1m43s
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Successful in 2s
Operator, 2026-09-25: "the extension update trigger from inside the
extension doesn't work and the manual update seems to not move it to the
most recent version or at least mark it the most recent."

- fabledcurator-latest.xpi was served with Quart's default
  `public, max-age=43200`: one URL whose bytes change every release, so a
  browser that had fetched it reinstalled the previous build for 12 hours
  (measured on the instance). It is now `no-cache` (the ETag keeps an
  unchanged file a 304); versioned XPIs are `immutable`.
- The web Settings card installs/downloads the VERSIONED xpi_url, which can
  only ever be that build's bytes.
- The popup's Update button did tabs.create() on the .xpi, which Firefox
  refuses (NS_ERROR_FAILURE on a 200: it only installs from a user click on
  a web page). It now opens FC's install card (/subscriptions?tab=settings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-25 08:01:02 -04:00
bvandeusenandClaude Opus 5.5 423275a1e5 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
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
2026-09-25 07:50:48 -04:00
bvandeusenandClaude Opus 5.5 eeb9263125 feat: the Discord Add panel's artist field autocompletes against FabledCurator's artists (milestone 429)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m23s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2m56s
CI and images / build-web (push) Successful in 1m37s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Successful in 1s
Operator, after the first live run: "I need the extension to offer an
autofill search function so it's easier to match an entry with an existing
artist."

- The field searches as soon as the panel opens (the server name, usually)
  and on every keystroke; matches list under it, with ↑/↓, Enter/Tab to pick,
  Esc to close, and a last "+ New artist" row.
- Inline autofill: the rest of the top match is filled in and selected, so
  typing on replaces it and Tab/Enter accepts it. Backspacing never refills.
- A result whose name IS the text, spacing and case aside, is picked on its
  own; the hint says in green which existing artist the source will join.
- /api/artists/autocomplete also matches ignoring spacing and punctuation
  ("Tamada Heijun" finds "TamadaHeijun"), ranked just below an exact match.
  The web UI's artist search gets it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-25 07:45:51 -04:00
14 changed files with 709 additions and 127 deletions
+7 -1
View File
@@ -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)
+20 -2
View File
@@ -46,6 +46,14 @@ async def serve_extension(filename: str):
The application/x-xpinstall MIME tells Firefox to show its native
install prompt instead of downloading the file as a blob.
Caching differs by name, and has to. A versioned name is one build's bytes
forever, so it can be cached for good. `fabledcurator-latest.xpi` is ONE
URL whose bytes change on every release, and Quart's default for a file
is `public, max-age=43200`: a browser that fetched it once reused those
bytes for 12 hours, so "install the latest" quietly reinstalled the
previous build (operator-flagged 2026-09-25). It is `no-cache` — the ETag
still makes an unchanged file a cheap 304.
"""
if not _XPI_NAME_RE.fullmatch(filename):
abort(404)
@@ -56,10 +64,11 @@ async def serve_extension(filename: str):
if not xpis:
abort(404)
latest = xpis[-1]
return await send_file(
resp = await send_file(
latest, mimetype="application/x-xpinstall",
attachment_filename=latest.name,
)
return _cache(resp, "no-cache")
target = (XPI_DIR / filename).resolve()
try:
target.relative_to(XPI_DIR)
@@ -67,10 +76,19 @@ async def serve_extension(filename: str):
abort(404)
if not target.is_file():
abort(404)
return await send_file(
resp = await send_file(
target, mimetype="application/x-xpinstall",
attachment_filename=filename,
)
return _cache(resp, "public, max-age=31536000, immutable")
def _cache(resp, policy: str):
"""Set the XPI's Cache-Control, dropping the Expires send_file adds so the
two can never disagree."""
resp.headers["Cache-Control"] = policy
resp.headers.pop("Expires", None)
return resp
@frontend_bp.route("/")
+22 -7
View File
@@ -316,17 +316,32 @@ class ArtistService:
cleaned = (prefix or "").strip()
if not cleaned:
return []
like = f"%{cleaned.lower()}%"
prefix_like = f"{cleaned.lower()}%"
# Rank: exact (0) < prefix (1) < substring (2).
low = cleaned.lower()
like = f"%{low}%"
prefix_like = f"{low}%"
# Spacing- and punctuation-insensitive too, so "Tamada Heijun" finds
# "TamadaHeijun" and "sabu_art" finds "Sabu Art" — the same creator is
# spelled differently on every platform, and the browser extension's
# Add panel matches a Discord server name against these (milestone 429).
# [[:alnum:]] keeps non-Latin letters; a query with none (all
# punctuation) skips this arm rather than matching every artist.
squashed = "".join(ch for ch in low if ch.isalnum())
name_squashed = func.regexp_replace(func.lower(Artist.name), "[^[:alnum:]]", "", "g")
matches = [func.lower(Artist.name).like(like)]
if squashed:
matches.append(name_squashed.like(f"%{squashed}%"))
# Rank: exact (0) < exact ignoring spacing (1) < prefix (2) <
# substring (3) < substring ignoring spacing (4).
rank = case(
(func.lower(Artist.name) == cleaned.lower(), 0),
(func.lower(Artist.name).like(prefix_like), 1),
else_=2,
(func.lower(Artist.name) == low, 0),
(name_squashed == squashed, 1),
(func.lower(Artist.name).like(prefix_like), 2),
(func.lower(Artist.name).like(like), 3),
else_=4,
).label("rank")
rows = (await self.session.execute(
select(Artist, rank)
.where(func.lower(Artist.name).like(like))
.where(or_(*matches))
.order_by(rank, Artist.name.asc())
.limit(limit)
)).all()
+58 -30
View File
@@ -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:
+8 -2
View File
@@ -88,7 +88,12 @@ async function checkForUpdateInfo() {
currentVersion,
latestVersion,
channel,
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
// Where the Update button sends the operator: FC's own install card, not
// the XPI. Firefox refuses an add-on install whose navigation an extension
// started (tabs.create on the .xpi dies with NS_ERROR_FAILURE — operator-
// flagged 2026-09-25); it accepts one from a user click on a web page,
// which is exactly what the card's Install button is.
installPageUrl: base ? `${base}/subscriptions?tab=settings` : null,
};
}
@@ -270,6 +275,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 +290,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 };
}
+18
View File
@@ -82,3 +82,21 @@
}
.fc-panel__btn--primary { border-color: rgb(244, 186, 122); background: rgb(244, 186, 122); color: rgb(20, 23, 26); }
.fc-panel__btn:disabled { opacity: 0.5; cursor: default; }
/* Artist autocomplete — the list sits under the field, inside the panel. */
.fc-panel__combo { position: relative; }
.fc-panel__results {
margin-top: 4px; border-radius: 6px;
background: rgb(12, 14, 16);
}
.fc-panel__results:empty { display: none; }
.fc-panel__results:not(:empty) { border: 1px solid rgb(70, 74, 80); padding: 3px; }
.fc-panel__result { display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box; }
.fc-panel__result--active { background: rgb(52, 44, 32); outline: 1px solid rgb(244, 186, 122); }
.fc-panel__result--new { color: rgb(244, 186, 122); }
.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; }
+200 -47
View File
@@ -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,14 +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);
let searchSeq = 0;
const discord = probe.platform === 'discord';
const choice = panelDefaults(probe, window.location.href);
const scopeRow = (value, label, disabled) => {
const input = el('input', {
@@ -168,58 +194,126 @@
return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]);
};
// The artist field is an autocomplete over FC's artists: it searches as
// soon as the panel opens (with the prefilled name) and on every keystroke,
// lists the matches under the field, fills in the rest of the top match as
// you type (Tab or Enter accepts it, typing on replaces it), and picks an
// artist whose name IS the text, spacing and case aside, without being
// asked. ↑/↓ walk the list; the last row creates a new artist instead.
const nameInput = el('input', {
type: 'text', class: 'fc-panel__input', value: choice.artistName,
placeholder: 'Artist name — search or type a new one',
placeholder: 'Search artists or type a new name',
autocomplete: 'off', spellcheck: false,
role: 'combobox',
});
const results = el('div', { class: 'fc-panel__results' });
nameInput.setAttribute('aria-autocomplete', 'both');
nameInput.setAttribute('aria-expanded', 'false');
const results = el('div', { class: 'fc-panel__results', role: 'listbox' });
const hint = el('div', { class: 'fc-panel__hint' });
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' }),
nameInput,
results,
el('div', { class: 'fc-panel__combo' }, [nameInput, results]),
renameRow,
hint,
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
]);
// Search state: the rows on screen, which one ↑/↓ has highlighted (-1 =
// none), and whether the list is open.
let rows = [];
let active = -1;
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} in FabledCurator.`;
else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`;
else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`;
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 showResults(rows) {
results.replaceChildren(...rows.map((a) => {
const row = el('button', { class: 'fc-panel__result', text: a.name });
row.addEventListener('click', () => {
choice.artist = { id: a.id, name: a.name };
choice.artistName = a.name;
nameInput.value = a.name;
results.replaceChildren();
refresh();
});
function pick(artist) {
choice.artist = artist ? { id: artist.id, name: artist.name } : null;
if (artist) {
choice.artistName = artist.name;
nameInput.value = artist.name;
}
closeList();
refresh();
}
function closeList() {
listOpen = false;
active = -1;
results.replaceChildren();
nameInput.setAttribute('aria-expanded', 'false');
}
function renderList() {
const typed = nameInput.value.trim();
const exact = exactArtistMatch(typed, rows);
const items = rows.map((a, i) => {
const row = el('button', { type: 'button', class: 'fc-panel__result', role: 'option' }, [
el('span', { text: a.name }),
]);
if (choice.artist && choice.artist.id === a.id) {
row.appendChild(el('span', { class: 'fc-panel__result-tag', text: 'selected' }));
}
row.classList.toggle('fc-panel__result--active', i === active);
// mousedown, not click: it fires before the input's blur closes the list.
row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(a); });
return row;
}));
});
if (typed && !exact) {
const i = rows.length;
const create = el('button', { type: 'button', class: 'fc-panel__result fc-panel__result--new', role: 'option',
text: `+ New artist “${typed}”` });
create.classList.toggle('fc-panel__result--active', i === active);
create.addEventListener('mousedown', (e) => { e.preventDefault(); pick(null); choice.artistName = typed; refresh(); });
items.push(create);
}
if (typed && !rows.length) {
items.unshift(el('div', { class: 'fc-panel__empty', text: 'No FabledCurator artist matches.' }));
}
results.replaceChildren(...items);
listOpen = items.length > 0;
nameInput.setAttribute('aria-expanded', String(listOpen));
results.querySelector('.fc-panel__result--active')?.scrollIntoView({ block: 'nearest' });
}
let debounce = null;
nameInput.addEventListener('input', () => {
choice.artistName = nameInput.value;
refresh();
let searchSeq = 0;
// `autofill` is false for deletions: filling the name back in as you
// backspace would make it impossible to delete.
function search(autofill) {
clearTimeout(debounce);
const q = nameInput.value.trim();
if (!q) { results.replaceChildren(); return; }
if (!q) { rows = []; closeList(); return; }
debounce = setTimeout(async () => {
const mine = ++searchSeq;
let r;
@@ -228,20 +322,75 @@
} catch {
return;
}
if (mine !== searchSeq || r?.error) return;
showResults(r.artists || []);
}, 200);
// Stale: a later keystroke has its own search coming.
if (mine !== searchSeq || r?.error || nameInput.value.trim() !== q) return;
rows = r.artists || [];
active = -1;
const exact = exactArtistMatch(q, rows);
if (exact && !choice.artist) {
choice.artist = { id: exact.id, name: exact.name };
} else if (autofill && document.activeElement === nameInput) {
const hit = inlineCompletion(nameInput.value, rows);
const caret = nameInput.value.length;
if (hit && nameInput.selectionStart === caret) {
nameInput.value = nameInput.value + hit.name.slice(caret);
nameInput.setSelectionRange(caret, hit.name.length);
choice.artist = { id: hit.id, name: hit.name };
choice.artistName = hit.name;
}
}
renderList();
refresh();
}, 150);
}
nameInput.addEventListener('input', (e) => {
choice.artistName = nameInput.value;
// Any edit un-picks: the artist is whatever the field now says.
choice.artist = null;
refresh();
search(!String(e.inputType || '').startsWith('delete'));
});
nameInput.addEventListener('focus', () => { if (rows.length) renderList(); });
nameInput.addEventListener('blur', () => closeList());
// Keep Discord's global shortcuts from eating keystrokes meant for us.
panel.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Escape') closePanel();
const count = results.querySelectorAll('.fc-panel__result').length;
if (e.target === nameInput && listOpen && count) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
active = e.key === 'ArrowDown' ? (active + 1) % count : (active <= 0 ? count - 1 : active - 1);
renderList();
return;
}
if ((e.key === 'Enter' || e.key === 'Tab') && active >= 0) {
e.preventDefault();
results.querySelectorAll('.fc-panel__result')[active]
.dispatchEvent(new MouseEvent('mousedown', { cancelable: true }));
return;
}
if ((e.key === 'Tab' || e.key === 'Enter') && choice.artist
&& nameInput.selectionEnd > nameInput.selectionStart) {
// Accept the inline autofill — Enter too, but only to accept: the
// add itself takes a second Enter, once the hint names the artist.
e.preventDefault();
pick(choice.artist);
return;
}
}
if (e.key === 'Escape') {
if (listOpen) closeList();
else closePanel();
return;
}
if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click();
});
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;
@@ -253,6 +402,10 @@
document.body.appendChild(panel);
refresh();
nameInput.focus();
nameInput.select();
// Search what the field opens with — the server's name, usually — so an
// artist it already matches is picked before the operator types anything.
if (!choice.artist && nameInput.value.trim()) search(false);
}
function showToast(text, kind) {
+11 -4
View File
@@ -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
+80 -14
View File
@@ -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,35 +46,101 @@ 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 };
}
/**
* An artist name reduced to what identifies it: lowercase letters and digits
* of any script, nothing else — so "Tamada Heijun", "tamada_heijun" and
* "TamadaHeijun" are one name. Mirrors the server's autocomplete (#429).
*/
function squashName(name) {
return String(name || '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
}
/** The search result that IS the query, spacing aside, else null. */
function exactArtistMatch(query, results) {
const q = squashName(query);
if (!q) return null;
return (results || []).find((a) => squashName(a.name) === q) || null;
}
/**
* Inline autofill: the first result whose name extends what was typed
* (case-insensitive), so the panel can fill in the rest and select it —
* typing on overwrites it, Tab or Enter accepts it. null when none does.
*/
function inlineCompletion(typed, results) {
const t = String(typed || '').toLowerCase();
if (!t) return null;
return (results || []).find((a) => a.name.length > t.length && a.name.toLowerCase().startsWith(t)) || null;
}
+8 -4
View File
@@ -76,7 +76,7 @@ function updateConnectionDot(connected) {
async function checkForUpdate() {
try {
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
if (r && r.updateAvailable && r.installPageUrl) showUpdateBanner(r);
} catch { /* non-fatal */ }
}
@@ -86,10 +86,14 @@ function showUpdateBanner(r) {
// exactly as it did before the field existed.
const channel = r.channel ? ` (${r.channel})` : '';
document.getElementById('update-text').textContent =
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
// Opening the signed XPI triggers Firefox's native install prompt.
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion}). ` +
'Opens FabledCurator — click “Install Firefox extension” there.';
// Opens FC's install card rather than the XPI: Firefox only installs an
// add-on from a user click on a web page, never from a tab an extension
// opened on the .xpi itself.
document.getElementById('update-btn').addEventListener('click', () => {
browser.tabs.create({ url: r.xpiUrl });
browser.tabs.create({ url: r.installPageUrl });
window.close();
});
document.getElementById('update-banner').classList.remove('hidden');
}
+106 -14
View File
@@ -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,46 +64,137 @@ 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)
})
})
const { squashName, exactArtistMatch, inlineCompletion } = loadLib('chip.js', [
'squashName',
'exactArtistMatch',
'inlineCompletion'
])
describe('artist matching for the Add panel', () => {
const results = [
{ id: 1, name: 'TamadaHeijun' },
{ id: 2, name: 'Tamago' },
{ id: 3, name: 'Sabu Art' }
]
it('treats spacing, case and punctuation as the same name', () => {
expect(squashName('Tamada Heijun')).toBe('tamadaheijun')
expect(squashName('sabu_art!')).toBe('sabuart')
expect(squashName('玉田 平順')).toBe('玉田平順')
})
it('finds the result that is the query, spacing aside', () => {
expect(exactArtistMatch('tamada heijun', results)).toEqual({ id: 1, name: 'TamadaHeijun' })
expect(exactArtistMatch('Tama', results)).toBe(null)
expect(exactArtistMatch(' ', results)).toBe(null)
})
it('autofills the first name that extends what was typed', () => {
expect(inlineCompletion('tamad', results)).toEqual({ id: 1, name: 'TamadaHeijun' })
expect(inlineCompletion('Sab', results).name).toBe('Sabu Art')
// Nothing to add once the name is complete, or when nothing extends it.
expect(inlineCompletion('Sabu Art', results)).toBe(null)
expect(inlineCompletion('heijun', 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')
})
})
@@ -49,16 +49,20 @@
sometimes triggered nothing instead of the install dialog
(operator-flagged 2026-05-26). No `download` attribute —
that would force a save dialog instead of install. -->
<!-- The VERSIONED file, not the `latest` alias: a versioned URL can
only ever be this build's bytes, so a browser cache can't hand
back the previous build (operator-flagged 2026-09-25: a cached
alias reinstalled the old version and the update never took). -->
<v-btn
v-if="isFirefox"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-firefox"
:href="manifest.latest_url"
:href="manifest.xpi_url"
>Install Firefox extension</v-btn>
<v-btn
variant="outlined" rounded="pill"
:href="manifest.latest_url" download
:href="manifest.xpi_url" download
prepend-icon="mdi-download"
>Download XPI</v-btn>
+137
View File
@@ -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 ---------------------------------------
@@ -664,6 +779,28 @@ async def test_serve_extension_latest_returns_most_recent_xpi(
assert data == b"new"
@pytest.mark.asyncio
async def test_the_latest_alias_is_never_served_from_a_stale_cache(
client, monkeypatch, tmp_path,
):
"""One URL whose bytes change every release: a cached copy reinstalls the
previous build (operator-flagged 2026-09-25, when it was max-age=43200)."""
(tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new")
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
resp = await client.get("/extension/fabledcurator-latest.xpi")
assert resp.headers["Cache-Control"] == "no-cache"
assert "Expires" not in resp.headers
@pytest.mark.asyncio
async def test_a_versioned_xpi_is_cached_for_good(client, monkeypatch, tmp_path):
"""A versioned name is one build's bytes forever."""
(tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new")
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
resp = await client.get("/extension/fabledcurator-1.0.1.xpi")
assert "immutable" in resp.headers["Cache-Control"]
@pytest.mark.asyncio
async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path):
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
@@ -50,6 +50,34 @@ async def test_autocomplete_ranks_exact_prefix_substring(db):
assert "Bob" not in names
@pytest.mark.asyncio
async def test_autocomplete_ignores_spacing_and_punctuation(db):
"""The same creator is spelled differently per platform — a Discord server
"Tamada Heijun" must find the Patreon artist "TamadaHeijun" (milestone 429)."""
db.add_all([
Artist(name="TamadaHeijun", slug="tamadaheijun"),
Artist(name="Sabu Art", slug="sabu-art"),
Artist(name="Bob", slug="bob"),
])
await db.flush()
svc = ArtistService(db)
assert [r.name for r in await svc.autocomplete("Tamada Heijun")] == ["TamadaHeijun"]
assert [r.name for r in await svc.autocomplete("sabu_art")] == ["Sabu Art"]
# All punctuation: no squashed arm, so it does not match everyone.
assert await svc.autocomplete("--") == []
@pytest.mark.asyncio
async def test_autocomplete_ranks_an_exact_match_ignoring_spacing_above_a_prefix(db):
db.add_all([
Artist(name="Sabu Artworks", slug="sabu-artworks"),
Artist(name="SabuArt", slug="sabuart"),
])
await db.flush()
names = [r.name for r in await ArtistService(db).autocomplete("sabu art")]
assert names == ["SabuArt", "Sabu Artworks"]
@pytest.mark.asyncio
async def test_autocomplete_empty_query_returns_empty(db):
svc = ArtistService(db)