refactor(platforms): retire deviantart end-to-end (#3069)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
extension / lint (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m43s

Executes the 2026-07-05 product decision (FC downloaders = art-dedicated
services only), which removed Twitter/X and Bluesky but left deviantart
fully wired for seven weeks — the half-retired state rule 22 exists to
prevent.

Removed: the PlatformInfo module and its registry entry, the gallery-dl
extractor block, extension_service's artist-page pattern, the extension's
PLATFORMS + PLATFORM_ARTIST_PATTERNS entries, its manifest host permission
and content-script match, the frontend icon/colour/label, and the operator-
facing "supported platforms" list that still advertised it.

Two judgment calls, both recorded in migration 0088:

  * existing `source` rows are DISABLED, not deleted. The row is the only
    record of the artist's DeviantArt URL. Disabling is also required for
    correctness rather than tidiness: with the platform unregistered the
    download path falls through to gallery-dl, which carries its OWN
    deviantart extractor, so an enabled row would have kept downloading
    from a dropped platform.
  * the `credential` row IS deleted — a live session cookie for a site FC
    will never call again.

Adds the invariant whose absence is why manifest.json drifted in the first
place: nothing tied its domain lists back to the platform table. The
extension suite now asserts both directions, plus that no host permission
belongs to an unclaimed domain (`*://*/*` exempted — FC is self-hosted at
an operator-chosen URL the extension cannot enumerate).

Extension version 1.0.10 -> 1.0.11: ci.yml's guard hard-fails a packaged
extension change without a bump. No release is cut — build.yml's
sign-extension job only runs on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 07:24:08 -04:00
co-authored by Claude Opus 5
parent 2e0f8f8c61
commit ddf896078c
23 changed files with 202 additions and 94 deletions
@@ -0,0 +1,70 @@
"""retire deviantart (#3069) — quiesce the rows the dropped platform leaves behind
`deviantart` is no longer a registered platform, so nothing can create or edit a
source with that key any more. Existing rows are a different question, and the
two tables want opposite treatment:
* `source` rows are DISABLED, not deleted. The row is the only place the
artist's DeviantArt URL is recorded, and losing it is unrecoverable — whereas
a disabled row is visible in the UI and reversible by hand. Disabling is also
required for correctness, not just tidiness: with the platform unregistered
the download path falls through to gallery-dl, which carries its OWN built-in
deviantart extractor, so an enabled row would have gone on downloading from a
platform the product dropped.
* the `credential` row IS deleted. It is an encrypted DeviantArt session cookie
for a site FC will never call again — keeping a live credential we have no
use for is strictly worse than dropping it, and re-exporting from the browser
is the recovery path if that judgment is ever wrong.
A no-op on an instance that never had a DeviantArt source, which is the
expected case.
Revision ID: 0088
Revises: 0087
Create Date: 2026-08-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0088"
down_revision: Union[str, None] = "0087"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_RETIRED = "deviantart"
# Written into last_error so the disabled row explains itself in the UI rather
# than looking like an unexplained toggle someone flipped.
_REASON = (
"Platform retired: FabledCurator no longer supports DeviantArt "
"(dropped 2026-08-27, #3069). This source was disabled automatically; "
"the URL is kept for reference and the row can be deleted by hand."
)
def upgrade() -> None:
conn = op.get_bind()
disabled = conn.execute(
sa.text(
"UPDATE source SET enabled = false, last_error = :reason "
"WHERE platform = :p AND enabled = true"
),
{"reason": _REASON, "p": _RETIRED},
).rowcount
creds = conn.execute(
sa.text("DELETE FROM credential WHERE platform = :p"), {"p": _RETIRED}
).rowcount
print(f"0088: disabled {disabled} deviantart source(s), removed {creds} credential(s)")
def downgrade() -> None:
# Re-enabling is deliberately NOT done: the platform is gone from the
# registry, so a re-enabled source would still have no backend to run on.
# Clearing the stamped reason is the only half that means anything.
op.get_bind().execute(
sa.text("UPDATE source SET last_error = NULL WHERE platform = :p AND last_error = :reason"),
{"p": _RETIRED, "reason": _REASON},
)
+1 -1
View File
@@ -181,7 +181,7 @@ def _augment_cookies(platform: str, netscape: str) -> str:
"""Delegate to the platform's `augment_cookies` hook if one is """Delegate to the platform's `augment_cookies` hook if one is
registered (subscribestar, hentaifoundry, etc. — see registered (subscribestar, hentaifoundry, etc. — see
`services/platforms/<name>.py`). No-op when the platform doesn't `services/platforms/<name>.py`). No-op when the platform doesn't
register a hook (Patreon, DeviantArt). Centralizing the register a hook (Patreon, Discord). Centralizing the
quirks-per-platform in the platforms package means adding a new quirks-per-platform in the platforms package means adding a new
platform's cookie quirks doesn't require touching this file.""" platform's cookie quirks doesn't require touching this file."""
info = PLATFORMS.get(platform) info = PLATFORMS.get(platform)
+2 -3
View File
@@ -31,9 +31,8 @@ from .pixiv_ingester import PixivIngester
from .subscribestar_ingester import SubscribeStarIngester from .subscribestar_ingester import SubscribeStarIngester
# Platforms whose download + verify go through the native ingester rather than # Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord, # gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until
# deviantart — the latter slated for retirement, not migration) until they # they migrate too.
# migrate too.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "pixiv"}) NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "pixiv"})
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure # Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
@@ -55,12 +55,6 @@ _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,
)), )),
("deviantart", re.compile(
r"^https?://(?:www\.)?deviantart\.com/"
r"(?!home$|watch\b|tag\b|browse\b)"
r"(?P<slug>[^/?#]+)/?$",
re.IGNORECASE,
)),
("pixiv", re.compile( ("pixiv", re.compile(
r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\d+)", r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\d+)",
re.IGNORECASE, re.IGNORECASE,
+3 -11
View File
@@ -299,8 +299,9 @@ class GalleryDLService:
# (services/patreon_ingester.py), not gallery-dl. # (services/patreon_ingester.py), not gallery-dl.
PLATFORM_DEFAULTS = { PLATFORM_DEFAULTS = {
# subscribestar removed — native-ingester platform now (#71); pixiv # subscribestar removed — native-ingester platform now (#71); pixiv
# removed likewise (#129). The remaining entries are the gallery-dl # removed likewise (#129); deviantart removed at #3069 as a dropped
# platforms not yet migrated. # platform, not a migrated one. The remaining entries are the
# gallery-dl platforms not yet migrated.
"hentaifoundry": { "hentaifoundry": {
"content_types": ["all"], "content_types": ["all"],
"directory": [], "directory": [],
@@ -316,15 +317,6 @@ class GalleryDLService:
"reactions": False, "reactions": False,
"threads": True, "threads": True,
}, },
"deviantart": {
"content_types": ["all"],
"directory": [],
"filename": "{index:>03}_{title[:50]}.{extension}",
"flat": True,
"original": True,
"mature": True,
"metadata": True,
},
} }
def __init__( def __init__(
+3 -4
View File
@@ -8,9 +8,10 @@ PLATFORMS below. Sidecar parsing, cookie materialization, and
Lifted from GallerySubscriber's Lifted from GallerySubscriber's
~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py ~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py
and ~/.../extension/lib/platforms.js. Six platforms; auth_type and and ~/.../extension/lib/platforms.js. Five platforms; auth_type and
URL patterns match GS exactly so the existing browser extension URL patterns match GS exactly so the existing browser extension
hits FC unmodified. hits FC unmodified. deviantart was dropped at #3069 (2026-08-27) —
FC downloaders are art-dedicated services only.
""" """
from .base import ( from .base import (
@@ -18,7 +19,6 @@ from .base import (
DEFAULT_EXTERNAL_POST_ID_KEYS, DEFAULT_EXTERNAL_POST_ID_KEYS,
PlatformInfo, PlatformInfo,
) )
from .deviantart import INFO as _DEVIANTART
from .discord import INFO as _DISCORD from .discord import INFO as _DISCORD
from .hentaifoundry import INFO as _HENTAIFOUNDRY from .hentaifoundry import INFO as _HENTAIFOUNDRY
from .patreon import INFO as _PATREON from .patreon import INFO as _PATREON
@@ -33,7 +33,6 @@ PLATFORMS: dict[str, PlatformInfo] = {
_HENTAIFOUNDRY, _HENTAIFOUNDRY,
_DISCORD, _DISCORD,
_PIXIV, _PIXIV,
_DEVIANTART,
) )
} }
+1 -1
View File
@@ -63,7 +63,7 @@ class PlatformInfo:
# Synthesize a post permalink from sidecar data. Required when # Synthesize a post permalink from sidecar data. Required when
# gallery-dl's `url` field is the file/CDN URL rather than the post # gallery-dl's `url` field is the file/CDN URL rather than the post
# permalink (subscribestar/pixiv/hf/discord). None = trust the bare # permalink (subscribestar/pixiv/hf/discord). None = trust the bare
# `url` field (patreon, deviantart). # `url` field (patreon).
derive_post_url: Callable[[dict], str | None] | None = None derive_post_url: Callable[[dict], str | None] | None = None
# Post-process the materialized cookies.txt for gallery-dl. Used by # Post-process the materialized cookies.txt for gallery-dl. Used by
@@ -1,23 +0,0 @@
"""DeviantArt — no exercised quirks yet.
No operator-owned DeviantArt archive existed at the 2026-05-27 sidecar
audit, so we don't know yet whether DA's gallery-dl sidecars are
well-behaved or have their own quirks. When DA gets exercised for the
first time, add `derive_post_url` / `augment_cookies` here as needed.
"""
from .base import GD_DEFAULTS, PlatformInfo
INFO = PlatformInfo(
key="deviantart",
name="DeviantArt",
description="Download artwork from DeviantArt artists",
auth_type="cookies",
requires_auth=False,
url_pattern=r"^https?://(www\.)?deviantart\.com/",
url_examples=[
"https://www.deviantart.com/example-artist",
"https://www.deviantart.com/example-artist/gallery",
],
default_config={**GD_DEFAULTS, "content_types": ["gallery"]},
)
+3 -3
View File
@@ -1,9 +1,9 @@
# FabledCurator Firefox Extension # FabledCurator Firefox Extension
Self-hosted Firefox extension that pushes session cookies from supported Self-hosted Firefox extension that pushes session cookies from supported
platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv, platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv)
DeviantArt) into FabledCurator, and lets you add a creator as a Source into FabledCurator, and lets you add a creator as a Source from their
from their page in one click. page in one click.
## Install (operator) ## Install (operator)
-11
View File
@@ -68,16 +68,6 @@ const PLATFORMS = {
urlPattern: /^https?:\/\/(www\.)?pixiv\.net/, urlPattern: /^https?:\/\/(www\.)?pixiv\.net/,
note: 'Click to authenticate via OAuth', note: 'Click to authenticate via OAuth',
}, },
deviantart: {
name: 'DeviantArt',
domains: ['.deviantart.com', 'www.deviantart.com', 'deviantart.com'],
authType: 'cookies',
color: '#05CC47',
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
// DA's logged-in-only endpoints sit behind their internal _napi
// namespace which shifts; skipping verify until a stable check
// surfaces. Same posture as SubscribeStar.
},
}; };
/** /**
@@ -98,7 +88,6 @@ 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,
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
pixiv: /^https?:\/\/(www\.)?pixiv\.net\/(en\/)?users\/\d+/i, pixiv: /^https?:\/\/(www\.)?pixiv\.net\/(en\/)?users\/\d+/i,
}; };
+1 -3
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.10", "version": "1.0.11",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
@@ -33,7 +33,6 @@
"*://*.hentai-foundry.com/*", "*://*.hentai-foundry.com/*",
"*://*.discord.com/*", "*://*.discord.com/*",
"*://*.pixiv.net/*", "*://*.pixiv.net/*",
"*://*.deviantart.com/*",
"*://app-api.pixiv.net/*", "*://app-api.pixiv.net/*",
"*://oauth.secure.pixiv.net/*", "*://oauth.secure.pixiv.net/*",
"*://*/*" "*://*/*"
@@ -61,7 +60,6 @@
"*://*.subscribestar.com/*", "*://*.subscribestar.com/*",
"*://*.subscribestar.adult/*", "*://*.subscribestar.adult/*",
"*://*.hentai-foundry.com/*", "*://*.hentai-foundry.com/*",
"*://*.deviantart.com/*",
"*://*.pixiv.net/*" "*://*.pixiv.net/*"
], ],
"js": ["lib/platforms.js", "content/content-script.js"], "js": ["lib/platforms.js", "content/content-script.js"],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.10", "version": "1.0.11",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"comment_ignore_files": "The --ignore-files list comes from scripts/packaging.sh, the single source of truth shared with ci.yml's guard and the derived-version patch count. `set -f` is REQUIRED before the substitution: without it the shell globs `test/**` against the working tree and silently narrows the pattern to whatever files happen to exist.", "comment_ignore_files": "The --ignore-files list comes from scripts/packaging.sh, the single source of truth shared with ci.yml's guard and the derived-version patch count. `set -f` is REQUIRED before the substitution: without it the shell globs `test/**` against the working tree and silently narrows the pattern to whatever files happen to exist.",
+71 -8
View File
@@ -1,6 +1,12 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { loadLib } from './helpers/loadLib.js' import { loadLib } from './helpers/loadLib.js'
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'))
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib( const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
'platforms.js', 'platforms.js',
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS'] ['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
@@ -13,7 +19,6 @@ describe('getPlatformFromUrl', () => {
expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry') expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry')
expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord') expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord')
expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv') expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv')
expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe('deviantart')
}) })
it('accepts http as well as https, with or without www', () => { it('accepts http as well as https, with or without www', () => {
@@ -26,6 +31,15 @@ describe('getPlatformFromUrl', () => {
expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null) expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null)
expect(getPlatformFromUrl('')).toBe(null) expect(getPlatformFromUrl('')).toBe(null)
}) })
it('returns null for deviantart, retired at #3069', () => {
// The 2026-07-05 product decision (FC downloaders = art-dedicated services
// only) left deviantart wired for seven weeks. Asserting the negative is
// what keeps a partial retirement from being re-completed by accident.
expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe(null)
expect(PLATFORMS.deviantart).toBeUndefined()
expect(PLATFORM_ARTIST_PATTERNS.deviantart).toBeUndefined()
})
}) })
describe('isArtistPage', () => { describe('isArtistPage', () => {
@@ -72,12 +86,6 @@ describe('isArtistPage', () => {
expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false) expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false)
}) })
it('excludes DeviantArt navigation roots', () => {
expect(isArtistPage('https://www.deviantart.com/someone', 'deviantart')).toBe(true)
expect(isArtistPage('https://www.deviantart.com/home', 'deviantart')).toBe(false)
expect(isArtistPage('https://www.deviantart.com/watch', 'deviantart')).toBe(false)
})
it('returns false for a platform with no artist pattern (discord)', () => { it('returns false for a platform with no artist pattern (discord)', () => {
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false) expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
}) })
@@ -116,7 +124,6 @@ describe('platform table integrity', () => {
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',
deviantart: 'https://www.deviantart.com/someone',
pixiv: 'https://www.pixiv.net/en/users/12345' pixiv: 'https://www.pixiv.net/en/users/12345'
} }
for (const [key, url] of Object.entries(samples)) { for (const [key, url] of Object.entries(samples)) {
@@ -125,3 +132,59 @@ describe('platform table integrity', () => {
} }
}) })
}) })
describe('manifest.json agrees with the platform table', () => {
// #3069: deviantart was dropped from the product in July but survived in
// manifest.json until late August, because NOTHING tied the manifest's
// domain lists back to PLATFORMS. These two specs are that tie. Both
// directions matter: a stale match ships host access the product decided
// not to use, and a missing one silently kills the Add-to-FC button.
const matches = manifest.content_scripts[0].matches
// '*://*.patreon.com/*' -> '.patreon.com', the form PLATFORMS.domains uses.
const hostOf = (m) => m.replace(/^\*:\/\/\*/, '').replace(/\/\*$/, '')
it('injects the content script only on domains a platform claims', () => {
for (const m of matches) {
const host = hostOf(m)
const owner = Object.entries(PLATFORMS).find(
([, p]) => p.domains.includes(host)
)
expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy()
// The content script exists to draw the Add-as-source button, so a
// platform with no artist pattern (discord) has no business here.
expect(
PLATFORM_ARTIST_PATTERNS[owner[0]],
`"${m}" injects for ${owner[0]}, which has no artist pattern`
).toBeTruthy()
}
})
it('injects on every platform that has an artist pattern', () => {
const covered = new Set(
matches
.map(hostOf)
.map((h) => Object.entries(PLATFORMS).find(([, p]) => p.domains.includes(h)))
.filter(Boolean)
.map(([key]) => key)
)
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
expect(covered, `${key} has an artist pattern but no content-script match`).toContain(key)
}
})
it('requests no host permission for a domain no platform claims', () => {
// '*://*/*' is the deliberate exception: FC is self-hosted at an arbitrary
// operator-chosen URL, so the extension cannot enumerate its own backend.
// Every OTHER entry is a platform domain and must still have an owner.
for (const h of manifest.host_permissions) {
if (h === '*://*/*') continue
const host = hostOf(h)
// pixiv's OAuth/API hosts are pixiv infrastructure, not creator pages,
// so they are matched by suffix rather than by the domains list.
const claimed = Object.values(PLATFORMS).some(
(p) => p.domains.includes(host) || p.domains.some((d) => host.endsWith(d))
)
expect(claimed, `host permission "${h}" belongs to no platform`).toBe(true)
}
})
})
@@ -9,7 +9,7 @@
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2"> <p class="fc-muted text-body-2">
Pushes session cookies from supported platforms Pushes session cookies from supported platforms
(patreon, subscribestar, hentaifoundry, discord, pixiv, deviantart) (patreon, subscribestar, hentaifoundry, discord, pixiv)
into FabledCurator, and lets you add a creator as a source from into FabledCurator, and lets you add a creator as a source from
their page in one click. their page in one click.
</p> </p>
+5 -6
View File
@@ -1,8 +1,10 @@
// Single source of truth for platform → color + icon mapping. Used by // Single source of truth for platform → color + icon mapping. Used by
// PlatformChip and any other GS-style platform-tagged surface. The six // PlatformChip and any other GS-style platform-tagged surface. The five
// platforms FC supports map 1:1 to the GS palette; unknown platforms fall // platforms FC supports map 1:1 to the GS palette; unknown platforms fall
// back to grey + mdi-web. Operator-confirmed scope 2026-05-27. The ICONS key // back to grey + mdi-web — which is deliberately what a retired platform
// set is pinned against backend known_platform_keys() by // hits: a pre-#3069 deviantart source row still renders, as its raw key on
// a grey chip. Operator-confirmed scope 2026-05-27. The ICONS key set is
// pinned against backend known_platform_keys() by
// tests/test_fe_be_contract.py. // tests/test_fe_be_contract.py.
const ICONS = { const ICONS = {
@@ -11,7 +13,6 @@ const ICONS = {
hentaifoundry: 'mdi-palette', hentaifoundry: 'mdi-palette',
discord: 'mdi-discord', discord: 'mdi-discord',
pixiv: 'mdi-alpha-p-box', pixiv: 'mdi-alpha-p-box',
deviantart: 'mdi-deviantart',
} }
const COLORS = { const COLORS = {
@@ -20,7 +21,6 @@ const COLORS = {
hentaifoundry: 'purple', hentaifoundry: 'purple',
discord: 'indigo', discord: 'indigo',
pixiv: 'blue', pixiv: 'blue',
deviantart: 'green',
} }
const LABELS = { const LABELS = {
@@ -29,7 +29,6 @@ const LABELS = {
hentaifoundry: 'HentaiFoundry', hentaifoundry: 'HentaiFoundry',
discord: 'Discord', discord: 'Discord',
pixiv: 'Pixiv', pixiv: 'Pixiv',
deviantart: 'DeviantArt',
} }
export function platformIcon(platform) { export function platformIcon(platform) {
+17 -1
View File
@@ -129,7 +129,6 @@ async def test_resolve_artist_name_dispatches_per_platform(db, monkeypatch):
("https://www.subscribestar.com/foobar", "subscribestar", "foobar"), ("https://www.subscribestar.com/foobar", "subscribestar", "foobar"),
("https://subscribestar.adult/foobar", "subscribestar", "foobar"), ("https://subscribestar.adult/foobar", "subscribestar", "foobar"),
("https://www.hentai-foundry.com/user/Foo/profile", "hentaifoundry", "Foo"), ("https://www.hentai-foundry.com/user/Foo/profile", "hentaifoundry", "Foo"),
("https://www.deviantart.com/baz", "deviantart", "baz"),
("https://www.pixiv.net/users/12345", "pixiv", "12345"), ("https://www.pixiv.net/users/12345", "pixiv", "12345"),
("https://www.pixiv.net/en/users/12345", "pixiv", "12345"), ("https://www.pixiv.net/en/users/12345", "pixiv", "12345"),
]) ])
@@ -160,6 +159,23 @@ async def test_quick_add_source_unknown_url_400(client, ext_key):
assert "known" in body assert "known" in body
@pytest.mark.asyncio
async def test_quick_add_source_rejects_retired_deviantart(client, ext_key):
"""#3069: a DeviantArt creator URL used to derive cleanly. Now that the
platform is retired, the extension's own gate should never offer the
button — but a stale content script on an un-updated browser still can,
so the backend has to refuse it rather than create an unusable source."""
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": "https://www.deviantart.com/baz"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "unknown_platform"
assert "deviantart" not in body["known"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_quick_add_source_invalid_url_400(client, ext_key): async def test_quick_add_source_invalid_url_400(client, ext_key):
resp = await client.post( resp = await client.post(
+3 -2
View File
@@ -6,16 +6,17 @@ pytestmark = pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_platforms_returns_gs_six(client): async def test_platforms_returns_gs_five(client):
resp = await client.get("/api/platforms") resp = await client.get("/api/platforms")
assert resp.status_code == 200 assert resp.status_code == 200
body = await resp.get_json() body = await resp.get_json()
platforms = body["platforms"] platforms = body["platforms"]
assert set(platforms.keys()) == { assert set(platforms.keys()) == {
"patreon", "subscribestar", "hentaifoundry", "patreon", "subscribestar", "hentaifoundry",
"discord", "pixiv", "deviantart", "discord", "pixiv",
} }
assert "fanbox" not in platforms assert "fanbox" not in platforms
assert "deviantart" not in platforms # retired at #3069
@pytest.mark.asyncio @pytest.mark.asyncio
+1 -1
View File
@@ -154,7 +154,7 @@ async def test_list_platform_filter_excludes_no_source(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_platform_filter_excludes_wrong_platform(db): async def test_list_platform_filter_excludes_wrong_platform(db):
a = await _seed_artist(db, "alice-wplat") a = await _seed_artist(db, "alice-wplat")
await _seed_source(db, a.id, "deviantart", "https://d/alice-wp") await _seed_source(db, a.id, "discord", "https://d/alice-wp")
await db.commit() await db.commit()
page = await ArtistDirectoryService(db).list_artists( page = await ArtistDirectoryService(db).list_artists(
+1 -1
View File
@@ -19,7 +19,7 @@ def test_native_platforms():
def test_gallery_dl_platforms_are_not_native(): def test_gallery_dl_platforms_are_not_native():
# The platforms still served by gallery-dl must NOT route to the native # The platforms still served by gallery-dl must NOT route to the native
# ingester — guards an accidental over-broad migration. # ingester — guards an accidental over-broad migration.
for platform in ("hentaifoundry", "discord", "deviantart"): for platform in ("hentaifoundry", "discord"):
assert uses_native_ingester(platform) is False assert uses_native_ingester(platform) is False
+1 -1
View File
@@ -9,8 +9,8 @@ pytestmark = pytest.mark.integration
def test_non_serialized_platform_has_no_lock(): def test_non_serialized_platform_has_no_lock():
# gallery-dl platforms aren't capped — they get no lock at all. # gallery-dl platforms aren't capped — they get no lock at all.
assert platform_lock("deviantart", ttl_seconds=60) is None
assert platform_lock("hentaifoundry", ttl_seconds=60) is None assert platform_lock("hentaifoundry", ttl_seconds=60) is None
assert platform_lock("discord", ttl_seconds=60) is None
def test_subscribestar_is_serialized(): def test_subscribestar_is_serialized():
+11 -2
View File
@@ -11,10 +11,10 @@ from backend.app.services.platforms import (
) )
def test_known_platform_keys_is_gs_six(): def test_known_platform_keys_is_gs_five():
assert known_platform_keys() == frozenset({ assert known_platform_keys() == frozenset({
"patreon", "subscribestar", "hentaifoundry", "patreon", "subscribestar", "hentaifoundry",
"discord", "pixiv", "deviantart", "discord", "pixiv",
}) })
@@ -23,6 +23,15 @@ def test_fanbox_not_in_registry():
assert "fanbox" not in PLATFORMS assert "fanbox" not in PLATFORMS
def test_deviantart_is_retired():
# #3069 executed the 2026-07-05 drop decision (FC downloaders = ART-
# DEDICATED services only). The registry is what /api/platforms, the
# source validator and the credential validator all read, so its absence
# here is what actually retires the platform everywhere else.
assert "deviantart" not in PLATFORMS
assert auth_type_for("deviantart") is None
def test_auth_type_for_known_and_unknown(): def test_auth_type_for_known_and_unknown():
assert auth_type_for("patreon") == "cookies" assert auth_type_for("patreon") == "cookies"
assert auth_type_for("discord") == "token" assert auth_type_for("discord") == "token"
+2 -2
View File
@@ -169,7 +169,7 @@ async def test_scroll_filters_by_artist(db):
async def test_scroll_filters_by_platform(db): async def test_scroll_filters_by_platform(db):
artist = await _seed_artist(db, "alice-platf") artist = await _seed_artist(db, "alice-platf")
src_p = await _seed_source(db, artist.id, "patreon", "https://p/alice-pp") src_p = await _seed_source(db, artist.id, "patreon", "https://p/alice-pp")
src_d = await _seed_source(db, artist.id, "deviantart", "https://d/alice-dd") src_d = await _seed_source(db, artist.id, "discord", "https://d/alice-dd")
now = datetime.now(UTC) now = datetime.now(UTC)
pp = await _seed_post(db, src_p.id, external_id="PP", post_date=now) pp = await _seed_post(db, src_p.id, external_id="PP", post_date=now)
await _seed_post(db, src_d.id, external_id="PD", post_date=now) await _seed_post(db, src_d.id, external_id="PD", post_date=now)
@@ -234,7 +234,7 @@ async def test_scroll_combined_artist_and_platform(db):
alice = await _seed_artist(db, "alice-combo") alice = await _seed_artist(db, "alice-combo")
bob = await _seed_artist(db, "bob-combo") bob = await _seed_artist(db, "bob-combo")
src_alice_patreon = await _seed_source(db, alice.id, "patreon", "https://p/alice-c") src_alice_patreon = await _seed_source(db, alice.id, "patreon", "https://p/alice-c")
src_alice_da = await _seed_source(db, alice.id, "deviantart", "https://d/alice-c") src_alice_da = await _seed_source(db, alice.id, "discord", "https://d/alice-c")
src_bob_patreon = await _seed_source(db, bob.id, "patreon", "https://p/bob-c") src_bob_patreon = await _seed_source(db, bob.id, "patreon", "https://p/bob-c")
now = datetime.now(UTC) now = datetime.now(UTC)
target = await _seed_post( target = await _seed_post(
+4 -2
View File
@@ -23,12 +23,14 @@ async def _artist(db, name="Alice"):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_known_platforms_is_gs_six(db): async def test_known_platforms_is_gs_five(db):
assert KNOWN_PLATFORMS == frozenset({ assert KNOWN_PLATFORMS == frozenset({
"patreon", "subscribestar", "hentaifoundry", "patreon", "subscribestar", "hentaifoundry",
"discord", "pixiv", "deviantart", "discord", "pixiv",
}) })
assert "fanbox" not in KNOWN_PLATFORMS assert "fanbox" not in KNOWN_PLATFORMS
# Retired at #3069 — a source can no longer be created on it.
assert "deviantart" not in KNOWN_PLATFORMS
@pytest.mark.asyncio @pytest.mark.asyncio