Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e1382812 | ||
|
|
423275a1e5 |
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-4
@@ -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
|
||||
|
||||
+53
-14
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+71
-14
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user