feat: the extension adds Discord channels to an artist you pick, and its tests gate the XPI (milestone 429)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s

Server (#4420)
- extension_service gains a Discord pattern (server or channel, jump links,
  ptb/canary; not DMs or threads), mirrored in platforms.js and pinned by
  the shared artist-url-samples.json.
- probe on a Discord URL matches the source by ids under any artist, reports
  a whole-server source as covering the channel, suggests the artist who owns
  another source on the same server, and names server/channel via the stored
  token (best-effort, bounded, no rate-limit waits).
- quick-add takes artist_id / artist_name; Discord URLs are stored canonical.

Extension (#4421, #4422)
- Content script on discord.com; SPA navigation by URL polling (the old
  pushState patch ran in the isolated world and never fired); stale probes
  are dropped.
- Discord chip opens an Add panel: this channel or the whole server, and the
  suggested artist / a search / a new name.
- Popup: sources show artist, platform and state; a Discord token export is
  verified by FC and the result shown. Token capture covers ptb/canary.
- Pure logic in lib/chip.js and lib/popup-format.js, with specs.

CI (#4423)
- extension.yml's lane (web-ext lint, vitest, XPI contents) moves into
  build.yml as extension-test and joins the needs of sign-extension,
  build-web and build-agent. As a separate workflow it gated nothing: a red
  extension suite still signed and shipped the XPI (rule 177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-24 23:31:38 -04:00
co-authored by Claude Opus 5.5
parent 97045279a3
commit 4c75dd0f88
23 changed files with 1176 additions and 209 deletions
+76 -4
View File
@@ -242,7 +242,7 @@ jobs:
# rather than left running beside the new mechanism (rule 22). # rather than left running beside the new mechanism (rule 22).
# #
# Two things are still worth asserting, and this is the only lane that can: # Two things are still worth asserting, and this is the only lane that can:
# the extension.yml suite runs on node:24-slim, which is exactly why # the extension-test lane runs on node:24-slim, which is exactly why
# version.spec.js sticks to packaging.sh's git-free subcommands. # version.spec.js sticks to packaging.sh's git-free subcommands.
# 1. the derivation actually resolves on this commit # 1. the derivation actually resolves on this commit
# 2. the derived string is one AMO will accept, checked against Mozilla's # 2. the derived string is one AMO will accept, checked against Mozilla's
@@ -378,6 +378,76 @@ jobs:
- run: npm run test:unit - run: npm run test:unit
- run: npm run build - run: npm run build
# The extension's lane: web-ext lint, the vitest suite, and the check that
# asks web-ext what it ACTUALLY packaged. Moved in from `extension.yml`
# (deleted) at milestone 429 — the same move `ci.yml` made on 2026-09-23 and
# for the same reason: a separate workflow cannot gate this one, so a red
# extension suite still let `sign-extension` sign and `build-web` ship the
# XPI. It is a lane in THE GATE now. Its old path filter is gone with it; a
# filter here would make the lane skip, and a skipped lane blocks the publish.
#
# The vitest suite is also the JS half of the #3093 artist-pattern mirror —
# the Python half runs in backend-lint-and-test — so until this move one half
# of that guard could fail without stopping anything.
extension-test:
runs-on: python-ci
container:
image: node:24-bookworm-slim
steps:
- uses: actions/checkout@v4
# Not --no-save: vitest and web-ext are both real devDependencies now,
# and the suite needs vitest resolvable from node_modules.
- name: Install dev dependencies
run: cd extension && npm install --no-audit --no-fund
- name: Lint
run: cd extension && npm run lint
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
# package version-consistency checks. No browser, no network.
- name: Unit tests
run: cd extension && npm run test:unit
# Everything else about packaging is asserted against our own declaration
# of what ships. This is the only check that asks web-ext what it ACTUALLY
# put in the archive. Until now that was an unverified assumption about
# glob semantics — and a fragile one: `test/**` reaches web-ext intact
# only because callers `set -f` first, so losing that quoting would
# silently start shipping dev files with no other signal.
- name: Verify XPI contents
run: |
set -eu
command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; }
cd extension
npm run build
ZIP=$(ls web-ext-artifacts/*.zip | head -1)
echo "=== packaged entries in $ZIP ==="
unzip -Z1 "$ZIP" | sort
echo "=== end ==="
ENTRIES=$(unzip -Z1 "$ZIP")
fail=0
# Must NOT ship: repo infrastructure with no business in a user's browser.
for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do
if echo "$ENTRIES" | grep -q "^$pat"; then
echo "ERROR: '$pat' was packaged into the XPI but must not be"
fail=1
fi
done
# Must ship: if an exclusion pattern ever over-matches, the extension
# breaks at runtime rather than at build time, so assert presence too.
for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js' 'lib/chip.js' 'lib/popup-format.js'; do
if ! echo "$ENTRIES" | grep -q "^$req$"; then
echo "ERROR: '$req' is missing from the XPI"
fail=1
fi
done
for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do
if ! echo "$ENTRIES" | grep -q "^$dir"; then
echo "ERROR: nothing from '$dir' was packaged"
fail=1
fi
done
[ "$fail" -eq 0 ] || exit 1
echo "XPI contents verified."
# Single integration job — collapsed from a 3-way shard split on 2026-06-04. # Single integration job — collapsed from a 3-way shard split on 2026-06-04.
# The shards existed to parallelize ~8.5min of integration tests; once the # The shards existed to parallelize ~8.5min of integration tests; once the
# throwaway Postgres runs with fsync OFF (the durability step below) the whole # throwaway Postgres runs with fsync OFF (the durability step below) the whole
@@ -521,7 +591,8 @@ jobs:
# itself through a job-level `if:` that could not read `env`, and a design # itself through a job-level `if:` that could not read `env`, and a design
# where only a FAILED gate blocks would have published unverified images # where only a FAILED gate blocks would have published unverified images
# while reporting success. # while reporting success.
needs: [lint, extension-version, backend-lint-and-test, frontend-build, integration] needs: [lint, extension-version, backend-lint-and-test, frontend-build, extension-test,
integration]
# A pull_request run is the lanes and nothing else. This is the ONLY thing # A pull_request run is the lanes and nothing else. This is the ONLY thing
# separating "validate a Renovate bump" from "publish a Renovate bump", so # separating "validate a Renovate bump" from "publish a Renovate bump", so
# it is stated on each publishing job rather than inferred from a `needs` # it is stated on each publishing job rather than inferred from a `needs`
@@ -859,7 +930,7 @@ jobs:
# default behaviour is exactly what we want: a failed sign skips build-web # default behaviour is exactly what we want: a failed sign skips build-web
# rather than shipping an image without its XPI. # rather than shipping an image without its XPI.
needs: [sign-extension, lint, extension-version, backend-lint-and-test, needs: [sign-extension, lint, extension-version, backend-lint-and-test,
frontend-build, integration] frontend-build, extension-test, integration]
# The lanes in that list are THE GATE (2026-09-23) — see sign-extension's # The lanes in that list are THE GATE (2026-09-23) — see sign-extension's
# copy of this comment for why, including the property that a SKIPPED lane # copy of this comment for why, including the property that a SKIPPED lane
# blocks as firmly as a failing one. They are repeated here rather than # blocks as firmly as a failing one. They are repeated here rather than
@@ -2083,7 +2154,8 @@ jobs:
# itself through a job-level `if:` that could not read `env`, and a design # itself through a job-level `if:` that could not read `env`, and a design
# where only a FAILED gate blocks would have published unverified images # where only a FAILED gate blocks would have published unverified images
# while reporting success. # while reporting success.
needs: [lint, extension-version, backend-lint-and-test, frontend-build, integration] needs: [lint, extension-version, backend-lint-and-test, frontend-build, extension-test,
integration]
# What `promote` needs to publish this image once the smoke has passed: # What `promote` needs to publish this image once the smoke has passed:
# the manifest this run built (empty on a reuse hit) and every tag it # the manifest this run built (empty on a reuse hit) and every tag it
# belongs under. Same meaning as build-web's outputs of the same names. # belongs under. Same meaning as build-web's outputs of the same names.
-86
View File
@@ -1,86 +0,0 @@
name: extension
# Lint + unit tests. Deliberately NOT a publishing lane, which is why it is
# not part of build.yml's gate. The sign-and-publish dance moved into build.yml's
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
# because sign-extension runs as a build-web dependency in the SAME workflow,
# eliminating the prior race between build.yml and a separate extension.yml.
# Signed XPIs are cached in Forgejo Release Assets named `ext-<version>`.
on:
push:
branches: [dev, main]
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts things ABOUT build.yml — that it does not
# inline the packaged-file set, and that build.yml derives
# the shipped version rather than reading it out of the repo. A
# workflow-only edit can therefore break this suite, so it has to trigger
# it. build.yml joined the list at milestone 271 step 5, when the spec
# started asserting against it.
- '.forgejo/workflows/build.yml'
pull_request:
branches: [main]
paths:
- 'extension/**'
- '.forgejo/workflows/build.yml'
workflow_dispatch:
jobs:
lint:
runs-on: python-ci
container:
image: node:24-bookworm-slim
steps:
- uses: actions/checkout@v4
# Not --no-save: vitest and web-ext are both real devDependencies now,
# and the suite needs vitest resolvable from node_modules.
- name: Install dev dependencies
run: cd extension && npm install --no-audit --no-fund
- name: Lint
run: cd extension && npm run lint
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
# package version-consistency checks. No browser, no network.
- name: Unit tests
run: cd extension && npm run test:unit
# Everything else about packaging is asserted against our own declaration
# of what ships. This is the only check that asks web-ext what it ACTUALLY
# put in the archive. Until now that was an unverified assumption about
# glob semantics — and a fragile one: `test/**` reaches web-ext intact
# only because callers `set -f` first, so losing that quoting would
# silently start shipping dev files with no other signal.
- name: Verify XPI contents
run: |
set -eu
command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; }
cd extension
npm run build
ZIP=$(ls web-ext-artifacts/*.zip | head -1)
echo "=== packaged entries in $ZIP ==="
unzip -Z1 "$ZIP" | sort
echo "=== end ==="
ENTRIES=$(unzip -Z1 "$ZIP")
fail=0
# Must NOT ship: repo infrastructure with no business in a user's browser.
for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do
if echo "$ENTRIES" | grep -q "^$pat"; then
echo "ERROR: '$pat' was packaged into the XPI but must not be"
fail=1
fi
done
# Must ship: if an exclusion pattern ever over-matches, the extension
# breaks at runtime rather than at build time, so assert presence too.
for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js'; do
if ! echo "$ENTRIES" | grep -q "^$req$"; then
echo "ERROR: '$req' is missing from the XPI"
fail=1
fi
done
for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do
if ! echo "$ENTRIES" | grep -q "^$dir"; then
echo "ERROR: nothing from '$dir' was packaged"
fail=1
fi
done
[ "$fail" -eq 0 ] || exit 1
echo "XPI contents verified."
+6 -5
View File
@@ -271,11 +271,12 @@ Four deployable pieces, built by `.forgejo/workflows/build.yml`:
## CI / Forgejo setup ## CI / Forgejo setup
Three workflows: `build.yml` (the five verification lanes — lint, Two workflows that matter here: `build.yml` (the six verification lanes — lint,
extension-version check, backend unit tests, frontend build, integration — and extension-version check, backend unit tests, frontend build, extension lint +
then sign + publish), `extension.yml` (extension lint, vitest, XPI content vitest + XPI content check, integration — and then sign + publish), and
verification), and `release.yml`, which runs only on a `v*` tag and publishes a `release.yml`, which runs only on a `v*` tag and publishes a changelog without
changelog without building anything. building anything. The extension lane was its own `extension.yml` until
milestone 429, which let a red extension suite sign and ship the XPI anyway.
**The lanes and the publish are one workflow on purpose.** They were two **The lanes and the publish are one workflow on purpose.** They were two
(`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which (`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which
+20 -2
View File
@@ -19,6 +19,7 @@ from ..models import AppSetting
from ..services.extension_service import ( from ..services.extension_service import (
ExtensionService, ExtensionService,
InvalidUrlError, InvalidUrlError,
UnknownArtistError,
UnknownPlatformError, UnknownPlatformError,
) )
from ..services.source_service import KNOWN_PLATFORMS from ..services.source_service import KNOWN_PLATFORMS
@@ -87,10 +88,14 @@ async def probe_source():
url = (request.args.get("url") or "").strip() url = (request.args.get("url") or "").strip()
if not url: if not url:
return _bad("invalid_body", detail="url query parameter is required") return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session: async with get_session() as session:
if not await _ext_key_required(session): if not await _ext_key_required(session):
return _bad("unauthorized", status=401) return _bad("unauthorized", status=401)
result = await ExtensionService(session).probe(url) # 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)
return jsonify(result) return jsonify(result)
@@ -102,6 +107,15 @@ async def quick_add_source():
url = body.get("url") url = body.get("url")
if not isinstance(url, str) or not url.strip(): if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required") return _bad("invalid_body", detail="url is required")
# Optional: connect the new source to an existing artist (artist_id) or to
# the artist of that name (artist_name). A Discord channel names no
# creator, so the extension's Add panel always sends one of them.
artist_id = body.get("artist_id")
if artist_id is not None and (isinstance(artist_id, bool) or not isinstance(artist_id, int)):
return _bad("invalid_body", detail="artist_id must be an integer")
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")
from .credentials import _get_crypto from .credentials import _get_crypto
@@ -111,7 +125,11 @@ async def quick_add_source():
try: try:
# crypto lets an add resolve the artist's display name via the # crypto lets an add resolve the artist's display name via the
# stored credential (else it falls back to the URL handle). #130. # stored credential (else it falls back to the URL handle). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(url) result = await ExtensionService(session, _get_crypto()).quick_add_source(
url, artist_id=artist_id, artist_name=artist_name,
)
except UnknownArtistError as exc:
return _bad("not_found", detail=str(exc), status=404)
except UnknownPlatformError as exc: except UnknownPlatformError as exc:
return _bad( return _bad(
"unknown_platform", "unknown_platform",
+18
View File
@@ -510,6 +510,24 @@ class DiscordClient:
# -- verify ------------------------------------------------------------ # -- verify ------------------------------------------------------------
def describe(self, server_id: str | None, channel_id: str | None) -> dict:
"""The display names behind a server/channel pair, for the browser
extension's Add panel. Best-effort per name: one that can't be read
comes back None, and the other is still returned."""
out: dict = {"server": None, "channel": None, "parent": None}
if server_id:
try:
out["server"] = (self._get(f"/guilds/{server_id}") or {}).get("name") or None
except DiscordAPIError:
pass
if channel_id:
try:
meta = self._parse_channel(self._get(f"/channels/{channel_id}"))
out["channel"] = meta.get("channel") or None
except (DiscordAPIError, AttributeError):
pass
return out
def verify_auth(self, url: str) -> tuple[bool | None, str]: def verify_auth(self, url: str) -> tuple[bool | None, str]:
"""Is the token valid, and can it see what the source names?""" """Is the token valid, and can it see what the source names?"""
try: try:
+188 -10
View File
@@ -21,6 +21,9 @@ from .source_service import BACKFILL_MAX_CHUNKS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# The probe runs while the chip is drawing; names that take longer are skipped.
_NAME_LOOKUP_SECONDS = 6.0
class UnknownPlatformError(Exception): class UnknownPlatformError(Exception):
"""URL didn't match any platform pattern.""" """URL didn't match any platform pattern."""
@@ -30,6 +33,10 @@ class InvalidUrlError(Exception):
"""URL was empty or missing a scheme.""" """URL was empty or missing a scheme."""
class UnknownArtistError(Exception):
"""quick-add named an `artist_id` that does not exist."""
# Mirrored byte-for-byte from extension/lib/platforms.js # Mirrored byte-for-byte from extension/lib/platforms.js
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand — # PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
# reviewers catch drift. # reviewers catch drift.
@@ -55,8 +62,39 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)", r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
re.IGNORECASE, re.IGNORECASE,
)), )),
# A Discord URL names a server or a channel, never a creator, so the slug is
# `<server>` or `<server>/<channel>` and the artist is chosen, not derived.
# A trailing message id (a jump link) still names its channel. DMs (`@me`)
# are not sources; thread links (`/threads/`) are left to the manual form.
("discord", re.compile(
r"^https?://(?:www\.|ptb\.|canary\.)?discord\.com/channels/"
r"(?P<slug>\d+(?:/\d+)?)(?:/\d+)?/?(?:[?#].*)?$",
re.IGNORECASE,
)),
] ]
DISCORD = "discord"
def canonical_source_url(platform: str, url: str, slug: str) -> str:
"""The URL a new source is stored under. Discord's is rebuilt from the ids
— the form the manual Add form and the ingester use — so a jump link, a
ptb/canary host or a trailing slash never makes a second source for the
same channel. Every other platform keeps the URL as given."""
if platform == DISCORD:
return f"https://discord.com/channels/{slug}"
return url
def _discord_ids(url: str) -> tuple[str | None, str | None] | None:
"""`(server_id, channel_id)` of a stored Discord source URL, None if it
does not parse (a DM or thread link, or an old malformed row)."""
from .discord_client import DiscordAPIError, parse_source_url
try:
return parse_source_url(url)
except DiscordAPIError:
return None
class ExtensionService: class ExtensionService:
def __init__(self, session: AsyncSession, crypto=None) -> None: def __init__(self, session: AsyncSession, crypto=None) -> None:
@@ -65,30 +103,63 @@ class ExtensionService:
# add-time. None → skip resolution, fall back to the handle. # add-time. None → skip resolution, fall back to the handle.
self._crypto = crypto self._crypto = crypto
async def quick_add_source(self, url: str) -> dict: async def quick_add_source(
self,
url: str,
*,
artist_id: int | None = None,
artist_name: str | None = None,
) -> 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."""
platform, raw_slug = self._derive(url) platform, raw_slug = self._derive(url)
url = canonical_source_url(platform, url, raw_slug)
# Identity by SOURCE handle (#130): an existing (platform, url) source # Identity by SOURCE handle (#130): an existing (platform, url) source
# keeps its artist on re-add — even if that artist was since renamed (its # keeps its artist on re-add — even if that artist was since renamed (its
# frozen slug no longer matches the current name). Only a genuinely new # frozen slug no longer matches the current name), and even when the
# source resolves/creates an artist. # add named a different artist. Only a genuinely new source
existing = (await self.session.execute( # resolves/creates an artist.
select(Source).where(Source.platform == platform, Source.url == url) existing = await self._existing_source(platform, url)
)).scalar_one_or_none()
if existing is not None: if existing is not None:
artist = (await self.session.execute( artist = (await self.session.execute(
select(Artist).where(Artist.id == existing.artist_id) select(Artist).where(Artist.id == existing.artist_id)
)).scalar_one() )).scalar_one()
return self._shape(existing, artist, created_source=False, created_artist=False) return self._shape(existing, artist, created_source=False, created_artist=False)
# New source → name the artist properly by resolving the real display if artist_id is not None:
# name from the platform (falls back to the URL handle). artist = (await self.session.execute(
name = await self._resolve_artist_name(platform, raw_slug, url) select(Artist).where(Artist.id == artist_id)
artist, created_artist = await self._find_or_create_artist(name) )).scalar_one_or_none()
if artist is None:
raise UnknownArtistError(f"no artist with id {artist_id}")
created_artist = False
else:
name = (artist_name or "").strip()
if not name:
# Name the artist properly by resolving the real display name
# from the platform (falls back to the URL handle).
name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name)
source, created_source = await self._find_or_create_source( source, created_source = await self._find_or_create_source(
artist_id=artist.id, platform=platform, url=url, artist_id=artist.id, platform=platform, url=url,
) )
return self._shape(source, artist, created_source, created_artist) return self._shape(source, artist, created_source, created_artist)
async def _existing_source(self, platform: str, url: str) -> Source | None:
"""The source this URL already is, whichever artist owns it. Discord
compares ids, not strings, so a row stored before canonicalisation (a
ptb host, a trailing slash) is still found."""
if platform != DISCORD:
return (await self.session.execute(
select(Source).where(Source.platform == platform, Source.url == url)
)).scalars().first()
want = _discord_ids(url)
rows = (await self.session.execute(
select(Source).where(Source.platform == DISCORD).order_by(Source.id)
)).scalars().all()
return next((s for s in rows if _discord_ids(s.url) == want), None)
@staticmethod @staticmethod
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict: def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
return { return {
@@ -117,6 +188,11 @@ class ExtensionService:
platforms (and any failure — no credential, network error) fall back to platforms (and any failure — no credential, network error) fall back to
the URL handle, which is already readable. the URL handle, which is already readable.
The resolvers are sync, so they run in an executor.""" The resolvers are sync, so they run in an executor."""
if platform == DISCORD:
# The server's name: what the operator knows the community as.
server_id = raw_slug.split("/", 1)[0]
names = await self._discord_names(server_id, None)
return names.get("server") or f"Discord {server_id}"
if self._crypto is None or platform not in ("patreon", "subscribestar"): if self._crypto is None or platform not in ("patreon", "subscribestar"):
return raw_slug return raw_slug
import asyncio import asyncio
@@ -166,6 +242,8 @@ class ExtensionService:
platform, raw_slug = self._derive(url) platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError): except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"} return {"state": "unknown_platform"}
if platform == DISCORD:
return await self._probe_discord(raw_slug)
slug = slugify(raw_slug) slug = slugify(raw_slug)
artist = (await self.session.execute( artist = (await self.session.execute(
@@ -205,6 +283,106 @@ class ExtensionService:
}, },
} }
async def _probe_discord(self, raw_slug: str) -> dict:
"""probe for a Discord server or channel. The states mean what they
mean elsewhere, but the artist is never read off the URL:
- source_match: this channel is a source — or the whole server is
(`covered_by_server`), which already walks every channel;
- artist_match: another source on this server belongs to an artist,
the one this channel most likely belongs to too (a suggestion the
Add panel preselects, not a decision);
- new: nothing on this server yet.
`discord` carries the ids, both canonical URLs and the display names,
read with the stored token; a name that can't be read is None."""
server_id, _, channel_id = raw_slug.partition("/")
channel_id = channel_id or None
rows = (await self.session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == DISCORD)
.order_by(Source.id)
)).all()
exact = server_whole = on_server = None
for source, artist in rows:
ids = _discord_ids(source.url)
if ids is None or ids[0] != server_id:
continue
if ids[1] == channel_id and exact is None:
exact = (source, artist)
elif ids[1] is None and server_whole is None:
server_whole = (source, artist)
if on_server is None:
on_server = (source, artist)
names = await self._discord_names(server_id, channel_id)
base = f"https://discord.com/channels/{server_id}"
result: dict = {
"platform": DISCORD,
"slug": raw_slug,
"discord": {
"server_id": server_id,
"channel_id": channel_id,
"server_name": names.get("server"),
"channel_name": names.get("channel"),
"server_url": base,
"channel_url": f"{base}/{channel_id}" if channel_id else None,
},
}
hit = exact or server_whole
if hit is not None:
source, artist = hit
result.update(
state="source_match",
artist=self._artist_payload(artist),
source=self._source_payload(source),
covered_by_server=exact is None,
)
elif on_server is not None:
result.update(state="artist_match", artist=self._artist_payload(on_server[1]))
else:
result["state"] = "new"
return result
async def _discord_names(self, server_id: str | None, channel_id: str | None) -> dict:
"""Server/channel display names via the stored Discord token. Never
raises and never waits out a rate limit: it runs while the operator
looks at a page, so a slow or missing answer just means no names."""
if self._crypto is None:
return {}
import asyncio
from .credential_service import CredentialService
from .discord_client import DiscordClient
try:
token = await CredentialService(self.session, self._crypto).get_token(DISCORD)
if not token:
return {}
client = DiscordClient(token, max_retries=0)
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(None, client.describe, server_id, channel_id),
timeout=_NAME_LOOKUP_SECONDS,
)
except Exception as exc: # names are decoration — never fail the call
log.info("Discord name lookup failed: %s", exc)
return {}
@staticmethod
def _artist_payload(artist) -> dict:
return {"id": artist.id, "name": artist.name, "slug": artist.slug}
@staticmethod
def _source_payload(source) -> dict:
return {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
}
def _derive(self, url: str) -> tuple[str, str]: def _derive(self, url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url.strip(): if not isinstance(url, str) or not url.strip():
raise InvalidUrlError("url is empty") raise InvalidUrlError("url is empty")
+3 -3
View File
@@ -15,7 +15,7 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
## Secondary runtime image ## Secondary runtime image
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only. node:24-bookworm-slim — `build.yml`'s `extension-test` job only.
`.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else `.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else
and installs nothing: it needs git and stdlib python, and builds no image. and installs nothing: it needs git and stdlib python, and builds no image.
@@ -29,8 +29,8 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs - `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
- `npm install --no-audit --no-fund` — in `frontend-build` job - `npm install --no-audit --no-fund` — in `frontend-build` job
- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest) - `npm install --no-audit --no-fund` — in `build.yml`'s `extension-test` job (web-ext + vitest)
- `unzip` — in `extension.yml`'s "Verify XPI contents" step, installed via apt - `unzip` — in `extension-test`'s "Verify XPI contents" step, installed via apt
only when absent (`node:24-bookworm-slim` may or may not carry it). Debian only when absent (`node:24-bookworm-slim` may or may not carry it). Debian
package, ~2s. Not worth baking into a shared image for a single consumer, per package, ~2s. Not worth baking into a shared image for a single consumer, per
`docs/process.md`'s ">1 project" rule. `docs/process.md`'s ">1 project" rule.
+24 -3
View File
@@ -128,7 +128,8 @@ browser.webRequest.onBeforeSendHeaders.addListener(
saveDiscordToken(auth.value); saveDiscordToken(auth.value);
} }
}, },
{ urls: ['https://discord.com/api/*'] }, // ptb/canary are Discord's beta clients; their API calls carry the same token.
{ urls: ['https://discord.com/api/*', 'https://*.discord.com/api/*'] },
['requestHeaders'], ['requestHeaders'],
); );
@@ -213,7 +214,17 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (key === 'discord') { if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
await api.uploadCredentials('discord', 'token', discordToken); await api.uploadCredentials('discord', 'token', discordToken);
return { success: true }; // Then have FC try it against a Discord source, so a token Discord
// has already revoked shows up here rather than at the next check.
// A failed verify never undoes the upload: valid=null means FC could
// not test (no Discord source yet), not that the token is bad.
let verify = null;
try {
verify = await api.verifyCredential('discord');
} catch (e) {
verify = { valid: null, reason: e.message };
}
return { success: true, verify };
} }
return { error: 'Unsupported platform.' }; return { error: 'Unsupported platform.' };
} catch (e) { } catch (e) {
@@ -256,7 +267,17 @@ browser.runtime.onMessage.addListener(async (msg) => {
case 'ADD_AS_SOURCE': case 'ADD_AS_SOURCE':
try { try {
return await api.quickAddSource(msg.url); return await api.quickAddSource(msg.url, {
artistId: msg.artistId ?? null,
artistName: msg.artistName ?? null,
});
} catch (e) {
return { error: e.message };
}
case 'SEARCH_ARTISTS':
try {
return { artists: await api.searchArtists(msg.q || '') };
} catch (e) { } catch (e) {
return { error: e.message }; return { error: e.message };
} }
+42
View File
@@ -40,3 +40,45 @@
from { transform: translateY(20px); opacity: 0; } from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
} }
/* Discord Add panel — sits above the chip. Same slate/parchment palette. */
.fc-panel {
all: revert;
position: fixed; bottom: 76px; right: 24px; z-index: 2147483647;
box-sizing: border-box; width: 320px; max-width: calc(100vw - 48px);
padding: 14px 16px; border-radius: 10px;
background: rgb(20, 23, 26); color: rgb(232, 228, 216);
border: 1px solid rgb(60, 64, 70);
font: 14px/1.4 system-ui, sans-serif;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.fc-panel__title { font-weight: 600; color: rgb(244, 186, 122); }
.fc-panel__sub { color: rgb(170, 166, 156); font-size: 12px; margin-bottom: 8px; }
.fc-panel__label {
margin: 10px 0 4px; font-size: 11px; letter-spacing: 0.06em;
text-transform: uppercase; color: rgb(170, 166, 156);
}
.fc-panel__radio { display: flex; gap: 8px; align-items: center; padding: 2px 0; cursor: pointer; }
.fc-panel__radio input { margin: 0; accent-color: rgb(244, 186, 122); }
.fc-panel__input {
all: revert; box-sizing: border-box; width: 100%;
padding: 7px 9px; border-radius: 6px;
border: 1px solid rgb(70, 74, 80); background: rgb(12, 14, 16); color: inherit;
font: inherit;
}
.fc-panel__input:focus { outline: 2px solid rgb(244, 186, 122); outline-offset: -1px; }
.fc-panel__results { display: flex; flex-direction: column; max-height: 160px; overflow-y: auto; }
.fc-panel__result {
all: revert; text-align: left; padding: 6px 9px; border: none; border-radius: 4px;
background: transparent; color: inherit; font: inherit; cursor: pointer;
}
.fc-panel__result:hover, .fc-panel__result:focus { background: rgb(36, 40, 46); }
.fc-panel__hint { margin-top: 8px; font-size: 12px; color: rgb(170, 166, 156); }
.fc-panel__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.fc-panel__btn {
all: revert; padding: 6px 14px; border-radius: 999px; cursor: pointer;
border: 1px solid rgb(70, 74, 80); background: transparent; color: inherit;
font: 500 13px/1.2 system-ui, sans-serif;
}
.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; }
+194 -71
View File
@@ -5,42 +5,61 @@
// Cached probe result for the current URL so click-handlers know which // Cached probe result for the current URL so click-handlers know which
// action to dispatch without round-tripping again. // action to dispatch without round-tripping again.
let currentProbe = null; let currentProbe = null;
// Bumped on every evaluate(): a probe that answers after the operator has
// navigated on is for a page they've left, and must not repaint the chip.
let generation = 0;
let lastUrl = window.location.href;
evaluate(); evaluate();
const reEval = () => evaluate(); // SPA navigation. Patreon, SubscribeStar and above all Discord change
window.addEventListener('popstate', reEval); // channel/page without a reload. Patching history.pushState from here never
const origPush = history.pushState; // worked: a content script runs in an isolated world, so the page's own
history.pushState = function () { origPush.apply(this, arguments); reEval(); }; // pushState is not the function we'd replace. Polling the URL is the one
// signal that sees every navigation, and costs a string compare.
window.addEventListener('popstate', () => onUrlMaybeChanged());
setInterval(onUrlMaybeChanged, 500);
function onUrlMaybeChanged() {
if (window.location.href === lastUrl) return;
lastUrl = window.location.href;
closePanel();
evaluate();
}
async function evaluate() { async function evaluate() {
const mine = ++generation;
const url = window.location.href; const url = window.location.href;
const platform = getPlatformFromUrl(url); const platform = getPlatformFromUrl(url);
const onArtist = platform && isArtistPage(url, platform); const onArtist = platform && isArtistPage(url, platform);
const btn = document.getElementById('fc-add-source-btn');
if (!onArtist) { if (!onArtist) {
if (btn) btn.remove(); removeButton();
currentProbe = null; currentProbe = null;
return; return;
} }
// On artist pages, ask the backend what state the URL is in BEFORE // Ask the backend what state the URL is in BEFORE drawing the button, so
// injecting the button — so the chip can render the right state on // the chip renders the right state on first paint instead of flashing the
// first paint instead of flashing the generic "Add" copy and // generic "Add" copy and updating afterwards.
// updating afterwards.
let probe; let probe;
try { try {
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url }); probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
} catch (e) { } catch (e) {
probe = { error: e?.message || 'probe failed' }; probe = { error: e?.message || 'probe failed' };
} }
if (mine !== generation) return;
currentProbe = probe; currentProbe = probe;
if (probe?.state === 'unknown_platform') { if (probe?.state === 'unknown_platform') {
if (btn) btn.remove(); removeButton();
return; return;
} }
renderButton(probe); renderButton(probe);
} }
function removeButton() {
document.getElementById('fc-add-source-btn')?.remove();
closePanel();
}
function renderButton(probe) { function renderButton(probe) {
let btn = document.getElementById('fc-add-source-btn'); let btn = document.getElementById('fc-add-source-btn');
if (!btn) { if (!btn) {
@@ -51,79 +70,36 @@
} }
// Reset state classes so re-renders (SPA navigation) don't stack. // Reset state classes so re-renders (SPA navigation) don't stack.
btn.className = 'fc-add-source-btn'; btn.className = 'fc-add-source-btn';
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`); btn.classList.add(`fc-add-source-btn--${chipState(probe)}`);
btn.textContent = labelFor(probe); btn.textContent = chipLabel(probe, PLATFORMS[probe?.platform]?.name || probe?.platform || '');
btn.disabled = false; btn.disabled = false;
} }
function stateModifier(probe) {
if (!probe || probe.error) return 'new';
return ({
source_match: 'source-match',
artist_match: 'artist-match',
new: 'new',
})[probe.state] || 'new';
}
function labelFor(probe) {
if (!probe || probe.error) return '+ Add to FabledCurator';
const platformName = platformDisplayName(probe.platform);
const artistName = probe.artist?.name;
switch (probe.state) {
case 'source_match':
return `✓ In FabledCurator · ${platformName}`;
case 'artist_match':
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
case 'new':
default:
return '+ Add to FabledCurator';
}
}
function platformDisplayName(key) {
return PLATFORMS[key]?.name || key || '';
}
async function onClick() { async function onClick() {
const btn = document.getElementById('fc-add-source-btn'); const btn = document.getElementById('fc-add-source-btn');
if (!btn) return; if (!btn) return;
btn.disabled = true;
const original = btn.textContent;
const probe = currentProbe; const probe = currentProbe;
if (probe?.state === 'source_match') { if (probe?.state === 'source_match') {
btn.textContent = 'Opening…'; await openArtist(btn, probe.artist?.slug);
try {
const r = await browser.runtime.sendMessage({
type: 'OPEN_ARTIST_PAGE',
slug: probe.artist?.slug,
});
if (r?.error) showToast(`Error: ${r.error}`, 'error');
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
}
return; 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);
return;
}
await add(btn, { url: window.location.href });
}
btn.textContent = 'Adding…'; async function openArtist(btn, slug) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Opening…';
try { try {
const r = await browser.runtime.sendMessage({ const r = await browser.runtime.sendMessage({ type: 'OPEN_ARTIST_PAGE', slug });
type: 'ADD_AS_SOURCE', if (r?.error) showToast(`Error: ${r.error}`, 'error');
url: window.location.href,
});
if (r?.error) {
showToast(`Error: ${r.error}`, 'error');
} else {
const verb = r.created_source ? 'Added' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
// Re-probe so the chip flips green without waiting for the next
// navigation.
evaluate();
return;
}
} catch (e) { } catch (e) {
showToast(`Error: ${e.message}`, 'error'); showToast(`Error: ${e.message}`, 'error');
} finally { } finally {
@@ -132,6 +108,153 @@
} }
} }
// One add, shared by the one-click chip and the Discord panel. Resolves
// true on success.
async function add(btn, request) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Adding…';
try {
const r = await browser.runtime.sendMessage({ type: 'ADD_AS_SOURCE', ...request });
if (r?.error) {
showToast(`Error: ${r.error}`, 'error');
return false;
}
const verb = r.created_source ? 'Added to' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
// Re-probe so the chip flips green without waiting for a navigation.
evaluate();
return true;
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
return false;
} finally {
btn.disabled = false;
btn.textContent = original;
}
}
// ---- 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.
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
const { class: className, text, ...rest } = props;
if (className) node.className = className;
if (text != null) node.textContent = text;
Object.assign(node, rest);
for (const c of children) node.appendChild(c);
return node;
}
function closePanel() {
document.getElementById('fc-discord-panel')?.remove();
}
function openDiscordPanel(probe) {
closePanel();
const d = probe.discord || {};
const choice = discordPanelDefaults(probe);
let searchSeq = 0;
const scopeRow = (value, label, disabled) => {
const input = el('input', {
type: 'radio', name: 'fc-discord-scope', value,
checked: choice.scope === value, disabled,
});
input.addEventListener('change', () => { choice.scope = value; refresh(); });
return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]);
};
const nameInput = el('input', {
type: 'text', class: 'fc-panel__input', value: choice.artistName,
placeholder: 'Artist name — search or type a new one',
autocomplete: 'off', spellcheck: false,
});
const results = el('div', { class: 'fc-panel__results' });
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),
el('div', { class: 'fc-panel__label', text: 'Artist' }),
nameInput,
results,
hint,
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
]);
function refresh() {
const req = discordAddRequest(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.`;
}
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();
});
return row;
}));
}
let debounce = null;
nameInput.addEventListener('input', () => {
choice.artistName = nameInput.value;
refresh();
clearTimeout(debounce);
const q = nameInput.value.trim();
if (!q) { results.replaceChildren(); return; }
debounce = setTimeout(async () => {
const mine = ++searchSeq;
let r;
try {
r = await browser.runtime.sendMessage({ type: 'SEARCH_ARTISTS', q });
} catch {
return;
}
if (mine !== searchSeq || r?.error) return;
showResults(r.artists || []);
}, 200);
});
// Keep Discord's global shortcuts from eating keystrokes meant for us.
panel.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Escape') closePanel();
if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click();
});
cancelBtn.addEventListener('click', closePanel);
addBtn.addEventListener('click', async () => {
const req = discordAddRequest(choice);
if (!req) return;
const btn = document.getElementById('fc-add-source-btn');
addBtn.disabled = true;
const ok = await add(btn || addBtn, req);
if (ok) closePanel();
else refresh();
});
document.body.appendChild(panel);
refresh();
nameInput.focus();
}
function showToast(text, kind) { function showToast(text, kind) {
const t = document.createElement('div'); const t = document.createElement('div');
t.className = `fc-toast fc-toast--${kind}`; t.className = `fc-toast fc-toast--${kind}`;
+19 -2
View File
@@ -80,6 +80,17 @@ class FabledCuratorAPI {
getCredentials() { getCredentials() {
return this.request('GET', '/credentials'); return this.request('GET', '/credentials');
} }
// Test the STORED credential against one of the platform's sources — the
// same check the web UI's Verify button runs. {valid: true|false|null, reason}.
verifyCredential(platform) {
return this.request('POST', `/credentials/${encodeURIComponent(platform)}/verify`);
}
// Artist search for the Discord Add panel — the web UI's autocomplete.
searchArtists(q, limit = 8) {
const qs = new URLSearchParams({ q, limit: String(limit) }).toString();
return this.request('GET', `/artists/autocomplete?${qs}`);
}
// FC-3a — sources. // FC-3a — sources.
listSources() { listSources() {
@@ -90,8 +101,14 @@ class FabledCuratorAPI {
} }
// FC-3g — extension-specific. // FC-3g — extension-specific.
quickAddSource(url) { // artistId connects the source to an existing artist, artistName to the
return this.request('POST', '/extension/quick-add-source', { url }); // 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 } = {}) {
const body = { url };
if (artistId != null) body.artist_id = artistId;
else if (artistName) body.artist_name = artistName;
return this.request('POST', '/extension/quick-add-source', body);
} }
probeSource(url) { probeSource(url) {
// Read-only existence check. Drives the content-script chip's // Read-only existence check. Drives the content-script chip's
+80
View File
@@ -0,0 +1,80 @@
/**
* 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.
*
* `probe` is /api/extension/probe's answer; `platformName` is the display
* name (PLATFORMS[key].name), passed in so this file needs no other lib.
*/
function chipState(probe) {
if (!probe || probe.error) return 'new';
return ({ source_match: 'source-match', artist_match: 'artist-match', new: 'new' })[probe.state] || 'new';
}
function chipLabel(probe, platformName) {
if (!probe || probe.error) return '+ Add to FabledCurator';
const artist = probe.artist?.name || 'artist';
if (probe.platform === 'discord') {
const d = probe.discord || {};
if (probe.state === 'source_match') {
return probe.covered_by_server
? `✓ Whole server in FabledCurator · ${artist}`
: `✓ In FabledCurator · ${artist}`;
}
// Every other Discord state opens the panel: the artist is always chosen.
return d.channel_id ? `+ Add ${channelLabel(d)} to FabledCurator` : '+ Add server to FabledCurator';
}
switch (probe.state) {
case 'source_match':
return `✓ In FabledCurator · ${platformName}`;
case 'artist_match':
return `+ Add ${platformName} source to ${artist}`;
default:
return '+ Add to FabledCurator';
}
}
/** `#name` when the probe could read it, else a neutral "this channel". */
function channelLabel(d) {
return d.channel_name ? `#${d.channel_name}` : 'this channel';
}
/** `name` when the probe could read it, else "this server". */
function serverLabel(d) {
return d.server_name || 'this server';
}
/**
* 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.
*/
function discordPanelDefaults(probe) {
const d = probe?.discord || {};
const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null;
return {
scope: d.channel_id ? 'channel' : 'server',
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 || ''),
};
}
/**
* 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.
*/
function discordAddRequest(choice) {
const url = choice.scope === 'server' ? choice.serverUrl : 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 name = (choice.artistName || '').trim();
return name ? { url, artistName: name } : null;
}
+17 -1
View File
@@ -57,7 +57,8 @@ const PLATFORMS = {
domains: ['.discord.com', 'discord.com'], domains: ['.discord.com', 'discord.com'],
authType: 'token', authType: 'token',
color: '#5865F2', color: '#5865F2',
urlPattern: /^https?:\/\/(www\.)?discord\.com/, // ptb/canary are Discord's beta clients — same channels, same token.
urlPattern: /^https?:\/\/((www|ptb|canary)\.)?discord\.com/,
note: 'Open Discord in browser to capture token', note: 'Open Discord in browser to capture token',
}, },
}; };
@@ -80,8 +81,23 @@ const PLATFORM_ARTIST_PATTERNS = {
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i, patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
// A Discord URL names a server or channel, not a creator: the backend's slug
// is `<server>[/<channel>]` and the Add panel asks which artist it belongs
// to. A message jump link still names its channel; DMs (@me) and thread
// links don't match. Mirrors extension_service._PLATFORM_PATTERNS.
discord: /^https?:\/\/(?:www\.|ptb\.|canary\.)?discord\.com\/channels\/\d+(?:\/\d+)?(?:\/\d+)?\/?(?:[?#].*)?$/i,
}; };
/**
* `{serverId, channelId}` from a Discord channel/server URL the artist
* pattern accepts, else null. channelId is null for a whole-server URL.
*/
function parseDiscordUrl(url) {
if (!PLATFORM_ARTIST_PATTERNS.discord.test(url || '')) return null;
const m = /\/channels\/(\d+)(?:\/(\d+))?/.exec(url);
return m ? { serverId: m[1], channelId: m[2] || null } : null;
}
function getPlatformFromUrl(url) { function getPlatformFromUrl(url) {
for (const [key, platform] of Object.entries(PLATFORMS)) { for (const [key, platform] of Object.entries(PLATFORMS)) {
if (platform.urlPattern.test(url)) return key; if (platform.urlPattern.test(url)) return key;
+55
View File
@@ -0,0 +1,55 @@
/**
* The popup's wording, kept apart from its DOM so the specs can load it
* (test/popup-format.spec.js). Classic script, like the rest of lib/.
*/
/**
* One line of state for a source row, from /api/sources' fields, with the
* status class the popup colours it by ('ready' | 'error' | 'no-cookies' for
* a warning | '' for plain). The most actionable fact wins: an error before
* a running backfill, a backfill before the last-checked time.
*/
function sourceStatus(src, now = Date.now()) {
if (!src.enabled) return { text: 'Disabled', kind: '' };
if (src.last_error) {
const first = String(src.last_error).split('\n')[0].trim();
const short = first.length > 90 ? `${first.slice(0, 89)}…` : first;
return { text: `Error — ${short}`, kind: 'error' };
}
if (src.backfill_state === 'running') {
const n = src.backfill_chunks || 0;
return { text: n ? `Backfilling — ${n} chunk${n === 1 ? '' : 's'} done` : 'Backfill queued', kind: 'ready' };
}
if (src.backfill_state === 'stalled') return { text: 'Backfill stalled', kind: 'no-cookies' };
if (!src.last_checked_at) return { text: 'Not checked yet', kind: '' };
return { text: `Checked ${relativeTime(src.last_checked_at, now)}`, kind: '' };
}
// Same buckets and wording as the web UI's canonical formatRelative
// (frontend/src/utils/date.js, snippet #3959) — the extension can't import it (classic
// scripts, separate package), so it mirrors it: "42s ago", "5m ago", "3h ago",
// "2d ago", and "Never" for a missing or unreadable time.
function relativeTime(iso, now = Date.now()) {
const t = iso ? Date.parse(iso) : NaN;
if (Number.isNaN(t)) return 'Never';
const abs = Math.abs(now - t) / 1000;
let body;
if (abs < 60) body = `${Math.floor(abs)}s`;
else if (abs < 3600) body = `${Math.floor(abs / 60)}m`;
else if (abs < 86400) body = `${Math.floor(abs / 3600)}h`;
else body = `${Math.floor(abs / 86400)}d`;
return `${body} ago`;
}
/**
* The popup message after a Discord token export, from FC's verify of the
* stored token: {text, kind} with kind 'success' | 'warning' | 'error'.
* valid=null is "FC couldn't test it" (no Discord source yet, or a network
* hiccup) — a warning with FC's reason, never a failure.
*/
function tokenExportMessage(verify) {
if (!verify) return { text: 'Discord: token exported', kind: 'success' };
if (verify.valid === true) return { text: `Discord: token exported and verified ✓ — ${verify.reason}`, kind: 'success' };
if (verify.valid === false) return { text: `Discord: token exported, but FC's check failed — ${verify.reason}`, kind: 'error' };
return { text: `Discord: token exported (not verified — ${verify.reason})`, kind: 'warning' };
}
+3 -2
View File
@@ -56,9 +56,10 @@
"*://*.patreon.com/*", "*://*.patreon.com/*",
"*://*.subscribestar.com/*", "*://*.subscribestar.com/*",
"*://*.subscribestar.adult/*", "*://*.subscribestar.adult/*",
"*://*.hentai-foundry.com/*" "*://*.hentai-foundry.com/*",
"*://*.discord.com/*"
], ],
"js": ["lib/platforms.js", "content/content-script.js"], "js": ["lib/platforms.js", "lib/chip.js", "content/content-script.js"],
"css": ["content/content-script.css"], "css": ["content/content-script.css"],
"run_at": "document_idle" "run_at": "document_idle"
} }
+1
View File
@@ -47,6 +47,7 @@
</section> </section>
<script src="../lib/platforms.js"></script> <script src="../lib/platforms.js"></script>
<script src="../lib/popup-format.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
+17 -5
View File
@@ -159,7 +159,11 @@ async function exportPlatformCookies(key, card) {
try { try {
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key }); const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
if (r.error) showError(r.error); if (r.error) showError(r.error);
else { else if (key === 'discord') {
const m = tokenExportMessage(r.verify);
showStatusMessage(m.text, m.kind);
await loadPlatformStatus();
} else {
const n = r.cookieCount ?? null; const n = r.cookieCount ?? null;
const verifiedSuffix = r.verified ? ' (verified ✓)' : ''; const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
const msg = n !== null const msg = n !== null
@@ -205,7 +209,10 @@ async function loadSources() {
c.appendChild(mutedNote('No sources yet.')); c.appendChild(mutedNote('No sources yet.'));
return; return;
} }
for (const src of r.sources) c.appendChild(createSourceRow(src)); // Grouped by artist so a creator's Patreon and Discord sit together.
const sorted = [...r.sources].sort((a, b) =>
(a.artist_name || '').localeCompare(b.artist_name || '') || a.id - b.id);
for (const src of sorted) c.appendChild(createSourceRow(src));
} }
function createSourceRow(src) { function createSourceRow(src) {
@@ -215,11 +222,16 @@ function createSourceRow(src) {
info.className = 'info'; info.className = 'info';
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'name'; name.className = 'name';
name.textContent = `${src.platform} · #${src.id}`; const platformName = PLATFORMS[src.platform]?.name || src.platform;
name.textContent = `${src.artist_name || `Source #${src.id}`} · ${platformName}`;
const state = sourceStatus(src);
const st = document.createElement('div');
st.className = `status ${state.kind}`;
st.textContent = state.text;
const url = document.createElement('div'); const url = document.createElement('div');
url.className = 'url'; url.className = 'url';
url.textContent = src.url; url.textContent = src.url;
info.appendChild(name); info.appendChild(url); info.appendChild(name); info.appendChild(st); info.appendChild(url);
const play = document.createElement('button'); const play = document.createElement('button');
play.className = 'play'; play.className = 'play';
play.textContent = '▶'; play.textContent = '▶';
@@ -229,7 +241,7 @@ function createSourceRow(src) {
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id }); const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
play.disabled = false; play.disabled = false;
if (r.error) showError(r.error); if (r.error) showError(r.error);
else showSuccess(`Triggered check for source #${src.id}`); else showSuccess(`Check queued for ${src.artist_name || `source #${src.id}`} (${platformName})`);
}); });
row.appendChild(info); row.appendChild(play); row.appendChild(info); row.appendChild(play);
return row; return row;
+37
View File
@@ -134,5 +134,42 @@
{ "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" }, { "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" },
{ "url": "https://www.hentai-foundry.com/", "why": "site root" } { "url": "https://www.hentai-foundry.com/", "why": "site root" }
] ]
},
"discord": {
"match": [
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222",
"slug": "111111111111111111/222222222222222222",
"why": "a channel: the slug is server/channel -- a Discord URL names a place, not a creator, so the artist is chosen in the Add panel"
},
{
"url": "https://discord.com/channels/111111111111111111",
"slug": "111111111111111111",
"why": "a whole server"
},
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333",
"slug": "111111111111111111/222222222222222222",
"why": "a message jump link still names its channel"
},
{
"url": "https://ptb.discord.com/channels/111111111111111111/222222222222222222",
"slug": "111111111111111111/222222222222222222",
"why": "the ptb and canary clients serve the same channels"
},
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222/",
"slug": "111111111111111111/222222222222222222",
"why": "trailing slash is tolerated"
}
],
"no_match": [
{ "url": "https://discord.com/channels/@me", "why": "the DM list is not a source" },
{ "url": "https://discord.com/channels/@me/222222222222222222", "why": "a DM is not a source" },
{ "url": "https://discord.com/app", "why": "the app shell, no server open" },
{ "url": "https://discord.com/channels/111111111111111111/222222222222222222/threads/444444444444444444", "why": "thread links are left to the manual Add form" },
{ "url": "https://discord.com/servers/111111111111111111", "why": "a server-discovery page, not a channel" }
]
} }
} }
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [
'chipState',
'chipLabel',
'discordPanelDefaults',
'discordAddRequest'
])
const discord = (extra = {}) => ({
platform: 'discord',
slug: '111/222',
discord: {
server_id: '111',
channel_id: '222',
server_name: 'Studio',
channel_name: 'drops',
server_url: 'https://discord.com/channels/111',
channel_url: 'https://discord.com/channels/111/222'
},
...extra
})
describe('chip state and label', () => {
it('keeps the one-click wording on creator platforms', () => {
expect(chipLabel({ state: 'new', platform: 'patreon' }, 'Patreon')).toBe('+ Add to FabledCurator')
expect(
chipLabel({ state: 'artist_match', platform: 'patreon', artist: { name: 'Atole' } }, 'Patreon')
).toBe('+ Add Patreon source to Atole')
expect(chipLabel({ state: 'source_match', platform: 'patreon' }, 'Patreon')).toBe(
'✓ In FabledCurator · Patreon'
)
})
it('offers the channel by name on Discord, whatever the suggestion', () => {
expect(chipLabel(discord({ state: 'new' }), 'Discord')).toBe('+ Add #drops to FabledCurator')
expect(chipLabel(discord({ state: 'artist_match', artist: { name: 'A' } }), 'Discord')).toBe(
'+ Add #drops to FabledCurator'
)
})
it('says "this channel" when the token could not read the name', () => {
const p = discord({ state: 'new' })
p.discord.channel_name = null
expect(chipLabel(p, 'Discord')).toBe('+ Add this channel to FabledCurator')
})
it('says whose source a Discord channel already is, and when the server covers it', () => {
const artist = { name: 'Tamada', slug: 'tamada' }
expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: false }), 'Discord')).toBe(
'✓ In FabledCurator · Tamada'
)
expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: true }), 'Discord')).toBe(
'✓ Whole server in FabledCurator · Tamada'
)
})
it('falls back to the generic add when the probe failed', () => {
expect(chipState({ error: 'x' })).toBe('new')
expect(chipLabel({ error: 'x' }, '')).toBe('+ Add to FabledCurator')
expect(chipState({ state: 'source_match' })).toBe('source-match')
})
})
describe('Discord Add panel', () => {
it('opens on the channel with the suggested artist preselected', () => {
const d = discordPanelDefaults(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' }))
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({
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' } }))
choice.artistName = 'Tamada Alt'
expect(discordAddRequest(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' }))
choice.scope = 'server'
expect(discordAddRequest(choice).url).toBe('https://discord.com/channels/111')
})
it('has nothing to send without an artist', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
choice.artistName = ' '
expect(discordAddRequest(choice)).toBe(null)
})
})
+42 -12
View File
@@ -7,10 +7,14 @@ import { loadLib } from './helpers/loadLib.js'
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8')) const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'))
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib( const { getPlatformFromUrl, isArtistPage, parseDiscordUrl, PLATFORMS, PLATFORM_ARTIST_PATTERNS } =
'platforms.js', loadLib('platforms.js', [
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS'] 'getPlatformFromUrl',
) 'isArtistPage',
'parseDiscordUrl',
'PLATFORMS',
'PLATFORM_ARTIST_PATTERNS'
])
describe('getPlatformFromUrl', () => { describe('getPlatformFromUrl', () => {
it('identifies each platform from a domain URL', () => { it('identifies each platform from a domain URL', () => {
@@ -88,8 +92,11 @@ describe('isArtistPage', () => {
) )
}) })
it('returns false for a platform with no artist pattern (discord)', () => { it('matches Discord server and channel pages, not DMs (milestone 429)', () => {
expect(isArtistPage('https://discord.com/channels/111/222', 'discord')).toBe(true)
expect(isArtistPage('https://discord.com/channels/111', 'discord')).toBe(true)
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false) expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
expect(isArtistPage('https://discord.com/channels/@me/222', 'discord')).toBe(false)
}) })
it('returns false for an unknown platform key', () => { it('returns false for an unknown platform key', () => {
@@ -100,8 +107,8 @@ describe('isArtistPage', () => {
describe('platform table integrity', () => { describe('platform table integrity', () => {
it('gives every artist pattern a corresponding platform entry', () => { it('gives every artist pattern a corresponding platform entry', () => {
// A pattern keyed to a platform that no longer exists is dead code that // A pattern keyed to a platform that no longer exists is dead code that
// silently never fires; the reverse (a platform with no pattern) is the // silently never fires; the reverse (a platform with no pattern) would be
// legitimate discord case, so only this direction is an error. // a platform the button never offers, a product choice rather than an error.
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) { for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
expect(Object.keys(PLATFORMS)).toContain(key) expect(Object.keys(PLATFORMS)).toContain(key)
} }
@@ -125,7 +132,8 @@ describe('platform table integrity', () => {
const samples = { const samples = {
patreon: 'https://www.patreon.com/cw/Atole', patreon: 'https://www.patreon.com/cw/Atole',
subscribestar: 'https://subscribestar.adult/someone', subscribestar: 'https://subscribestar.adult/someone',
hentaifoundry: 'https://www.hentai-foundry.com/user/someone' hentaifoundry: 'https://www.hentai-foundry.com/user/someone',
discord: 'https://ptb.discord.com/channels/111/222'
} }
for (const [key, url] of Object.entries(samples)) { for (const [key, url] of Object.entries(samples)) {
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true) expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
@@ -152,7 +160,7 @@ describe('manifest.json agrees with the platform table', () => {
) )
expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy() expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy()
// The content script exists to draw the Add-as-source button, so a // The content script exists to draw the Add-as-source button, so a
// platform with no artist pattern (discord) has no business here. // platform with no artist pattern has no business here.
expect( expect(
PLATFORM_ARTIST_PATTERNS[owner[0]], PLATFORM_ARTIST_PATTERNS[owner[0]],
`"${m}" injects for ${owner[0]}, which has no artist pattern` `"${m}" injects for ${owner[0]}, which has no artist pattern`
@@ -232,9 +240,8 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => {
it('has samples for every platform that has an artist pattern', () => { it('has samples for every platform that has an artist pattern', () => {
// The guard's own coverage check: without it, deleting a platform's // The guard's own coverage check: without it, deleting a platform's
// samples would make this block pass by testing less. Discord is // samples would make this block pass by testing less. Discord joined at
// deliberately in neither — it is channel-based, with no creator page to // milestone 429 — its slug is server/channel and the artist is chosen.
// put a button on, so it has no artist pattern on either side.
expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort()) expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort())
}) })
@@ -247,3 +254,26 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => {
} }
}) })
}) })
describe('parseDiscordUrl', () => {
it('reads the server and channel ids the Add panel offers', () => {
expect(parseDiscordUrl('https://discord.com/channels/111/222')).toEqual({
serverId: '111',
channelId: '222'
})
expect(parseDiscordUrl('https://discord.com/channels/111/222/333')).toEqual({
serverId: '111',
channelId: '222'
})
expect(parseDiscordUrl('https://discord.com/channels/111')).toEqual({
serverId: '111',
channelId: null
})
})
it('returns null for anything the artist pattern rejects', () => {
expect(parseDiscordUrl('https://discord.com/channels/@me/222')).toBe(null)
expect(parseDiscordUrl('https://discord.com/app')).toBe(null)
expect(parseDiscordUrl('')).toBe(null)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { sourceStatus, relativeTime, tokenExportMessage } = loadLib('popup-format.js', [
'sourceStatus',
'relativeTime',
'tokenExportMessage'
])
const NOW = Date.parse('2026-09-25T12:00:00Z')
const src = (extra = {}) => ({ enabled: true, last_error: null, backfill_state: null, ...extra })
describe('sourceStatus', () => {
it('puts an error first, trimmed to its first line', () => {
const s = sourceStatus(src({ last_error: 'Discord rejected the token\nstack…', backfill_state: 'running' }), NOW)
expect(s).toEqual({ text: 'Error — Discord rejected the token', kind: 'error' })
})
it('shows a running backfill and its progress', () => {
expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 0 }), NOW).text).toBe('Backfill queued')
expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 3 }), NOW).text).toBe(
'Backfilling — 3 chunks done'
)
})
it('says when a source was last checked, or that it never was', () => {
expect(sourceStatus(src({ last_checked_at: '2026-09-25T11:55:00Z' }), NOW).text).toBe('Checked 5m ago')
expect(sourceStatus(src({ last_checked_at: null }), NOW).text).toBe('Not checked yet')
})
it('shows a disabled source as disabled, whatever else it carries', () => {
expect(sourceStatus(src({ enabled: false, last_error: 'x' }), NOW).text).toBe('Disabled')
})
})
describe('relativeTime', () => {
it('uses the web UI formatRelative buckets', () => {
expect(relativeTime('2026-09-25T11:59:18Z', NOW)).toBe('42s ago')
expect(relativeTime('2026-09-25T09:00:00Z', NOW)).toBe('3h ago')
expect(relativeTime('2026-09-23T12:00:00Z', NOW)).toBe('2d ago')
expect(relativeTime('garbage', NOW)).toBe('Never')
})
})
describe('tokenExportMessage', () => {
it('distinguishes verified, rejected and untested tokens', () => {
expect(tokenExportMessage({ valid: true, reason: 'Token valid (me)' }).kind).toBe('success')
expect(tokenExportMessage({ valid: false, reason: 'Discord rejected the token' }).kind).toBe('error')
const untested = tokenExportMessage({ valid: null, reason: 'No enabled source' })
expect(untested.kind).toBe('warning')
expect(untested.text).toContain('No enabled source')
})
})
+171
View File
@@ -355,6 +355,177 @@ async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db,
assert artist_count == 1 # no duplicate created assert artist_count == 1 # no duplicate created
# --- Discord: channels are added to a CHOSEN artist (milestone 429) ---
_GUILD = "111111111111111111"
_CHAN = "222222222222222222"
_OTHER_CHAN = "333333333333333333"
async def _discord_source(db, name, url):
artist = Artist(name=name, slug=name.lower(), is_subscription=True)
db.add(artist)
await db.flush()
src = Source(artist_id=artist.id, platform="discord", url=url,
enabled=True, config_overrides={})
db.add(src)
await db.commit()
return artist, src
@pytest.mark.asyncio
async def test_a_discord_channel_is_added_to_the_artist_the_operator_picked(
client, ext_key, db, db_sync,
):
artist = Artist(name="Tamada", slug="tamada", is_subscription=True)
db.add(artist)
await db.commit()
resp = await client.post(
"/api/extension/quick-add-source",
# A jump link on the ptb host still names the channel.
json={"url": f"https://ptb.discord.com/channels/{_GUILD}/{_CHAN}/999",
"artist_id": artist.id},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 201, await resp.get_json()
body = await resp.get_json()
assert body["artist"]["id"] == artist.id
assert body["created_artist"] is False
# Stored canonical, so the manual form and the ingester read the same URL.
assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}"
assert body["source"]["platform"] == "discord"
@pytest.mark.asyncio
async def test_a_discord_add_can_name_a_new_artist(client, ext_key):
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}", "artist_name": "Studio Q"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 201
body = await resp.get_json()
assert body["artist"]["name"] == "Studio Q"
assert body["created_artist"] is True
assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}"
@pytest.mark.asyncio
async def test_a_discord_add_with_no_artist_and_no_token_names_the_server(client, ext_key):
"""No stored token means no server name to read; the fallback still names
a readable artist rather than slugifying the ids."""
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 201
assert (await resp.get_json())["artist"]["name"] == f"Discord {_GUILD}"
@pytest.mark.asyncio
async def test_re_adding_a_discord_channel_keeps_its_artist(client, ext_key, db):
"""Identity by source (#130), compared by ids: a row stored with a
trailing slash is the same channel, and naming another artist does not
move it."""
owner, src = await _discord_source(
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}/",
)
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}",
"artist_name": "Someone Else"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["source"]["id"] == src.id
assert body["artist"]["id"] == owner.id
@pytest.mark.asyncio
async def test_quick_add_with_a_missing_artist_id_is_404(client, ext_key):
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": 987654},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_quick_add_rejects_a_non_integer_artist_id(client, ext_key):
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": "7"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 400
async def _probe(client, ext_key, url):
resp = await client.get(
"/api/extension/probe", query_string={"url": url},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
return await resp.get_json()
@pytest.mark.asyncio
async def test_probe_a_fresh_discord_channel_is_new_with_both_urls(client, ext_key):
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
assert body["state"] == "new"
assert body["platform"] == "discord"
d = body["discord"]
assert d["server_id"] == _GUILD and d["channel_id"] == _CHAN
assert d["server_url"] == f"https://discord.com/channels/{_GUILD}"
assert d["channel_url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}"
# No token stored → no names, and no error.
assert d["server_name"] is None and d["channel_name"] is None
@pytest.mark.asyncio
async def test_probe_suggests_the_artist_who_owns_another_channel_on_the_server(
client, ext_key, db,
):
owner, _ = await _discord_source(
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_OTHER_CHAN}",
)
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
assert body["state"] == "artist_match"
assert body["artist"]["id"] == owner.id
assert "source" not in body
@pytest.mark.asyncio
async def test_probe_finds_the_channel_under_any_artist(client, ext_key, db):
owner, src = await _discord_source(
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}",
)
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}/555")
assert body["state"] == "source_match"
assert body["source"]["id"] == src.id
assert body["artist"]["id"] == owner.id
assert body["covered_by_server"] is False
@pytest.mark.asyncio
async def test_probe_a_channel_is_covered_by_a_whole_server_source(client, ext_key, db):
_, src = await _discord_source(db, "Owner", f"https://discord.com/channels/{_GUILD}")
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
assert body["state"] == "source_match"
assert body["source"]["id"] == src.id
assert body["covered_by_server"] is True
@pytest.mark.asyncio
async def test_probe_a_discord_dm_is_not_a_source(client, ext_key):
body = await _probe(client, ext_key, f"https://discord.com/channels/@me/{_CHAN}")
assert body["state"] == "unknown_platform"
# --- /api/extension/manifest --------------------------------------- # --- /api/extension/manifest ---------------------------------------
+2 -3
View File
@@ -104,9 +104,8 @@ def test_the_sample_table_covers_every_platform_that_has_a_pattern():
samples would make this file pass by testing less — the failure mode that samples would make this file pass by testing less — the failure mode that
makes absence-based tests untrustworthy (snippet #3352). makes absence-based tests untrustworthy (snippet #3352).
Discord is deliberately absent from both: it has no artist pattern on Discord is in the table since milestone 429: its "slug" is the
either side, because it is channel-based and has no creator page to put a server/channel pair, and the Add panel picks the artist.
button on.
""" """
from backend.app.services.extension_service import _PLATFORM_PATTERNS from backend.app.services.extension_service import _PLATFORM_PATTERNS