Milestone 406 retires pixiv (rule 171) in two phases at the operator's explicit ask: switch it off, then later delete its code. This is the switch-off. Steps 2 and 3 ship together because each is a half-state of the other: unregistered but still in the extension, pixiv creator pages would offer a button the backend then refuses. Reachability removed, never gated (rule 22 - no flag, no `if platform == "pixiv"`): - platforms registry: pixiv unregistered, so /api/platforms, the source validator and quick-add all refuse it through their existing unknown-platform paths. - NATIVE_INGESTER_PLATFORMS: pixiv removed. - extension_service: pixiv's quick-add URL pattern removed (the Python half of the JS mirror). - extension: pixiv's host permissions, content-script match, platform entry and artist pattern removed; popup's pixiv branches removed; and the whole pixiv PKCE OAuth flow cut out of background.js. That last one could not wait for phase 2 - a webRequest listener on a host the manifest no longer grants is at best dead and at worst a startup failure for the entire background script. On startup the extension now also removes any pixiv refresh token a browser still holds in storage, for the same reason as the server-side credential cleanup (3980). - frontend: the extension card stops listing pixiv; SourceActions' copy of the native list drops it. platformColor keeps rendering a pixiv key so existing pixiv posts do not look broken. The guard, and why a registry change alone was not enough. A source outlives its platform: the live instance still had one ENABLED pixiv source (step 1). Tracing it: the scheduler only selects enabled rows and every platform lookup uses .get(), so a disabled row is inert - but re-enabling it and pressing Check would have routed pixiv, no longer native, straight into the gallery-dl branch, which still has a pixiv extractor. And a worker can pick up a still-enabled row before a deploy's migration runs. So run_download and verify_source_credential - the two functions every download and credential probe pass through - now refuse any platform not in the registry: an unsupported_url failure for downloads, and an inconclusive (None, not False) verify, since nothing was probed so nothing was rejected. Generic by registration, so it covers deviantart's leftovers too. Positive-controlled: a supported gallery-dl platform must still reach gallery-dl, or a guard that refused everything would pass (rule 167). Migration 0097 disables sources on retired platforms (pixiv, deviantart) and clears their failure state exactly as disabling through the app does (1285), so the stale row stops being scheduled and stops showing as failing. Nothing is deleted: removing a source can collide with uq_post_artist_external_id_null_source on real data, which is phase 2's step 6 to check. No post or image is touched. Tests: the known-platform lists drop pixiv and gain retirement assertions beside deviantart's; pixiv's positive extension cases become negative guards; the pixiv sidecar post-URL test is deleted with the behaviour it tested; quick-add rejects a pixiv URL. The pixiv client/downloader/ingester suites stay - that code stays until phase 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
FabledCurator Firefox Extension
Self-hosted Firefox extension that pushes session cookies from supported platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv) into FabledCurator, and lets you add a creator as a Source from their page in one click.
Install (operator)
The signed XPI is bundled into the FC Docker image — :dev and
:latest each carry their own channel's build. Open FC →
Settings → Maintenance → Browser extension → click "Install Firefox
extension". Firefox shows its native install prompt. After installing,
open the extension's options page (about:addons → FabledCurator →
Preferences) and paste in the FC URL + extension API key shown on the
same card.
Develop
cd extension/
npm install --no-save # web-ext only
npm run lint # web-ext lint
npm run test:unit # vitest — lib/ logic + packaging/version checks
npm run start # launches Firefox with extension loaded
npm run build # unsigned XPI in web-ext-artifacts/
Smoke checklist (after every release that touches extension/**)
npm run lintpassesnpm run startloads the extension in a clean Firefox profile- Options page accepts FC URL + key, indicator turns green
- Cookie export: log into patreon.com, click Patreon card → "X cookies exported"
- Discord token: open discord.com, click Discord card → "Token captured"
- Pixiv OAuth: click Pixiv card → login redirects, token stored
- Add as source: visit patreon.com/, click floating button → toast
- Subscriptions list: popup → "Sources" tab → list renders
- Check now: click play icon on source row → no error toast
Versioning — the committed number decides nothing
The shipped version is derived, not committed. scripts/packaging.sh version returns YYYY.M.D.HHMM in UTC: the commit time of the newest change
to a packaged extension file. build.yml computes it and stamps it into both
manifest.json and package.json at build time. The stamp is never
committed — the commit carrying it would itself be a change to the extension,
which would move the version again.
So:
- Editing the version does nothing. All of it is overwritten before web-ext ever reads it. There is no bump to make, and none to forget. There is no hand-set part left either: MAJOR.MINOR went away with milestone 318 step 8.
npm run buildlocally produces an XPI labelled with the committed version, since nothing stamped it. Fine for loading into a test profile; not what ships.
Why the extension is the one artifact that does not zero-pad. Every other
FC artifact emits rule 148's YYYY.MM.DD.HHMM. AMO will not take it: Mozilla's
grammar for addons.mozilla.org is
^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
— each segment is the single digit 0 or starts 1-9, so 08 and 0201 are
rejected, and at most four segments are allowed. The extension therefore emits
the same numbers unpadded: 2026.8.29.201 where the rest of the family
says 2026.08.29.0201. Rule 148 already defines comparison as numeric per
segment, under which the two are equal, so nothing is reordered by the choice
and left-padding each segment recovers the family string exactly. ci.yml's
extension-version lane checks the derived string against that regex on every
push — the cheap place to find out, because AMO 409s on re-signing and a
rejected version is burned for good.
Why commit time and not a commit count: a count is per-branch, so dev and
main count different histories of the same code and their versions end up
ordered by which branch accumulated more commits rather than by which is newer.
Commit time gives both branches the same number for the same source — which is
exactly what lets one AMO signature serve both channels (family rule 149, FC
issue #3092).
Channels
dev and main each build and sign their own extension, and an install is
tied to whichever FC instance it points at — Firefox's static update_url
cannot apply here, since every FC install is a different host, so the extension
asks its configured backend. The channel therefore IS the instance.
Switching channel means repointing the FC URL in options and reinstalling from
that host; there is no separate channel setting, and adding one would
contradict each server build shipping its own extension.
The channel is reported beside the version, never inside it:
/api/extension/manifest answers {"version": "...", "channel": "dev"}. It is
optional — an instance that declares none simply omits the key, and the popup,
the toolbar tooltip and the Settings card all read exactly as they did before
the field existed. Do not be tempted to make it a -dev version suffix: the
comparator parses each dotted segment with parseInt, so a suffixed segment
reads as 0 and every dev build compares equal to every other, collapsing "no
update available" and "I cannot read this version" into one answer.
Release
Nothing to do by hand. Push to dev: build.yml signs the extension if this
change moved the version, caches the signed XPI as a Forgejo ext-<version>
release, and bundles it into fabledcurator:dev. Merging to main derives the
same version, hits that cache, and bundles the byte-identical XPI into
:latest with no second AMO call.
AMO refuses to re-sign a version it has already issued, so signing is one-shot per version — which is why the cache exists and why the version must never move backwards.