diff --git a/locales/de.json b/locales/de.json index 1fbfae8b..2bf65cbf 100644 --- a/locales/de.json +++ b/locales/de.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Civitai API Key", "civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein", "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": { "label": "Einstellungsordner öffnen", "tooltip": "Den Ordner mit der settings.json öffnen", diff --git a/locales/en.json b/locales/en.json index 4acaff89..debda27b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Civitai API Key", "civitaiApiKeyPlaceholder": "Enter your Civitai API key", "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": { "label": "Open settings folder", "tooltip": "Open folder containing settings.json", diff --git a/locales/es.json b/locales/es.json index 9ba3be44..b54412a0 100644 --- a/locales/es.json +++ b/locales/es.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Clave API de Civitai", "civitaiApiKeyPlaceholder": "Introduce tu clave API 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": { "label": "Abrir carpeta de ajustes", "tooltip": "Abrir la carpeta que contiene settings.json", diff --git a/locales/fr.json b/locales/fr.json index 32ed83ea..12d800fb 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -250,6 +250,19 @@ "civitaiApiKey": "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", + "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 d’hô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": { "label": "Ouvrir le dossier des paramètres", "tooltip": "Ouvrir le dossier contenant settings.json", diff --git a/locales/he.json b/locales/he.json index c0e45bad..8aab896d 100644 --- a/locales/he.json +++ b/locales/he.json @@ -250,6 +250,19 @@ "civitaiApiKey": "מפתח API של Civitai", "civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-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": { "label": "פתח תיקיית הגדרות", "tooltip": "פתח את התיקייה שמכילה את ‎settings.json", diff --git a/locales/ja.json b/locales/ja.json index 46223803..6b84ed0b 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Civitai APIキー", "civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください", "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": { "label": "設定フォルダーを開く", "tooltip": "settings.json を含むフォルダーを開きます", diff --git a/locales/ko.json b/locales/ko.json index 1cded4d6..a3d8569c 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Civitai API 키", "civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요", "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": { "label": "설정 폴더 열기", "tooltip": "settings.json이 있는 폴더를 엽니다", diff --git a/locales/ru.json b/locales/ru.json index 09211dd8..d21ca52a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Ключ API Civitai", "civitaiApiKeyPlaceholder": "Введите ваш ключ API 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": { "label": "Открыть папку настроек", "tooltip": "Открыть папку, содержащую settings.json", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 93c4f630..52979a9a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Civitai API 密钥", "civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥", "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": { "label": "打开设置文件夹", "tooltip": "打开包含 settings.json 的文件夹", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b4d73a59..908b4ac5 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -250,6 +250,19 @@ "civitaiApiKey": "Civitai API 金鑰", "civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰", "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": { "label": "開啟設定資料夾", "tooltip": "開啟包含 settings.json 的資料夾", diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 681ed325..2eb88138 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -20,6 +20,7 @@ from .model_query import ( resolve_sub_type, ) from .settings_manager import get_settings_manager +from ..utils.civitai_utils import build_civitai_model_page_url logger = logging.getLogger(__name__) @@ -774,9 +775,12 @@ class BaseModelService(ABC): version_id = civitai_data.get("id") if model_id: - civitai_url = f"https://civitai.com/models/{model_id}" - if version_id: - civitai_url += f"?modelVersionId={version_id}" + civitai_host = self.settings.get("civitai_host", "civitai.com") + civitai_url = build_civitai_model_page_url( + model_id, + version_id, + host=civitai_host, + ) return { "civitai_url": civitai_url, diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 00d1319e..8b6cfebc 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -54,6 +54,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10 DEFAULT_SETTINGS: Dict[str, Any] = { "civitai_api_key": "", + "civitai_host": "civitai.com", "use_portable_settings": False, "hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB, "language": "en", diff --git a/py/utils/civitai_utils.py b/py/utils/civitai_utils.py index 3c7326d8..07fc0057 100644 --- a/py/utils/civitai_utils.py +++ b/py/utils/civitai_utils.py @@ -8,6 +8,7 @@ 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",) _LICENSE_DEFAULTS: Dict[str, Any] = { "allowNoCredit": True, @@ -27,6 +28,44 @@ def is_supported_civitai_page_host(hostname: str | None) -> bool: 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 diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 56171c60..f9e08deb 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -1,7 +1,7 @@ // PageControls.js - Manages controls for both LoRAs and Checkpoints pages import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.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 { sidebarManager } from '../SidebarManager.js'; @@ -353,18 +353,8 @@ export class PageControls { const metaData = JSON.parse(card.dataset.meta); const civitaiId = metaData.modelId; const versionId = metaData.id; - - // Build URL - 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'); - } + + openCivitaiByMetadata(civitaiId, versionId, modelName); } /** diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index f4f56525..54a2fa7e 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -1,26 +1,21 @@ import { getModelApiClient } from '../../api/modelApiFactory.js'; import { downloadManager } from '../../managers/DownloadManager.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 { state } from '../../state/index.js'; +import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js'; import { formatFileSize } from './utils.js'; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; function buildCivitaiVersionUrl(modelId, versionId) { - if (modelId == null || versionId == null) { - return null; - } - const normalizedModelId = String(modelId).trim(); - 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}`; + return buildCivitaiModelUrl( + modelId, + versionId, + state?.global?.settings?.civitai_host + ); } function escapeHtml(value) { @@ -1352,6 +1347,13 @@ export function initVersionsTab({ } 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) { return; } @@ -1371,7 +1373,7 @@ export function initVersionsTab({ return; } event.preventDefault(); - window.open(targetUrl, '_blank', 'noopener,noreferrer'); + openCivitaiUrl(targetUrl); }); // Listen for extension-triggered refresh requests diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index dda95daf..1dcc5d2f 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -802,6 +802,11 @@ export class SettingsManager { 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'); if (recipesPathInput) { recipesPathInput.value = state.global.settings.recipes_path || ''; diff --git a/static/js/state/index.js b/static/js/state/index.js index a95a671d..3cd2e4d2 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co const DEFAULT_SETTINGS_BASE = Object.freeze({ civitai_api_key: '', + civitai_host: 'civitai.com', use_portable_settings: false, language: 'en', show_only_sfw: false, diff --git a/static/js/utils/civitaiUtils.js b/static/js/utils/civitaiUtils.js index 4a4f2ee7..29550e78 100644 --- a/static/js/utils/civitaiUtils.js +++ b/static/js/utils/civitaiUtils.js @@ -13,11 +13,64 @@ export const OptimizationMode = { 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. * Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 739c274b..bd0ca852 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.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 @@ -184,14 +244,15 @@ function filterByFolder(folderPath) { } export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) { - if (versionId) { - // Use model-versions endpoint which auto-redirects to correct model page - window.open(`https://civitai.com/model-versions/${versionId}`, '_blank'); - } else if (civitaiId) { - window.open(`https://civitai.com/models/${civitaiId}`, '_blank'); - } else if (modelName) { - // Fallback: search by name - window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank'); + const url = buildCivitaiUrl({ + modelId: civitaiId, + versionId, + modelName, + host: getPreferredCivitaiHost(), + }); + + if (url) { + openCivitaiUrl(url); } } diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 16b2e62d..c85a7cfc 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -114,6 +114,21 @@ +
+
+
+ + +
+
+ +
+
+
+
diff --git a/tests/frontend/components/modelVersionsTab.media.test.js b/tests/frontend/components/modelVersionsTab.media.test.js index 9400abe1..b3630d21 100644 --- a/tests/frontend/components/modelVersionsTab.media.test.js +++ b/tests/frontend/components/modelVersionsTab.media.test.js @@ -26,6 +26,7 @@ vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({ vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), + openCivitaiUrl: vi.fn(), })); const stateMock = { diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js index 678cea51..6763496e 100644 --- a/tests/frontend/components/pageControls.filtering.test.js +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -12,6 +12,7 @@ const apiClientMock = { }; const showToastMock = vi.fn(); +const openCivitaiByMetadataMock = vi.fn(); const updatePanelPositionsMock = vi.fn(); const downloadManagerMock = { showDownloadModal: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: showToastMock, + openCivitaiByMetadata: openCivitaiByMetadataMock, updatePanelPositions: updatePanelPositionsMock, })); diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js index 719d78f0..45b94de9 100644 --- a/tests/frontend/pages/recipesPage.test.js +++ b/tests/frontend/pages/recipesPage.test.js @@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn(); const getSessionItemMock = vi.fn(); const removeSessionItemMock = vi.fn(); const getStorageItemMock = vi.fn(); +const setStorageItemMock = vi.fn(); +const removeStorageItemMock = vi.fn(); const RecipeContextMenuMock = vi.fn(); const refreshVirtualScrollMock = vi.fn(); const refreshRecipesMock = vi.fn(); @@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ getSessionItem: getSessionItemMock, removeSessionItem: removeSessionItemMock, getStorageItem: getStorageItemMock, + setStorageItem: setStorageItemMock, + removeStorageItem: removeStorageItemMock, })); vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ diff --git a/tests/frontend/state/index.test.js b/tests/frontend/state/index.test.js index c21268f8..ba586d84 100644 --- a/tests/frontend/state/index.test.js +++ b/tests/frontend/state/index.test.js @@ -14,6 +14,7 @@ describe('state module', () => { expect(defaultSettings).toMatchObject({ civitai_api_key: '', + civitai_host: 'civitai.com', language: 'en', blur_mature_content: true, mature_blur_level: 'R' diff --git a/tests/frontend/utils/civitaiUtils.test.js b/tests/frontend/utils/civitaiUtils.test.js index b7cef6d9..11ec2c7e 100644 --- a/tests/frontend/utils/civitaiUtils.test.js +++ b/tests/frontend/utils/civitaiUtils.test.js @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; import { + DEFAULT_CIVITAI_PAGE_HOST, + normalizeCivitaiPageHost, + buildCivitaiModelUrl, + buildCivitaiSearchUrl, + buildCivitaiUrl, rewriteCivitaiUrl, getOptimizedUrl, getShowcaseUrl, @@ -19,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', () => { it('should rewrite image URLs with /original=true for thumbnail mode', () => { const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg'; diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js index 2c74dcd4..ff8e5080 100644 --- a/tests/frontend/utils/uiHelpers.dom.test.js +++ b/tests/frontend/utils/uiHelpers.dom.test.js @@ -6,6 +6,8 @@ const { STORAGE_MODULE, CONSTANTS_MODULE, EVENT_MANAGER_MODULE, + BANNER_SERVICE_MODULE, + MODAL_MANAGER_MODULE, UI_HELPERS_MODULE, } = vi.hoisted(() => ({ 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, 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, + 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, })); const translateMock = vi.fn((key, _params, fallback) => fallback || key); const getStorageItemMock = vi.fn(); const setStorageItemMock = vi.fn(); +const registerBannerMock = vi.fn(); +const showModalMock = vi.fn(); vi.mock(I18N_MODULE, () => ({ 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', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => { document.documentElement.removeAttribute('data-theme'); getStorageItemMock.mockReset(); setStorageItemMock.mockReset(); + registerBannerMock.mockReset(); + showModalMock.mockReset(); translateMock.mockReset(); globalThis.requestAnimationFrame = (cb) => cb(); }); @@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => { '#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' + ); + }); }); diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index 6b2d9d14..d7a6cb73 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -886,3 +886,111 @@ async def test_format_response_defaults_update_flag_false(service_cls, extra_fie assert "update_available" in formatted 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", + }