From 07c1ae604fa23dc6b79bcef8c52c216cd5f86d8a Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 8 May 2026 15:51:33 -0700 Subject: [PATCH] [backport cloud/1.43] feat: add civitai.red hostname support (#12090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #11349 to cloud/1.43. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12090-backport-cloud-1-43-feat-add-civitai-red-hostname-support-35a6d73d36508141b2dfe6d6985f2959) by [Unito](https://www.unito.io) Co-authored-by: Hunter Co-authored-by: Glary-Bot Co-authored-by: Terry Jia --- .../src/formatUtil.test.ts | 9 +++++ .../shared-frontend-utils/src/formatUtil.ts | 10 ++++- .../composables/useUploadModelWizard.test.ts | 26 ++++++++++++- .../importSources/civitaiImportSource.ts | 2 +- .../assets/utils/assetMetadataUtils.test.ts | 5 +++ .../assets/utils/assetMetadataUtils.ts | 18 ++++++++- .../useMissingModelInteractions.test.ts | 2 +- .../missingModel/missingModelDownload.test.ts | 37 ++++++++++++++++++- .../missingModel/missingModelDownload.ts | 1 + 9 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 93300a9644..70dc8a6a10 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -7,6 +7,7 @@ import { getMediaTypeFromFilename, getPathDetails, highlightQuery, + isCivitaiModelUrl, isPreviewableMediaType, truncateFilename } from './formatUtil' @@ -357,4 +358,12 @@ describe('formatUtil', () => { expect(isPreviewableMediaType('other')).toBe(false) }) }) + + describe('isCivitaiModelUrl', () => { + it('recognizes civitai.red model URLs', () => { + expect( + isCivitaiModelUrl('https://civitai.red/api/download/models/123456') + ).toBe(true) + }) + }) }) diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 874cc60936..53eec9aff0 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -361,9 +361,17 @@ export const generateUUID = (): string => { */ export const isCivitaiModelUrl = (url: string): boolean => { if (!isValidUrl(url)) return false - if (!url.includes('civitai.com')) return false const urlObj = new URL(url) + const hostname = urlObj.hostname.toLowerCase() + const isCivitaiHost = + hostname === 'civitai.com' || + hostname.endsWith('.civitai.com') || + hostname === 'civitai.red' || + hostname.endsWith('.civitai.red') + if (!isCivitaiHost) { + return false + } const pathname = urlObj.pathname return ( diff --git a/src/platform/assets/composables/useUploadModelWizard.test.ts b/src/platform/assets/composables/useUploadModelWizard.test.ts index 3250b466b9..5f4e37965c 100644 --- a/src/platform/assets/composables/useUploadModelWizard.test.ts +++ b/src/platform/assets/composables/useUploadModelWizard.test.ts @@ -17,7 +17,7 @@ vi.mock('@/platform/assets/services/assetService', () => ({ vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({ civitaiImportSource: { name: 'Civitai', - hostnames: ['civitai.com'], + hostnames: ['civitai.com', 'civitai.red'], fetchMetadata: vi.fn() } })) @@ -154,4 +154,28 @@ describe('useUploadModelWizard', () => { expect(wizard.uploadStatus.value).toBe('error') expect(wizard.uploadError.value).toBe('Network error') }) + + it('accepts civitai.red model URLs', async () => { + const { assetService } = + await import('@/platform/assets/services/assetService') + + const asyncResponse: AsyncUploadResponse = { + type: 'async', + task: { + task_id: 'task-red', + status: 'created', + message: 'Download queued' + } + } + vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse) + + const wizard = useUploadModelWizard(modelTypes) + wizard.wizardData.value.url = 'https://civitai.red/models/12345' + wizard.selectedModelType.value = 'checkpoints' + + await wizard.uploadModel() + + expect(assetService.uploadAssetAsync).toHaveBeenCalled() + expect(wizard.uploadStatus.value).toBe('processing') + }) }) diff --git a/src/platform/assets/importSources/civitaiImportSource.ts b/src/platform/assets/importSources/civitaiImportSource.ts index 5ff324d009..8e46dcbeef 100644 --- a/src/platform/assets/importSources/civitaiImportSource.ts +++ b/src/platform/assets/importSources/civitaiImportSource.ts @@ -6,5 +6,5 @@ import type { ImportSource } from '@/platform/assets/types/importSource' export const civitaiImportSource: ImportSource = { type: 'civitai', name: 'Civitai', - hostnames: ['civitai.com'] + hostnames: ['civitai.com', 'civitai.red'] } diff --git a/src/platform/assets/utils/assetMetadataUtils.test.ts b/src/platform/assets/utils/assetMetadataUtils.test.ts index df3476db96..6146b1685b 100644 --- a/src/platform/assets/utils/assetMetadataUtils.test.ts +++ b/src/platform/assets/utils/assetMetadataUtils.test.ts @@ -187,6 +187,11 @@ describe('assetMetadataUtils', () => { url: 'https://civitai.com/models/123', expected: 'Civitai' }, + { + name: 'returns Civitai for civitai.red', + url: 'https://civitai.red/models/123', + expected: 'Civitai' + }, { name: 'returns Hugging Face for huggingface.co', url: 'https://huggingface.co/org/model', diff --git a/src/platform/assets/utils/assetMetadataUtils.ts b/src/platform/assets/utils/assetMetadataUtils.ts index d290cb29ad..8e54b83569 100644 --- a/src/platform/assets/utils/assetMetadataUtils.ts +++ b/src/platform/assets/utils/assetMetadataUtils.ts @@ -126,8 +126,22 @@ export function getAssetAdditionalTags(asset: AssetItem): string[] { * @returns Human-readable source name */ export function getSourceName(url: string): string { - if (url.includes('civitai.com')) return 'Civitai' - if (url.includes('huggingface.co')) return 'Hugging Face' + try { + const hostname = new URL(url).hostname.toLowerCase() + if ( + hostname === 'civitai.com' || + hostname.endsWith('.civitai.com') || + hostname === 'civitai.red' || + hostname.endsWith('.civitai.red') + ) { + return 'Civitai' + } + if (hostname === 'huggingface.co' || hostname.endsWith('.huggingface.co')) { + return 'Hugging Face' + } + } catch { + // fall through for invalid URLs + } return 'Source' } diff --git a/src/platform/missingModel/composables/useMissingModelInteractions.test.ts b/src/platform/missingModel/composables/useMissingModelInteractions.test.ts index b4afd36ef6..102153d084 100644 --- a/src/platform/missingModel/composables/useMissingModelInteractions.test.ts +++ b/src/platform/missingModel/composables/useMissingModelInteractions.test.ts @@ -89,7 +89,7 @@ vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({ civitaiImportSource: { type: 'civitai', name: 'Civitai', - hostnames: ['civitai.com'] + hostnames: ['civitai.com', 'civitai.red'] } })) diff --git a/src/platform/missingModel/missingModelDownload.test.ts b/src/platform/missingModel/missingModelDownload.test.ts index 96f007adf4..4b68ea8efa 100644 --- a/src/platform/missingModel/missingModelDownload.test.ts +++ b/src/platform/missingModel/missingModelDownload.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { fetchModelMetadata, toBrowsableUrl } from './missingModelDownload' +import { + fetchModelMetadata, + isModelDownloadable, + toBrowsableUrl +} from './missingModelDownload' const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) @@ -177,4 +181,35 @@ describe('toBrowsableUrl', () => { 'https://civitai.com/models/12345' ) }) + + it('converts civitai.red URLs to model pages', () => { + expect( + toBrowsableUrl('https://civitai.red/api/download/models/12345') + ).toBe('https://civitai.red/models/12345') + expect(toBrowsableUrl('https://civitai.red/api/v1/models/12345')).toBe( + 'https://civitai.red/models/12345' + ) + }) +}) + +describe('isModelDownloadable', () => { + it('allows civitai.red URLs', () => { + expect( + isModelDownloadable({ + name: 'model.safetensors', + url: 'https://civitai.red/api/download/models/12345', + directory: 'checkpoints' + }) + ).toBe(true) + }) + + it('rejects non-allowlisted URLs', () => { + expect( + isModelDownloadable({ + name: 'model.safetensors', + url: 'https://example.com/model.safetensors', + directory: 'checkpoints' + }) + ).toBe(false) + }) }) diff --git a/src/platform/missingModel/missingModelDownload.ts b/src/platform/missingModel/missingModelDownload.ts index 2563826ab0..efbf0241f7 100644 --- a/src/platform/missingModel/missingModelDownload.ts +++ b/src/platform/missingModel/missingModelDownload.ts @@ -4,6 +4,7 @@ import { useElectronDownloadStore } from '@/stores/electronDownloadStore' const ALLOWED_SOURCES = [ 'https://civitai.com/', + 'https://civitai.red/', 'https://huggingface.co/', 'http://localhost:' ] as const