[backport cloud/1.43] feat: add civitai.red hostname support (#12090)

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 <huntcsg@users.noreply.github.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
This commit is contained in:
Christian Byrne
2026-05-08 15:51:33 -07:00
committed by GitHub
parent c05b81e92f
commit 07c1ae604f
9 changed files with 103 additions and 7 deletions

View File

@@ -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)
})
})
})

View File

@@ -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 (

View File

@@ -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')
})
})

View File

@@ -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']
}

View File

@@ -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',

View File

@@ -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'
}

View File

@@ -89,7 +89,7 @@ vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com']
hostnames: ['civitai.com', 'civitai.red']
}
}))

View File

@@ -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)
})
})

View File

@@ -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