Compare commits

..

5 Commits

Author SHA1 Message Date
Will Miao
68bf8442eb chore(release): bump version to v1.0.4 and add release notes 2026-04-16 14:26:28 +08:00
Will Miao
605fbf4117 feat(civitai): add host preference for view links 2026-04-16 13:28:51 +08:00
Will Miao
406d5fea6a fix(civitai): use red-only api host (#897) 2026-04-16 12:08:07 +08:00
Will Miao
af2146f96c fix(civitai): fallback image info hosts on request failure 2026-04-16 09:29:03 +08:00
Will Miao
bdc8dec860 fix(civitai): support civitai.red URLs (#897) 2026-04-16 08:54:12 +08:00
47 changed files with 1232 additions and 136 deletions

View File

@@ -56,6 +56,13 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes ## Release Notes
### v1.0.4
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
* **Civitai API Host Migration** - Updated core Civitai API requests to use `civitai.red` for compatibility with Civitai's current API host.
* **Configurable Civitai View Host** - Added a setting to choose which Civitai site opens by default for model, search, and view links.
* **401 Unauthorized Reminder** - Some users have reported `401 Unauthorized` errors. If you run into this, try generating a new API key on `civitai.red` and updating it in LoRA Manager settings.
### v1.0.3 ### v1.0.3
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations. * **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein", "civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet", "civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiHost": {
"label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
"options": {
"com": "civitai.com (nur SFW)",
"red": "civitai.red (uneingeschränkt)"
}
},
"civitaiHostBanner": {
"title": "Civitai-Host-Einstellung verfügbar",
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
"openSettings": "Einstellungen öffnen"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Einstellungsordner öffnen", "label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen", "tooltip": "Den Ordner mit der settings.json öffnen",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key", "civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiHost": {
"label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.",
"options": {
"com": "civitai.com (SFW)",
"red": "civitai.red (unrestricted)"
}
},
"civitaiHostBanner": {
"title": "Civitai host preference available",
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
"openSettings": "Open Settings"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Open settings folder", "label": "Open settings folder",
"tooltip": "Open folder containing settings.json", "tooltip": "Open folder containing settings.json",
@@ -685,9 +698,9 @@
"title": "Import a recipe from image or URL", "title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path", "urlLocalPath": "URL / Local Path",
"uploadImage": "Upload Image", "uploadImage": "Upload Image",
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.", "urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
"imageUrlOrPath": "Image URL or File Path:", "imageUrlOrPath": "Image URL or File Path:",
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
"fetchImage": "Fetch Image", "fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.", "uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image", "selectImage": "Select Image",
@@ -1090,9 +1103,9 @@
}, },
"proceedText": "Only proceed if you're sure this is what you want.", "proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:", "urlLabel": "Civitai Model URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": { "helpText": {
"title": "Paste any Civitai model URL. Supported formats:", "title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Clave API de Civitai", "civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai", "civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai", "civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiHost": {
"label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
"options": {
"com": "civitai.com (solo SFW)",
"red": "civitai.red (sin restricciones)"
}
},
"civitaiHostBanner": {
"title": "Preferencia de host de Civitai disponible",
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
"openSettings": "Abrir ajustes"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Abrir carpeta de ajustes", "label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json", "tooltip": "Abrir la carpeta que contiene settings.json",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Clé API Civitai", "civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai", "civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai", "civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiHost": {
"label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
"options": {
"com": "civitai.com (SFW uniquement)",
"red": "civitai.red (sans restriction)"
}
},
"civitaiHostBanner": {
"title": "Préférence dhôte Civitai disponible",
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
"openSettings": "Ouvrir les paramètres"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Ouvrir le dossier des paramètres", "label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json", "tooltip": "Ouvrir le dossier contenant settings.json",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "מפתח API של Civitai", "civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai", "civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai", "civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiHost": {
"label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
"options": {
"com": "civitai.com (SFW בלבד)",
"red": "civitai.red (ללא הגבלות)"
}
},
"civitaiHostBanner": {
"title": "העדפת מארח Civitai זמינה",
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
"openSettings": "פתח הגדרות"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "פתח תיקיית הגדרות", "label": "פתח תיקיית הגדרות",
"tooltip": "פתח את התיקייה שמכילה את settings.json", "tooltip": "פתח את התיקייה שמכילה את settings.json",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai APIキー", "civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください", "civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます", "civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiHost": {
"label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
"options": {
"com": "civitai.comSFW のみ)",
"red": "civitai.red制限なし"
}
},
"civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
"openSettings": "設定を開く"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "設定フォルダーを開く", "label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます", "tooltip": "settings.json を含むフォルダーを開きます",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API 키", "civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요", "civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다", "civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiHost": {
"label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
"options": {
"com": "civitai.com(SFW 전용)",
"red": "civitai.red(무제한)"
}
},
"civitaiHostBanner": {
"title": "Civitai 호스트 기본 설정 사용 가능",
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
"openSettings": "설정 열기"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "설정 폴더 열기", "label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다", "tooltip": "settings.json이 있는 폴더를 엽니다",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Ключ API Civitai", "civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai", "civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai", "civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiHost": {
"label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
"options": {
"com": "civitai.com (только SFW)",
"red": "civitai.red (без ограничений)"
}
},
"civitaiHostBanner": {
"title": "Доступна настройка хоста Civitai",
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
"openSettings": "Открыть настройки"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Открыть папку настроек", "label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json", "tooltip": "Открыть папку, содержащую settings.json",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API 密钥", "civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥", "civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证", "civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiHost": {
"label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
"options": {
"com": "civitai.com仅 SFW",
"red": "civitai.red无限制"
}
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
"openSettings": "打开设置"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "打开设置文件夹", "label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹", "tooltip": "打开包含 settings.json 的文件夹",
@@ -685,9 +698,9 @@
"title": "从图片或 URL 导入配方", "title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径", "urlLocalPath": "URL / 本地路径",
"uploadImage": "上传图片", "uploadImage": "上传图片",
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。", "urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL或本地文件路径以导入为配方。",
"imageUrlOrPath": "图片 URL 或文件路径:", "imageUrlOrPath": "图片 URL 或文件路径:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "获取图片", "fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。", "uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片", "selectImage": "选择图片",
@@ -1090,9 +1103,9 @@
}, },
"proceedText": "仅在你确定需要此操作时继续。", "proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL", "urlLabel": "Civitai 模型 URL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": { "helpText": {
"title": "粘贴任意 Civitai 模型 URL。支持格式", "title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API 金鑰", "civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰", "civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證", "civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiHost": {
"label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
"options": {
"com": "civitai.com僅 SFW",
"red": "civitai.red無限制"
}
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
"openSettings": "開啟設定"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "開啟設定資料夾", "label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾", "tooltip": "開啟包含 settings.json 的資料夾",

View File

@@ -1,11 +1,11 @@
import logging import logging
import json import json
import re
import os import os
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from .merger import GenParamsMerger from .merger import GenParamsMerger
from .base import RecipeMetadataParser from .base import RecipeMetadataParser
from ..services.metadata_service import get_default_metadata_provider from ..services.metadata_service import get_default_metadata_provider
from ..utils.civitai_utils import extract_civitai_image_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,11 +39,12 @@ class RecipeEnricher:
source_url = recipe.get("source_url") or recipe.get("source_path", "") source_url = recipe.get("source_url") or recipe.get("source_path", "")
# Check if it's a Civitai image URL # Check if it's a Civitai image URL
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url)) image_id = extract_civitai_image_id(str(source_url))
if image_id_match: if image_id:
image_id = image_id_match.group(1)
try: try:
image_info = await civitai_client.get_image_info(image_id) image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_url)
)
if image_info: if image_info:
# Handle nested meta often found in Civitai API responses # Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta") raw_meta = image_info.get("meta")

View File

@@ -26,7 +26,7 @@ from ...services.recipes import (
RecipeValidationError, RecipeValidationError,
) )
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
@@ -1196,13 +1196,15 @@ class RecipeManagementHandler:
temp_path = temp_file.name temp_path = temp_file.name
download_url = image_url download_url = image_url
image_info = None image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url) civitai_image_id = extract_civitai_image_id(image_url)
if civitai_match: if civitai_image_id:
if civitai_client is None: if civitai_client is None:
raise RecipeDownloadError( raise RecipeDownloadError(
"Civitai client unavailable for image download" "Civitai client unavailable for image download"
) )
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=image_url
)
if not image_info: if not image_info:
raise RecipeDownloadError( raise RecipeDownloadError(
"Failed to fetch image information from Civitai" "Failed to fetch image information from Civitai"
@@ -1236,7 +1238,7 @@ class RecipeManagementHandler:
return ( return (
file_obj.read(), file_obj.read(),
extension, extension,
image_info.get("meta") if civitai_match and image_info else None, image_info.get("meta") if civitai_image_id and image_info else None,
) )
except RecipeDownloadError: except RecipeDownloadError:
raise raise

View File

@@ -20,6 +20,7 @@ from .model_query import (
resolve_sub_type, resolve_sub_type,
) )
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -774,9 +775,12 @@ class BaseModelService(ABC):
version_id = civitai_data.get("id") version_id = civitai_data.get("id")
if model_id: if model_id:
civitai_url = f"https://civitai.com/models/{model_id}" civitai_host = self.settings.get("civitai_host", "civitai.com")
if version_id: civitai_url = build_civitai_model_page_url(
civitai_url += f"?modelVersionId={version_id}" model_id,
version_id,
host=civitai_host,
)
return { return {
"civitai_url": civitai_url, "civitai_url": civitai_url,

View File

@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60 DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
# Civitai API endpoint for enums # Civitai API endpoint for enums
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums" CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
@classmethod @classmethod
async def get_instance(cls) -> CivitaiBaseModelService: async def get_instance(cls) -> CivitaiBaseModelService:

View File

@@ -39,7 +39,10 @@ class CivitaiClient:
return return
self._initialized = True self._initialized = True
self.base_url = "https://civitai.com/api/v1" self.base_url = "https://civitai.red/api/v1"
def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
async def _make_request( async def _make_request(
self, self,
@@ -190,7 +193,9 @@ class CivitaiClient:
"""Get all versions of a model with local availability info""" """Get all versions of a model with local availability info"""
try: try:
success, result = await self._make_request( success, result = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True "GET",
f"{self.base_url}/models/{model_id}",
use_auth=True,
) )
if success: if success:
# Also return model type along with versions # Also return model type along with versions
@@ -346,7 +351,9 @@ class CivitaiClient:
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]: async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request( success, data = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True "GET",
f"{self.base_url}/models/{model_id}",
use_auth=True,
) )
if success: if success:
return data return data
@@ -358,7 +365,9 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True "GET",
f"{self.base_url}/model-versions/{version_id}",
use_auth=True,
) )
if success: if success:
return version return version
@@ -371,7 +380,9 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True "GET",
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True,
) )
if success: if success:
return version return version
@@ -453,13 +464,11 @@ class CivitaiClient:
try: try:
url = f"{self.base_url}/model-versions/{version_id}" url = f"{self.base_url}/model-versions/{version_id}"
logger.debug(f"Resolving DNS for model version info: {url}") logger.debug("Resolving Civitai model version info: %s", url)
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request("GET", url, use_auth=True)
if success: if success:
logger.debug( logger.debug("Successfully fetched model version info for: %s", version_id)
f"Successfully fetched model version info for: {version_id}"
)
self._remove_comfy_metadata(result) self._remove_comfy_metadata(result)
return result, None return result, None
@@ -479,48 +488,58 @@ class CivitaiClient:
logger.error(error_msg) logger.error(error_msg)
return None, error_msg return None, error_msg
async def get_image_info(self, image_id: str) -> Optional[Dict]: async def get_image_info(
self, image_id: str, source_url: str | None = None
) -> Optional[Dict]:
"""Fetch image information from Civitai API """Fetch image information from Civitai API
Args: Args:
image_id: The Civitai image ID image_id: The Civitai image ID
source_url: Original image page URL. Accepted for caller compatibility;
API requests always target ``civitai.red``.
Returns: Returns:
Optional[Dict]: The image data or None if not found Optional[Dict]: The image data or None if not found
""" """
try: try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id) requested_id = int(image_id)
url = self._build_image_info_url(image_id)
logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request("GET", url, use_auth=True)
if success: if not success:
if result and "items" in result and isinstance(result["items"], list): logger.error(
items = result["items"] "Failed to fetch image info for ID %s from civitai.red: %s",
image_id,
# First, try to find the item with matching ID result,
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return item
# No matching ID found - log warning with details about returned items
returned_ids = [
item.get("id") for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
f"CivitAI API returned no matching image for requested ID {image_id}. "
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
f"This may indicate the image was deleted, hidden, or there is a database lag."
) )
return None return None
logger.warning(f"No image found with ID: {image_id}") if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(
"Successfully fetched image info for ID %s from civitai.red",
image_id,
)
return item
returned_ids = [
item.get("id")
for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
image_id,
len(items),
returned_ids,
)
return None return None
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}") logger.warning("No image found with ID: %s", image_id)
return None return None
except RateLimitError: except RateLimitError:
raise raise
@@ -539,8 +558,12 @@ class CivitaiClient:
return None return None
try: try:
url = f"{self.base_url}/models?username={username}" success, result = await self._make_request(
success, result = await self._make_request("GET", url, use_auth=True) "GET",
f"{self.base_url}/models",
use_auth=True,
params={"username": username},
)
if not success: if not success:
logger.error("Failed to fetch models for %s: %s", username, result) logger.error("Failed to fetch models for %s: %s", username, result)

View File

@@ -31,6 +31,11 @@ import tempfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/",
"https://civitai.red/api/download/",
)
class DownloadManager: class DownloadManager:
_instance = None _instance = None
@@ -647,12 +652,12 @@ class DownloadManager:
civitai_urls = [ civitai_urls = [
u u
for u in download_urls for u in download_urls
if u.startswith("https://civitai.com/api/download/") if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
] ]
non_civitai_urls = [ non_civitai_urls = [
u u
for u in download_urls for u in download_urls
if not u.startswith("https://civitai.com/api/download/") if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
] ]
download_urls = non_civitai_urls + civitai_urls download_urls = non_civitai_urls + civitai_urls
else: else:
@@ -1133,7 +1138,7 @@ class DownloadManager:
pause_control.update_stall_timeout(downloader.stall_timeout) pause_control.update_stall_timeout(downloader.stall_timeout)
last_error = None last_error = None
for download_url in download_urls: for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/") use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
download_kwargs = { download_kwargs = {
"progress_callback": lambda progress, snapshot=None: ( "progress_callback": lambda progress, snapshot=None: (
self._handle_download_progress( self._handle_download_progress(

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import base64 import base64
import io import io
import os import os
import re
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -14,7 +13,7 @@ import numpy as np
from PIL import Image from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from .errors import ( from .errors import (
RecipeDownloadError, RecipeDownloadError,
RecipeNotFoundError, RecipeNotFoundError,
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
extension = ".jpg" # Default extension = ".jpg" # Default
try: try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url) civitai_image_id = extract_civitai_image_id(url)
if civitai_match: if civitai_image_id:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=url
)
if not image_info: if not image_info:
raise RecipeDownloadError( raise RecipeDownloadError(
"Failed to fetch image information from Civitai" "Failed to fetch image information from Civitai"

View File

@@ -54,6 +54,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = { DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "", "civitai_api_key": "",
"civitai_host": "civitai.com",
"use_portable_settings": False, "use_portable_settings": False,
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB, "hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
"language": "en", "language": "en",

View File

@@ -2,10 +2,13 @@
from __future__ import annotations from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Mapping, Sequence from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import urlparse, urlunparse from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",) _DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = { _LICENSE_DEFAULTS: Dict[str, Any] = {
"allowNoCredit": True, "allowNoCredit": True,
@@ -17,6 +20,115 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
_COMMERCIAL_SHIFT = 1 _COMMERCIAL_SHIFT = 1
def is_supported_civitai_page_host(hostname: str | None) -> bool:
"""Return whether the hostname is a supported Civitai page domain."""
if not hostname:
return False
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
def normalize_civitai_page_host(hostname: str | None) -> str:
"""Return a supported Civitai page host or the default host."""
if not isinstance(hostname, str):
return DEFAULT_CIVITAI_PAGE_HOST
normalized = hostname.strip().lower()
if is_supported_civitai_page_host(normalized):
return normalized
return DEFAULT_CIVITAI_PAGE_HOST
def build_civitai_model_page_url(
model_id: str | int | None,
version_id: str | int | None = None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai model or model-version page URL."""
normalized_host = normalize_civitai_page_host(host)
normalized_model_id = str(model_id).strip() if model_id is not None else ""
normalized_version_id = str(version_id).strip() if version_id is not None else ""
if normalized_model_id:
path = f"/models/{normalized_model_id}"
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
return urlunparse(("https", normalized_host, path, "", query, ""))
if normalized_version_id:
return urlunparse(
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
)
return None
def _parse_supported_civitai_page_url(url: str | None):
if not url:
return None
try:
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme not in {"http", "https"}:
return None
if not is_supported_civitai_page_host(parsed.hostname):
return None
return parsed
def extract_civitai_model_url_parts(
url: str | None,
) -> tuple[str | None, str | None]:
"""Extract model and version identifiers from a supported Civitai model URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None, None
path_match = re.search(r"/models/(\d+)", parsed.path)
if not path_match:
return None, None
model_id = path_match.group(1)
query_params = parse_qs(parsed.query)
version_values = query_params.get("modelVersionId") or []
version_id = version_values[0] if version_values else None
return model_id, version_id
def extract_civitai_image_id(url: str | None) -> str | None:
"""Extract the image identifier from a supported Civitai image page URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
path_match = re.search(r"/images/(\d+)", parsed.path)
if not path_match:
return None
return path_match.group(1)
def extract_civitai_page_host(url: str | None) -> str | None:
"""Extract the supported Civitai page host from a URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
return parsed.hostname.lower() if parsed.hostname else None
def _normalize_commercial_values(value: Any) -> Sequence[str]: def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""Return a normalized list of commercial permissions preserving source values.""" """Return a normalized list of commercial permissions preserving source values."""
@@ -199,6 +311,10 @@ def rewrite_preview_url(
__all__ = [ __all__ = [
"build_license_flags", "build_license_flags",
"extract_civitai_image_id",
"extract_civitai_page_host",
"extract_civitai_model_url_parts",
"is_supported_civitai_page_host",
"resolve_license_payload", "resolve_license_payload",
"resolve_license_info", "resolve_license_info",
"rewrite_preview_url", "rewrite_preview_url",

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.3" version = "1.0.4"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -6,6 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js'; import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = { export const ModelContextMenuMixin = {
@@ -154,25 +155,7 @@ export const ModelContextMenuMixin = {
}, },
extractModelVersionId(url) { extractModelVersionId(url) {
try { return extractCivitaiModelUrlParts(url);
// Handle all three URL formats:
// 1. https://civitai.com/models/649516
// 2. https://civitai.com/models/649516?modelVersionId=726676
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
const parsedUrl = new URL(url);
// Extract model ID from path
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
// Extract model version ID from query parameters
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
}, },
parseModelId(value) { parseModelId(value) {

View File

@@ -1,7 +1,7 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages // PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js'; import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js'; import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { showToast } from '../../utils/uiHelpers.js'; import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
@@ -354,17 +354,7 @@ export class PageControls {
const civitaiId = metaData.modelId; const civitaiId = metaData.modelId;
const versionId = metaData.id; const versionId = metaData.id;
// Build URL openCivitaiByMetadata(civitaiId, versionId, modelName);
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
url += `?modelVersionId=${versionId}`;
}
window.open(url, '_blank');
} else {
// If no ID, try searching by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
} }
/** /**

View File

@@ -1,26 +1,21 @@
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { downloadManager } from '../../managers/DownloadManager.js'; import { downloadManager } from '../../managers/DownloadManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { showToast } from '../../utils/uiHelpers.js'; import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js'; import { formatFileSize } from './utils.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
function buildCivitaiVersionUrl(modelId, versionId) { function buildCivitaiVersionUrl(modelId, versionId) {
if (modelId == null || versionId == null) { return buildCivitaiModelUrl(
return null; modelId,
} versionId,
const normalizedModelId = String(modelId).trim(); state?.global?.settings?.civitai_host
const normalizedVersionId = String(versionId).trim(); );
if (!normalizedModelId || !normalizedVersionId) {
return null;
}
const encodedModelId = encodeURIComponent(normalizedModelId);
const encodedVersionId = encodeURIComponent(normalizedVersionId);
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
} }
function escapeHtml(value) { function escapeHtml(value) {
@@ -1352,6 +1347,13 @@ export function initVersionsTab({
} }
const row = event.target.closest('.model-version-row.is-clickable'); const row = event.target.closest('.model-version-row.is-clickable');
const civitaiLink = event.target.closest('.version-civitai-link');
if (civitaiLink) {
event.preventDefault();
openCivitaiUrl(civitaiLink.href);
return;
}
if (!row) { if (!row) {
return; return;
} }
@@ -1371,7 +1373,7 @@ export function initVersionsTab({
return; return;
} }
event.preventDefault(); event.preventDefault();
window.open(targetUrl, '_blank', 'noopener,noreferrer'); openCivitaiUrl(targetUrl);
}); });
// Listen for extension-triggered refresh requests // Listen for extension-triggered refresh requests

View File

@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
export class DownloadManager { export class DownloadManager {
constructor() { constructor() {
@@ -197,21 +198,22 @@ export class DownloadManager {
} }
extractModelId(url) { extractModelId(url) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i); const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) { if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive'; this.source = 'civarchive';
return civarchiveMatch[1]; return civarchiveMatch[1];
} }
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i); const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (civitaiMatch) { if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null; this.source = null;
return civitaiMatch[1]; return modelId;
} }
this.modelVersionId = null;
this.source = null; this.source = null;
return null; return null;
} }

View File

@@ -802,6 +802,11 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
} }
const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) {
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
}
const recipesPathInput = document.getElementById('recipesPath'); const recipesPathInput = document.getElementById('recipesPath');
if (recipesPathInput) { if (recipesPathInput) {
recipesPathInput.value = state.global.settings.recipes_path || ''; recipesPathInput.value = state.global.settings.recipes_path || '';

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
civitai_host: 'civitai.com',
use_portable_settings: false, use_portable_settings: false,
language: 'en', language: 'en',
show_only_sfw: false, show_only_sfw: false,

View File

@@ -13,6 +13,64 @@ export const OptimizationMode = {
THUMBNAIL: 'thumbnail', THUMBNAIL: 'thumbnail',
}; };
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
'civitai.com',
'civitai.red',
]);
export function normalizeCivitaiPageHost(hostname) {
if (!hostname || typeof hostname !== 'string') {
return DEFAULT_CIVITAI_PAGE_HOST;
}
const normalized = hostname.trim().toLowerCase();
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(normalized)) {
return normalized;
}
return DEFAULT_CIVITAI_PAGE_HOST;
}
export function buildCivitaiModelUrl(modelId, versionId = null, host = DEFAULT_CIVITAI_PAGE_HOST) {
const normalizedHost = normalizeCivitaiPageHost(host);
const normalizedModelId = modelId == null ? '' : String(modelId).trim();
const normalizedVersionId = versionId == null ? '' : String(versionId).trim();
if (normalizedModelId) {
const encodedModelId = encodeURIComponent(normalizedModelId);
let url = `https://${normalizedHost}/models/${encodedModelId}`;
if (normalizedVersionId) {
url += `?modelVersionId=${encodeURIComponent(normalizedVersionId)}`;
}
return url;
}
if (normalizedVersionId) {
return `https://${normalizedHost}/model-versions/${encodeURIComponent(normalizedVersionId)}`;
}
return null;
}
export function buildCivitaiSearchUrl(query, host = DEFAULT_CIVITAI_PAGE_HOST) {
const normalizedQuery = query == null ? '' : String(query).trim();
if (!normalizedQuery) {
return null;
}
const normalizedHost = normalizeCivitaiPageHost(host);
return `https://${normalizedHost}/models?query=${encodeURIComponent(normalizedQuery)}`;
}
export function buildCivitaiUrl({ modelId = null, versionId = null, modelName = null, host = DEFAULT_CIVITAI_PAGE_HOST } = {}) {
return (
buildCivitaiModelUrl(modelId, versionId, host)
|| buildCivitaiSearchUrl(modelName, host)
);
}
/** /**
* Rewrite Civitai preview URLs to use optimized renditions. * Rewrite Civitai preview URLs to use optimized renditions.
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py * Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
return false; return false;
} }
} }
export function isSupportedCivitaiPageHost(hostname) {
if (!hostname) {
return false;
}
return SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname.toLowerCase());
}
export function extractCivitaiModelUrlParts(url) {
if (!url) {
return { modelId: null, modelVersionId: null };
}
try {
const parsedUrl = new URL(url);
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return { modelId: null, modelVersionId: null };
}
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
}
export function extractCivitaiImageId(url) {
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return null;
}
const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/);
return pathMatch ? pathMatch[1] : null;
} catch (e) {
return null;
}
}

View File

@@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js'; import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.js'; import { eventManager } from './EventManager.js';
import { bannerService } from '../managers/BannerService.js';
import { modalManager } from '../managers/ModalManager.js';
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
function getPreferredCivitaiHost() {
return normalizeCivitaiPageHost(state?.global?.settings?.civitai_host);
}
function maybeRegisterCivitaiHostInfoBanner() {
if (getStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, false)) {
return;
}
setStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, true);
bannerService.registerBanner(CIVITAI_HOST_INFO_BANNER_ID, {
id: CIVITAI_HOST_INFO_BANNER_ID,
title: translate(
'settings.civitaiHostBanner.title',
{},
'Civitai host preference available'
),
content: translate(
'settings.civitaiHostBanner.content',
{},
'Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.'
),
actions: [
{
text: translate('settings.civitaiHostBanner.openSettings', {}, 'Open Settings'),
icon: 'fas fa-cog',
action: 'open-settings-modal',
type: 'primary',
},
],
dismissible: true,
priority: 70,
onRegister: (bannerElement) => {
const button = bannerElement.querySelector('.banner-action[data-action="open-settings-modal"]');
if (button) {
button.addEventListener('click', (event) => {
event.preventDefault();
modalManager.showModal('settingsModal');
});
}
},
});
}
export function openCivitaiUrl(url) {
if (!url) {
return null;
}
maybeRegisterCivitaiHostInfoBanner();
return window.open(url, '_blank', 'noopener,noreferrer');
}
/** /**
* Utility function to copy text to clipboard with fallback for older browsers * Utility function to copy text to clipboard with fallback for older browsers
@@ -184,14 +244,15 @@ function filterByFolder(folderPath) {
} }
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) { export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
if (versionId) { const url = buildCivitaiUrl({
// Use model-versions endpoint which auto-redirects to correct model page modelId: civitaiId,
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank'); versionId,
} else if (civitaiId) { modelName,
window.open(`https://civitai.com/models/${civitaiId}`, '_blank'); host: getPreferredCivitaiHost(),
} else if (modelName) { });
// Fallback: search by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank'); if (url) {
openCivitaiUrl(url);
} }
} }

View File

@@ -114,6 +114,21 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="civitaiHost">{{ t('settings.civitaiHost.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiHost.help') }}"></i>
</div>
<div class="setting-control select-control">
<select id="civitaiHost" onchange="settingsManager.saveSelectSetting('civitaiHost', 'civitai_host')">
<option value="civitai.com">{{ t('settings.civitaiHost.options.com') }}</option>
<option value="civitai.red">{{ t('settings.civitaiHost.options.red') }}</option>
</select>
</div>
</div>
</div>
<!-- Backup --> <!-- Backup -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
describe('ModelContextMenuMixin.extractModelVersionId', () => {
it('accepts civitai.red model URLs', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
)
).toEqual({ modelId: '65423', modelVersionId: '777' });
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://example.com/models/65423?modelVersionId=777'
)
).toEqual({ modelId: null, modelVersionId: null });
});
});

View File

@@ -26,6 +26,7 @@ vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
vi.mock(UI_HELPERS_MODULE, () => ({ vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(), showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
})); }));
const stateMock = { const stateMock = {

View File

@@ -12,6 +12,7 @@ const apiClientMock = {
}; };
const showToastMock = vi.fn(); const showToastMock = vi.fn();
const openCivitaiByMetadataMock = vi.fn();
const updatePanelPositionsMock = vi.fn(); const updatePanelPositionsMock = vi.fn();
const downloadManagerMock = { const downloadManagerMock = {
showDownloadModal: vi.fn(), showDownloadModal: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock, showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock, updatePanelPositions: updatePanelPositionsMock,
})); }));

View File

@@ -136,4 +136,14 @@ describe('DownloadManager version history badges', () => {
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors'); expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
expect(items[1].querySelector('.downloaded-badge')).toBeNull(); expect(items[1].querySelector('.downloaded-badge')).toBeNull();
}); });
it('extracts model and version ids from civitai.red URLs', () => {
const manager = new DownloadManager();
expect(
manager.extractModelId('https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777')
).toBe('65423');
expect(manager.modelVersionId).toBe('777');
expect(manager.source).toBeNull();
});
}); });

View File

@@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn(); const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn(); const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn(); const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn(); const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn(); const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn(); const refreshRecipesMock = vi.fn();
@@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock, getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock, removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock, getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
})); }));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({

View File

@@ -14,6 +14,7 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({ expect(defaultSettings).toMatchObject({
civitai_api_key: '', civitai_api_key: '',
civitai_host: 'civitai.com',
language: 'en', language: 'en',
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R' mature_blur_level: 'R'

View File

@@ -1,10 +1,18 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
DEFAULT_CIVITAI_PAGE_HOST,
normalizeCivitaiPageHost,
buildCivitaiModelUrl,
buildCivitaiSearchUrl,
buildCivitaiUrl,
rewriteCivitaiUrl, rewriteCivitaiUrl,
getOptimizedUrl, getOptimizedUrl,
getShowcaseUrl, getShowcaseUrl,
getThumbnailUrl, getThumbnailUrl,
extractCivitaiImageId,
extractCivitaiModelUrlParts,
isCivitaiUrl, isCivitaiUrl,
isSupportedCivitaiPageHost,
OptimizationMode OptimizationMode
} from '../../../static/js/utils/civitaiUtils.js'; } from '../../../static/js/utils/civitaiUtils.js';
@@ -16,6 +24,47 @@ describe('civitaiUtils', () => {
}); });
}); });
describe('Civitai page URL helpers', () => {
it('normalizes invalid hosts to the default page host', () => {
expect(DEFAULT_CIVITAI_PAGE_HOST).toBe('civitai.com');
expect(normalizeCivitaiPageHost('civitai.red')).toBe('civitai.red');
expect(normalizeCivitaiPageHost(' CIVITAI.COM ')).toBe('civitai.com');
expect(normalizeCivitaiPageHost('example.com')).toBe('civitai.com');
expect(normalizeCivitaiPageHost(null)).toBe('civitai.com');
});
it('builds model URLs using the configured host', () => {
expect(buildCivitaiModelUrl(123, 456, 'civitai.red')).toBe(
'https://civitai.red/models/123?modelVersionId=456'
);
expect(buildCivitaiModelUrl(123, null, 'civitai.com')).toBe(
'https://civitai.com/models/123'
);
});
it('falls back to the model-versions endpoint when only a version id is available', () => {
expect(buildCivitaiModelUrl(null, 456, 'civitai.red')).toBe(
'https://civitai.red/model-versions/456'
);
});
it('builds search URLs using the configured host', () => {
expect(buildCivitaiSearchUrl('demo model', 'civitai.red')).toBe(
'https://civitai.red/models?query=demo%20model'
);
expect(buildCivitaiSearchUrl('', 'civitai.red')).toBe(null);
});
it('prefers model/version URLs and falls back to search URLs', () => {
expect(buildCivitaiUrl({ modelId: 321, versionId: 654, host: 'civitai.red' })).toBe(
'https://civitai.red/models/321?modelVersionId=654'
);
expect(buildCivitaiUrl({ modelName: 'search me', host: 'civitai.red' })).toBe(
'https://civitai.red/models?query=search%20me'
);
});
});
describe('rewriteCivitaiUrl', () => { describe('rewriteCivitaiUrl', () => {
it('should rewrite image URLs with /original=true for thumbnail mode', () => { it('should rewrite image URLs with /original=true for thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg'; const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
@@ -217,4 +266,43 @@ describe('civitaiUtils', () => {
expect(isCivitaiUrl('not-a-url')).toBe(false); expect(isCivitaiUrl('not-a-url')).toBe(false);
}); });
}); });
describe('isSupportedCivitaiPageHost', () => {
it('accepts civitai.com and civitai.red page hosts', () => {
expect(isSupportedCivitaiPageHost('civitai.com')).toBe(true);
expect(isSupportedCivitaiPageHost('civitai.red')).toBe(true);
});
it('rejects unrelated hosts', () => {
expect(isSupportedCivitaiPageHost('www.civitai.com')).toBe(false);
expect(isSupportedCivitaiPageHost('www.civitai.red')).toBe(false);
expect(isSupportedCivitaiPageHost('example.com')).toBe(false);
expect(isSupportedCivitaiPageHost('')).toBe(false);
expect(isSupportedCivitaiPageHost(null)).toBe(false);
});
});
describe('extractCivitaiModelUrlParts', () => {
it('extracts model and version ids from civitai.red model URLs', () => {
expect(
extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765')
).toEqual({ modelId: '65423', modelVersionId: '98765' });
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
extractCivitaiModelUrlParts('https://example.com/models/65423?modelVersionId=98765')
).toEqual({ modelId: null, modelVersionId: null });
});
});
describe('extractCivitaiImageId', () => {
it('extracts image ids from civitai.red image URLs', () => {
expect(extractCivitaiImageId('https://civitai.red/images/126920345')).toBe('126920345');
});
it('rejects image-like URLs from unsupported hosts', () => {
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
});
});
}); });

View File

@@ -6,6 +6,8 @@ const {
STORAGE_MODULE, STORAGE_MODULE,
CONSTANTS_MODULE, CONSTANTS_MODULE,
EVENT_MANAGER_MODULE, EVENT_MANAGER_MODULE,
BANNER_SERVICE_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE, UI_HELPERS_MODULE,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
@@ -13,12 +15,16 @@ const {
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname, CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname, EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
BANNER_SERVICE_MODULE: new URL('../../../static/js/managers/BannerService.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
})); }));
const translateMock = vi.fn((key, _params, fallback) => fallback || key); const translateMock = vi.fn((key, _params, fallback) => fallback || key);
const getStorageItemMock = vi.fn(); const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn(); const setStorageItemMock = vi.fn();
const registerBannerMock = vi.fn();
const showModalMock = vi.fn();
vi.mock(I18N_MODULE, () => ({ vi.mock(I18N_MODULE, () => ({
translate: translateMock, translate: translateMock,
@@ -50,6 +56,18 @@ vi.mock(EVENT_MANAGER_MODULE, () => ({
}, },
})); }));
vi.mock(BANNER_SERVICE_MODULE, () => ({
bannerService: {
registerBanner: registerBannerMock,
},
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
},
}));
describe('UI helper DOM utilities', () => { describe('UI helper DOM utilities', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
@@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
getStorageItemMock.mockReset(); getStorageItemMock.mockReset();
setStorageItemMock.mockReset(); setStorageItemMock.mockReset();
registerBannerMock.mockReset();
showModalMock.mockReset();
translateMock.mockReset(); translateMock.mockReset();
globalThis.requestAnimationFrame = (cb) => cb(); globalThis.requestAnimationFrame = (cb) => cb();
}); });
@@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => {
'#2 (Character Subgraph) Nested Loader', '#2 (Character Subgraph) Nested Loader',
]); ]);
}); });
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return false;
}
return defaultValue;
});
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(123, 456, 'Demo Model');
expect(setStorageItemMock).toHaveBeenCalledWith('civitai_host_info_banner_seen', true);
expect(registerBannerMock).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.com/models/123?modelVersionId=456',
'_blank',
'noopener,noreferrer'
);
});
it('uses the configured red host for fallback searches', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return true;
}
return defaultValue;
});
const stateModule = await import(STATE_MODULE);
stateModule.state.global = {
settings: {
civitai_host: 'civitai.red',
},
};
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(null, null, 'Demo Model');
expect(registerBannerMock).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.red/models?query=Demo%20Model',
'_blank',
'noopener,noreferrer'
);
});
}); });

View File

@@ -94,7 +94,7 @@ class DummyDoctorScanner:
class DummyCivitaiClient: class DummyCivitaiClient:
def __init__(self, *, success=True, result=None): def __init__(self, *, success=True, result=None):
self.base_url = 'https://civitai.com/api/v1' self.base_url = 'https://civitai.red/api/v1'
self._success = success self._success = success
self._result = result if result is not None else {'items': []} self._result = result if result is not None else {'items': []}

View File

@@ -274,7 +274,9 @@ class StubCivitaiClient:
def __init__(self) -> None: def __init__(self) -> None:
self.image_info: Dict[str, Any] = {} self.image_info: Dict[str, Any] = {}
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]: async def get_image_info(
self, image_id: str, source_url: str | None = None
) -> Optional[Dict[str, Any]]:
return self.image_info.get(image_id) return self.image_info.get(image_id)
@@ -635,6 +637,58 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
assert call["extension"] == ".mp4" assert call["extension"] == ".mp4"
async def test_import_remote_recipe_supports_civitai_red(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.civitai.image_info["126920345"] = {
"id": 126920345,
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
}
response = await harness.client.get(
"/api/lm/recipes/import-remote",
params={
"image_url": "https://civitai.red/images/126920345",
"name": "Red Recipe",
"resources": json.dumps([]),
"base_model": "Flux",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert harness.downloader.urls
assert "width=450,optimized=true" in harness.downloader.urls[0]
async def test_analyze_remote_image_supports_civitai_red(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.result = SimpleNamespace(payload={"loras": []}, status=200)
response = await harness.client.post(
"/api/lm/recipes/analyze-image",
json={"url": "https://civitai.red/images/126920345"},
)
payload = await response.json()
assert response.status == 200
assert payload == {"loras": []}
assert harness.analysis.remote_calls == [
"https://civitai.red/images/126920345"
]
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None: async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError( harness.analysis.raise_for_uploaded = RecipeValidationError(

View File

@@ -886,3 +886,111 @@ async def test_format_response_defaults_update_flag_false(service_cls, extra_fie
assert "update_available" in formatted assert "update_available" in formatted
assert formatted["update_available"] is False assert formatted["update_available"] is False
@pytest.mark.asyncio
async def test_get_model_civitai_url_uses_default_host():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}
@pytest.mark.asyncio
async def test_get_model_civitai_url_uses_configured_host():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({"civitai_host": "civitai.red"}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.red/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}
@pytest.mark.asyncio
async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_string():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({"civitai_host": True}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}

View File

@@ -581,6 +581,7 @@ class TestInputValidation:
assert service._validate_url("https://example.com/image.png") is True assert service._validate_url("https://example.com/image.png") is True
assert service._validate_url("http://example.com/image.png") is True assert service._validate_url("http://example.com/image.png") is True
assert service._validate_url("https://civitai.com/images/123") is True assert service._validate_url("https://civitai.com/images/123") is True
assert service._validate_url("https://civitai.red/images/123") is True
def test_validate_invalid_url(self, service): def test_validate_invalid_url(self, service):
assert service._validate_url("not-a-url") is False assert service._validate_url("not-a-url") is False

View File

@@ -62,6 +62,12 @@ async def test_download_file_uses_downloader(tmp_path, downloader):
assert downloader.download_calls[0]["use_auth"] is True assert downloader.download_calls[0]["use_auth"] is True
async def test_client_defaults_to_red_api_host(downloader):
client = await CivitaiClient.get_instance()
assert client.base_url == "https://civitai.red/api/v1"
async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader): async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
version_payload = { version_payload = {
"modelId": 123, "modelId": 123,
@@ -530,6 +536,69 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
assert result is None assert result is None
async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return True, {"items": [{"id": 124950237, "name": "target"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X"
]
async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return True, {"items": [{"id": 124950237, "name": "target"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return False, "403 forbidden"
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result is None
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog): async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
"""When given a non-numeric image ID, return None and log error.""" """When given a non-numeric image ID, return None and log error."""
client = await CivitaiClient.get_instance() client = await CivitaiClient.get_instance()

View File

@@ -7,7 +7,10 @@ from unittest.mock import AsyncMock
import pytest import pytest
from py.services.download_manager import DownloadManager from py.services.download_manager import (
CIVITAI_DOWNLOAD_URL_PREFIXES,
DownloadManager,
)
from py.services import download_manager from py.services import download_manager
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import SettingsManager, get_settings_manager from py.services.settings_manager import SettingsManager, get_settings_manager
@@ -309,6 +312,67 @@ async def test_execute_download_respects_blur_setting(monkeypatch, tmp_path):
assert stored_preview and stored_preview.endswith(".jpeg") assert stored_preview and stored_preview.endswith(".jpeg")
@pytest.mark.asyncio
async def test_execute_download_uses_auth_for_red_civitai_downloads(monkeypatch, tmp_path):
manager = DownloadManager()
save_dir = tmp_path / "downloads"
save_dir.mkdir()
target_path = save_dir / "file.safetensors"
class DummyMetadata:
def __init__(self, path: Path):
self.file_path = str(path)
self.sha256 = "sha256"
self.file_name = path.stem
self.preview_url = None
self.preview_nsfw_level = None
def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path)
def update_file_info(self, _path):
return None
def to_dict(self):
return {"file_path": self.file_path}
metadata = DummyMetadata(target_path)
recorded_use_auth = []
class DummyDownloader:
stall_timeout = None
async def download_file(self, url, path, progress_callback=None, use_auth=None, **_kwargs):
recorded_use_auth.append((url, use_auth))
Path(path).write_bytes(b"model")
return True, None
monkeypatch.setattr(
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
)
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
monkeypatch.setattr(
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
)
result = await manager._execute_download(
download_urls=["https://civitai.red/api/download/models/119514"],
save_dir=str(save_dir),
metadata=metadata,
version_info={"images": []},
relative_path="",
progress_callback=None,
model_type="lora",
download_id=None,
)
assert result == {"success": True}
assert recorded_use_auth == [("https://civitai.red/api/download/models/119514", True)]
assert "https://civitai.red/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_civarchive_source_uses_civarchive_provider( async def test_civarchive_source_uses_civarchive_provider(
monkeypatch, scanners, tmp_path monkeypatch, scanners, tmp_path

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
# We define these here to help with spec= if needed # We define these here to help with spec= if needed
class MockCivitaiClient: class MockCivitaiClient:
async def get_image_info(self, image_id): async def get_image_info(self, image_id, source_url=None):
pass pass
class MockPersistenceService: class MockPersistenceService:
@@ -119,6 +119,50 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
assert "hash" not in checkpoint assert "hash" not in checkpoint
assert "file_name" not in checkpoint assert "file_name" not in checkpoint
@pytest.mark.asyncio
async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
recipe = {
"id": "r1",
"title": "Red Recipe",
"source_url": "https://civitai.red/images/12345",
"checkpoint": None,
"gen_params": {"prompt": ""},
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
mock_civitai_client.get_image_info.return_value = {
"modelVersionId": 5678,
"meta": {"prompt": "from red"},
}
mock_metadata_provider.get_model_version_info.return_value = (
{
"id": 5678,
"modelId": 1234,
"name": "v1.0",
"model": {"name": "Full Model Name"},
"baseModel": "SDXL 1.0",
"images": [{"url": "https://image.url/thumb.jpg"}],
"files": [
{
"type": "Model",
"hashes": {"SHA256": "ABCDEF"},
"name": "full_filename.safetensors",
}
],
},
None,
)
results = await recipe_scanner.repair_all_recipes()
assert results["repaired"] == 1
mock_civitai_client.get_image_info.assert_called_with(
"12345", source_url="https://civitai.red/images/12345"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner): async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner

View File

@@ -678,7 +678,7 @@ async def test_analyze_remote_video(tmp_path):
) )
class DummyClient: class DummyClient:
async def get_image_info(self, image_id): async def get_image_info(self, image_id, source_url=None):
return { return {
"url": "https://civitai.com/video.mp4", "url": "https://civitai.com/video.mp4",
"type": "video", "type": "video",
@@ -698,3 +698,60 @@ async def test_analyze_remote_video(tmp_path):
assert result.payload["is_video"] is True assert result.payload["is_video"] is True
assert result.payload["extension"] == ".mp4" assert result.payload["extension"] == ".mp4"
assert result.payload["image_base64"] is not None assert result.payload["image_base64"] is not None
@pytest.mark.asyncio
async def test_analyze_remote_image_supports_civitai_red():
exif_utils = DummyExifUtils()
class DummyFactory:
def create_parser(self, metadata):
async def parse_metadata(m, recipe_scanner=None, civitai_client=None):
return {"loras": [], "gen_params": {"prompt": "red prompt"}}
return SimpleNamespace(parse_metadata=parse_metadata)
async def downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
Path(path).write_bytes(b"fake-image")
return True, "success"
return Downloader()
service = RecipeAnalysisService(
exif_utils=exif_utils,
recipe_parser_factory=DummyFactory(),
downloader_factory=downloader_factory,
metadata_collector=None,
metadata_processor_cls=None,
metadata_registry_cls=None,
standalone_mode=False,
logger=logging.getLogger("test"),
)
class DummyClient:
def __init__(self):
self.calls = []
async def get_image_info(self, image_id, source_url=None):
self.calls.append((image_id, source_url))
return {
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
"meta": {"prompt": "red prompt"},
}
class DummyScanner:
async def find_recipes_by_fingerprint(self, fingerprint):
return []
client = DummyClient()
result = await service.analyze_remote_image(
url="https://civitai.red/images/123",
recipe_scanner=DummyScanner(),
civitai_client=client,
)
assert client.calls == [("123", "https://civitai.red/images/123")]
assert result.payload["loras"] == []

View File

@@ -1,4 +1,11 @@
from py.utils.civitai_utils import build_license_flags, resolve_license_info, resolve_license_payload from py.utils.civitai_utils import (
build_license_flags,
extract_civitai_image_id,
extract_civitai_model_url_parts,
is_supported_civitai_page_host,
resolve_license_info,
resolve_license_payload,
)
def test_resolve_license_payload_defaults(): def test_resolve_license_payload_defaults():
@@ -78,3 +85,40 @@ def test_build_license_flags_parses_aggregate_inside_list():
flags = build_license_flags(source) flags = build_license_flags(source)
expected_flags = (1 << 0) | (7 << 1) | (1 << 5) expected_flags = (1 << 0) | (7 << 1) | (1 << 5)
assert flags == expected_flags assert flags == expected_flags
def test_supported_civitai_page_hosts_include_red():
assert is_supported_civitai_page_host("civitai.com") is True
assert is_supported_civitai_page_host("civitai.red") is True
assert is_supported_civitai_page_host("www.civitai.com") is False
assert is_supported_civitai_page_host("www.civitai.red") is False
assert is_supported_civitai_page_host("example.com") is False
def test_extract_civitai_model_url_parts_supports_red():
model_id, version_id = extract_civitai_model_url_parts(
"https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
)
assert model_id == "65423"
assert version_id == "777"
def test_extract_civitai_model_url_parts_rejects_non_civitai_host():
model_id, version_id = extract_civitai_model_url_parts(
"https://example.com/models/65423?modelVersionId=777"
)
assert model_id is None
assert version_id is None
def test_extract_civitai_image_id_supports_red():
assert (
extract_civitai_image_id("https://civitai.red/images/126920345")
== "126920345"
)
def test_extract_civitai_image_id_rejects_non_civitai_host():
assert extract_civitai_image_id("https://example.com/images/126920345") is None