From ddf896078c3a25cdcffe7652b2897fac4db834bc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 27 Aug 2026 07:24:08 -0400 Subject: [PATCH] refactor(platforms): retire deviantart end-to-end (#3069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- alembic/versions/0088_retire_deviantart.py | 70 ++++++++++++++++ backend/app/services/credential_service.py | 2 +- backend/app/services/download_backends.py | 5 +- backend/app/services/extension_service.py | 6 -- backend/app/services/gallery_dl.py | 14 +--- backend/app/services/platforms/__init__.py | 7 +- backend/app/services/platforms/base.py | 2 +- backend/app/services/platforms/deviantart.py | 23 ------ extension/README.md | 6 +- extension/lib/platforms.js | 11 --- extension/manifest.json | 4 +- extension/package.json | 2 +- extension/test/platforms.spec.js | 79 +++++++++++++++++-- .../settings/BrowserExtensionCard.vue | 2 +- frontend/src/utils/platformColor.js | 11 ++- tests/test_api_extension.py | 18 ++++- tests/test_api_platforms.py | 5 +- tests/test_artist_directory_service.py | 2 +- tests/test_download_backends.py | 2 +- tests/test_platform_lock.py | 2 +- tests/test_platforms_registry.py | 13 ++- tests/test_post_feed_service.py | 4 +- tests/test_source_service.py | 6 +- 23 files changed, 202 insertions(+), 94 deletions(-) create mode 100644 alembic/versions/0088_retire_deviantart.py delete mode 100644 backend/app/services/platforms/deviantart.py diff --git a/alembic/versions/0088_retire_deviantart.py b/alembic/versions/0088_retire_deviantart.py new file mode 100644 index 0000000..171e722 --- /dev/null +++ b/alembic/versions/0088_retire_deviantart.py @@ -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}, + ) diff --git a/backend/app/services/credential_service.py b/backend/app/services/credential_service.py index a8f6d8c..42c8a54 100644 --- a/backend/app/services/credential_service.py +++ b/backend/app/services/credential_service.py @@ -181,7 +181,7 @@ def _augment_cookies(platform: str, netscape: str) -> str: """Delegate to the platform's `augment_cookies` hook if one is registered (subscribestar, hentaifoundry, etc. — see `services/platforms/.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 platform's cookie quirks doesn't require touching this file.""" info = PLATFORMS.get(platform) diff --git a/backend/app/services/download_backends.py b/backend/app/services/download_backends.py index c6e1d2e..49c510a 100644 --- a/backend/app/services/download_backends.py +++ b/backend/app/services/download_backends.py @@ -31,9 +31,8 @@ from .pixiv_ingester import PixivIngester from .subscribestar_ingester import SubscribeStarIngester # Platforms whose download + verify go through the native ingester rather than -# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord, -# deviantart — the latter slated for retirement, not migration) until they -# migrate too. +# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until +# they migrate too. NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "pixiv"}) # Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index 25cb03a..7a629ab 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -55,12 +55,6 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P[^/?#]+)", re.IGNORECASE, )), - ("deviantart", re.compile( - r"^https?://(?:www\.)?deviantart\.com/" - r"(?!home$|watch\b|tag\b|browse\b)" - r"(?P[^/?#]+)/?$", - re.IGNORECASE, - )), ("pixiv", re.compile( r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P\d+)", re.IGNORECASE, diff --git a/backend/app/services/gallery_dl.py b/backend/app/services/gallery_dl.py index 71d13af..78abd9b 100644 --- a/backend/app/services/gallery_dl.py +++ b/backend/app/services/gallery_dl.py @@ -299,8 +299,9 @@ class GalleryDLService: # (services/patreon_ingester.py), not gallery-dl. PLATFORM_DEFAULTS = { # subscribestar removed — native-ingester platform now (#71); pixiv - # removed likewise (#129). The remaining entries are the gallery-dl - # platforms not yet migrated. + # removed likewise (#129); deviantart removed at #3069 as a dropped + # platform, not a migrated one. The remaining entries are the + # gallery-dl platforms not yet migrated. "hentaifoundry": { "content_types": ["all"], "directory": [], @@ -316,15 +317,6 @@ class GalleryDLService: "reactions": False, "threads": True, }, - "deviantart": { - "content_types": ["all"], - "directory": [], - "filename": "{index:>03}_{title[:50]}.{extension}", - "flat": True, - "original": True, - "mature": True, - "metadata": True, - }, } def __init__( diff --git a/backend/app/services/platforms/__init__.py b/backend/app/services/platforms/__init__.py index 822276a..be94f3d 100644 --- a/backend/app/services/platforms/__init__.py +++ b/backend/app/services/platforms/__init__.py @@ -8,9 +8,10 @@ PLATFORMS below. Sidecar parsing, cookie materialization, and Lifted from GallerySubscriber's ~/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 -hits FC unmodified. +hits FC unmodified. deviantart was dropped at #3069 (2026-08-27) — +FC downloaders are art-dedicated services only. """ from .base import ( @@ -18,7 +19,6 @@ from .base import ( DEFAULT_EXTERNAL_POST_ID_KEYS, PlatformInfo, ) -from .deviantart import INFO as _DEVIANTART from .discord import INFO as _DISCORD from .hentaifoundry import INFO as _HENTAIFOUNDRY from .patreon import INFO as _PATREON @@ -33,7 +33,6 @@ PLATFORMS: dict[str, PlatformInfo] = { _HENTAIFOUNDRY, _DISCORD, _PIXIV, - _DEVIANTART, ) } diff --git a/backend/app/services/platforms/base.py b/backend/app/services/platforms/base.py index 737b529..ce6ae48 100644 --- a/backend/app/services/platforms/base.py +++ b/backend/app/services/platforms/base.py @@ -63,7 +63,7 @@ class PlatformInfo: # Synthesize a post permalink from sidecar data. Required when # gallery-dl's `url` field is the file/CDN URL rather than the post # 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 # Post-process the materialized cookies.txt for gallery-dl. Used by diff --git a/backend/app/services/platforms/deviantart.py b/backend/app/services/platforms/deviantart.py deleted file mode 100644 index e41fc3b..0000000 --- a/backend/app/services/platforms/deviantart.py +++ /dev/null @@ -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"]}, -) diff --git a/extension/README.md b/extension/README.md index 5220623..e780d95 100644 --- a/extension/README.md +++ b/extension/README.md @@ -1,9 +1,9 @@ # FabledCurator Firefox Extension Self-hosted Firefox extension that pushes session cookies from supported -platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv, -DeviantArt) into FabledCurator, and lets you add a creator as a Source -from their page in one click. +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) diff --git a/extension/lib/platforms.js b/extension/lib/platforms.js index 2f37d93..c3f0c5a 100644 --- a/extension/lib/platforms.js +++ b/extension/lib/platforms.js @@ -68,16 +68,6 @@ const PLATFORMS = { urlPattern: /^https?:\/\/(www\.)?pixiv\.net/, 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, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/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, }; diff --git a/extension/manifest.json b/extension/manifest.json index e763e3f..b488988 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "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.", "browser_specific_settings": { @@ -33,7 +33,6 @@ "*://*.hentai-foundry.com/*", "*://*.discord.com/*", "*://*.pixiv.net/*", - "*://*.deviantart.com/*", "*://app-api.pixiv.net/*", "*://oauth.secure.pixiv.net/*", "*://*/*" @@ -61,7 +60,6 @@ "*://*.subscribestar.com/*", "*://*.subscribestar.adult/*", "*://*.hentai-foundry.com/*", - "*://*.deviantart.com/*", "*://*.pixiv.net/*" ], "js": ["lib/platforms.js", "content/content-script.js"], diff --git a/extension/package.json b/extension/package.json index 66fa304..638582e 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "fabledcurator-extension", - "version": "1.0.10", + "version": "1.0.11", "private": true, "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.", diff --git a/extension/test/platforms.spec.js b/extension/test/platforms.spec.js index deb9f97..df1e53a 100644 --- a/extension/test/platforms.spec.js +++ b/extension/test/platforms.spec.js @@ -1,6 +1,12 @@ 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' +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( 'platforms.js', ['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://discord.com/channels/@me')).toBe('discord') 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', () => { @@ -26,6 +31,15 @@ describe('getPlatformFromUrl', () => { expect(getPlatformFromUrl('https://not-patreon.com/Atole')).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', () => { @@ -72,12 +86,6 @@ describe('isArtistPage', () => { 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)', () => { 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', subscribestar: 'https://subscribestar.adult/someone', hentaifoundry: 'https://www.hentai-foundry.com/user/someone', - deviantart: 'https://www.deviantart.com/someone', pixiv: 'https://www.pixiv.net/en/users/12345' } 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) + } + }) +}) diff --git a/frontend/src/components/settings/BrowserExtensionCard.vue b/frontend/src/components/settings/BrowserExtensionCard.vue index 0424975..2dfbd38 100644 --- a/frontend/src/components/settings/BrowserExtensionCard.vue +++ b/frontend/src/components/settings/BrowserExtensionCard.vue @@ -9,7 +9,7 @@

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 their page in one click.

diff --git a/frontend/src/utils/platformColor.js b/frontend/src/utils/platformColor.js index 654619d..ef759b6 100644 --- a/frontend/src/utils/platformColor.js +++ b/frontend/src/utils/platformColor.js @@ -1,8 +1,10 @@ // 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 -// back to grey + mdi-web. Operator-confirmed scope 2026-05-27. The ICONS key -// set is pinned against backend known_platform_keys() by +// back to grey + mdi-web — which is deliberately what a retired platform +// 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. const ICONS = { @@ -11,7 +13,6 @@ const ICONS = { hentaifoundry: 'mdi-palette', discord: 'mdi-discord', pixiv: 'mdi-alpha-p-box', - deviantart: 'mdi-deviantart', } const COLORS = { @@ -20,7 +21,6 @@ const COLORS = { hentaifoundry: 'purple', discord: 'indigo', pixiv: 'blue', - deviantart: 'green', } const LABELS = { @@ -29,7 +29,6 @@ const LABELS = { hentaifoundry: 'HentaiFoundry', discord: 'Discord', pixiv: 'Pixiv', - deviantart: 'DeviantArt', } export function platformIcon(platform) { diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index d3010e0..b2885de 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -129,7 +129,6 @@ async def test_resolve_artist_name_dispatches_per_platform(db, monkeypatch): ("https://www.subscribestar.com/foobar", "subscribestar", "foobar"), ("https://subscribestar.adult/foobar", "subscribestar", "foobar"), ("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/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 +@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 async def test_quick_add_source_invalid_url_400(client, ext_key): resp = await client.post( diff --git a/tests/test_api_platforms.py b/tests/test_api_platforms.py index 1c76b90..f65817a 100644 --- a/tests/test_api_platforms.py +++ b/tests/test_api_platforms.py @@ -6,16 +6,17 @@ pytestmark = pytest.mark.integration @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") assert resp.status_code == 200 body = await resp.get_json() platforms = body["platforms"] assert set(platforms.keys()) == { "patreon", "subscribestar", "hentaifoundry", - "discord", "pixiv", "deviantart", + "discord", "pixiv", } assert "fanbox" not in platforms + assert "deviantart" not in platforms # retired at #3069 @pytest.mark.asyncio diff --git a/tests/test_artist_directory_service.py b/tests/test_artist_directory_service.py index 56f490e..e08e311 100644 --- a/tests/test_artist_directory_service.py +++ b/tests/test_artist_directory_service.py @@ -154,7 +154,7 @@ async def test_list_platform_filter_excludes_no_source(db): @pytest.mark.asyncio async def test_list_platform_filter_excludes_wrong_platform(db): 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() page = await ArtistDirectoryService(db).list_artists( diff --git a/tests/test_download_backends.py b/tests/test_download_backends.py index b53da45..ce7dda1 100644 --- a/tests/test_download_backends.py +++ b/tests/test_download_backends.py @@ -19,7 +19,7 @@ def test_native_platforms(): def test_gallery_dl_platforms_are_not_native(): # The platforms still served by gallery-dl must NOT route to the native # 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 diff --git a/tests/test_platform_lock.py b/tests/test_platform_lock.py index ce97ae1..b677e1e 100644 --- a/tests/test_platform_lock.py +++ b/tests/test_platform_lock.py @@ -9,8 +9,8 @@ pytestmark = pytest.mark.integration def test_non_serialized_platform_has_no_lock(): # 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("discord", ttl_seconds=60) is None def test_subscribestar_is_serialized(): diff --git a/tests/test_platforms_registry.py b/tests/test_platforms_registry.py index c100792..c94341c 100644 --- a/tests/test_platforms_registry.py +++ b/tests/test_platforms_registry.py @@ -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({ "patreon", "subscribestar", "hentaifoundry", - "discord", "pixiv", "deviantart", + "discord", "pixiv", }) @@ -23,6 +23,15 @@ def test_fanbox_not_in_registry(): 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(): assert auth_type_for("patreon") == "cookies" assert auth_type_for("discord") == "token" diff --git a/tests/test_post_feed_service.py b/tests/test_post_feed_service.py index 87d0de5..8b544ce 100644 --- a/tests/test_post_feed_service.py +++ b/tests/test_post_feed_service.py @@ -169,7 +169,7 @@ async def test_scroll_filters_by_artist(db): async def test_scroll_filters_by_platform(db): artist = await _seed_artist(db, "alice-platf") 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) 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) @@ -234,7 +234,7 @@ async def test_scroll_combined_artist_and_platform(db): alice = await _seed_artist(db, "alice-combo") bob = await _seed_artist(db, "bob-combo") 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") now = datetime.now(UTC) target = await _seed_post( diff --git a/tests/test_source_service.py b/tests/test_source_service.py index a9bdc28..17acecd 100644 --- a/tests/test_source_service.py +++ b/tests/test_source_service.py @@ -23,12 +23,14 @@ async def _artist(db, name="Alice"): @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({ "patreon", "subscribestar", "hentaifoundry", - "discord", "pixiv", "deviantart", + "discord", "pixiv", }) 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