Compare commits

...

6 Commits

Author SHA1 Message Date
Will Miao
89fd2b43d6 chore(release): bump version to v1.0.5 and add release notes 2026-04-16 21:52:34 +08:00
Will Miao
c53f44e7ef feat(excluded-models): add excluded management view 2026-04-16 21:40:59 +08:00
Will Miao
ae7bfdb517 fix(download): normalize civitai.red download URLs (#898) 2026-04-16 18:25:16 +08:00
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
56 changed files with 1742 additions and 208 deletions

View File

@@ -56,6 +56,18 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes ## Release Notes
### v1.0.5
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
### 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

@@ -175,6 +175,9 @@
"success": "{count} Rezepte erfolgreich repariert.", "success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-Reparatur fehlgeschlagen: {message}"
},
"manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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",
@@ -667,6 +683,7 @@
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren", "repairMetadata": "Metadaten reparieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen",
"deleteModel": "Modell löschen", "deleteModel": "Modell löschen",
"shareRecipe": "Rezept teilen", "shareRecipe": "Rezept teilen",
"viewAllLoras": "Alle LoRAs anzeigen", "viewAllLoras": "Alle LoRAs anzeigen",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Fehler beim Löschen von {type}: {message}", "deleteFailed": "Fehler beim Löschen von {type}: {message}",
"excludeSuccess": "{type} erfolgreich ausgeschlossen", "excludeSuccess": "{type} erfolgreich ausgeschlossen",
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}", "excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
"fileNameUpdated": "Dateiname erfolgreich aktualisiert", "fileNameUpdated": "Dateiname erfolgreich aktualisiert",
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}", "fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert", "previewUpdated": "Vorschau erfolgreich aktualisiert",

View File

@@ -175,6 +175,9 @@
"success": "Successfully repaired {count} recipes.", "success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.", "cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}" "error": "Recipe repair failed: {message}"
},
"manageExcludedModels": {
"label": "Manage Excluded Models"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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",
@@ -667,6 +683,7 @@
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata", "repairMetadata": "Repair metadata",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"restoreModel": "Restore Model",
"deleteModel": "Delete Model", "deleteModel": "Delete Model",
"shareRecipe": "Share Recipe", "shareRecipe": "Share Recipe",
"viewAllLoras": "View All LoRAs", "viewAllLoras": "View All LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Failed to delete {type}: {message}", "deleteFailed": "Failed to delete {type}: {message}",
"excludeSuccess": "{type} excluded successfully", "excludeSuccess": "{type} excluded successfully",
"excludeFailed": "Failed to exclude {type}: {message}", "excludeFailed": "Failed to exclude {type}: {message}",
"restoreSuccess": "{type} restored successfully",
"restoreFailed": "Failed to restore {type}: {message}",
"fileNameUpdated": "File name updated successfully", "fileNameUpdated": "File name updated successfully",
"fileRenameFailed": "Failed to rename file: {error}", "fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully", "previewUpdated": "Preview updated successfully",

View File

@@ -175,6 +175,9 @@
"success": "Se repararon con éxito {count} recetas.", "success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}" "error": "Error al reparar recetas: {message}"
},
"manageExcludedModels": {
"label": "Gestionar modelos excluidos"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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",
@@ -667,6 +683,7 @@
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos", "repairMetadata": "Reparar metadatos",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo",
"deleteModel": "Eliminar modelo", "deleteModel": "Eliminar modelo",
"shareRecipe": "Compartir receta", "shareRecipe": "Compartir receta",
"viewAllLoras": "Ver todos los LoRAs", "viewAllLoras": "Ver todos los LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Error al eliminar {type}: {message}", "deleteFailed": "Error al eliminar {type}: {message}",
"excludeSuccess": "{type} excluido exitosamente", "excludeSuccess": "{type} excluido exitosamente",
"excludeFailed": "Error al excluir {type}: {message}", "excludeFailed": "Error al excluir {type}: {message}",
"restoreSuccess": "{type} restaurado correctamente",
"restoreFailed": "No se pudo restaurar {type}: {message}",
"fileNameUpdated": "Nombre de archivo actualizado exitosamente", "fileNameUpdated": "Nombre de archivo actualizado exitosamente",
"fileRenameFailed": "Error al renombrar archivo: {error}", "fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente", "previewUpdated": "Vista previa actualizada exitosamente",

View File

@@ -175,6 +175,9 @@
"success": "{count} recettes réparées avec succès.", "success": "{count} recettes réparées avec succès.",
"cancelled": "Réparation annulée. {count} recettes ont été réparées.", "cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}" "error": "Échec de la réparation des recettes : {message}"
},
"manageExcludedModels": {
"label": "Gérer les modèles exclus"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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",
@@ -667,6 +683,7 @@
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées", "repairMetadata": "Réparer les métadonnées",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle",
"deleteModel": "Supprimer le modèle", "deleteModel": "Supprimer le modèle",
"shareRecipe": "Partager la recipe", "shareRecipe": "Partager la recipe",
"viewAllLoras": "Voir tous les LoRAs", "viewAllLoras": "Voir tous les LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Échec de la suppression de {type} : {message}", "deleteFailed": "Échec de la suppression de {type} : {message}",
"excludeSuccess": "{type} exclu avec succès", "excludeSuccess": "{type} exclu avec succès",
"excludeFailed": "Échec de l'exclusion de {type} : {message}", "excludeFailed": "Échec de l'exclusion de {type} : {message}",
"restoreSuccess": "{type} restauré avec succès",
"restoreFailed": "Échec de la restauration de {type} : {message}",
"fileNameUpdated": "Nom de fichier mis à jour avec succès", "fileNameUpdated": "Nom de fichier mis à jour avec succès",
"fileRenameFailed": "Échec du renommage du fichier : {error}", "fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès", "previewUpdated": "Aperçu mis à jour avec succès",

View File

@@ -175,6 +175,9 @@
"success": "תוקנו בהצלחה {count} מתכונים.", "success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
},
"manageExcludedModels": {
"label": "ניהול מודלים מוחרגים"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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",
@@ -667,6 +683,7 @@
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה", "repairMetadata": "תיקון מטא-דאטה",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל",
"deleteModel": "מחק מודל", "deleteModel": "מחק מודל",
"shareRecipe": "שתף מתכון", "shareRecipe": "שתף מתכון",
"viewAllLoras": "הצג את כל ה-LoRAs", "viewAllLoras": "הצג את כל ה-LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "מחיקת {type} נכשלה: {message}", "deleteFailed": "מחיקת {type} נכשלה: {message}",
"excludeSuccess": "{type} הוחרג בהצלחה", "excludeSuccess": "{type} הוחרג בהצלחה",
"excludeFailed": "החרגת {type} נכשלה: {message}", "excludeFailed": "החרגת {type} נכשלה: {message}",
"restoreSuccess": "{type} שוחזר בהצלחה",
"restoreFailed": "שחזור {type} נכשל: {message}",
"fileNameUpdated": "שם הקובץ עודכן בהצלחה", "fileNameUpdated": "שם הקובץ עודכן בהצלחה",
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}", "fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה", "previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",

View File

@@ -175,6 +175,9 @@
"success": "{count} 件のレシピを正常に修復しました。", "success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
},
"manageExcludedModels": {
"label": "除外モデルを管理"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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 を含むフォルダーを開きます",
@@ -667,6 +683,7 @@
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復", "repairMetadata": "メタデータを修復",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"restoreModel": "モデルを復元",
"deleteModel": "モデルを削除", "deleteModel": "モデルを削除",
"shareRecipe": "レシピを共有", "shareRecipe": "レシピを共有",
"viewAllLoras": "すべてのLoRAを表示", "viewAllLoras": "すべてのLoRAを表示",
@@ -1790,6 +1807,8 @@
"deleteFailed": "{type}の削除に失敗しました:{message}", "deleteFailed": "{type}の削除に失敗しました:{message}",
"excludeSuccess": "{type}が正常に除外されました", "excludeSuccess": "{type}が正常に除外されました",
"excludeFailed": "{type}の除外に失敗しました:{message}", "excludeFailed": "{type}の除外に失敗しました:{message}",
"restoreSuccess": "{type}を復元しました",
"restoreFailed": "{type}の復元に失敗しました: {message}",
"fileNameUpdated": "ファイル名が正常に更新されました", "fileNameUpdated": "ファイル名が正常に更新されました",
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}", "fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました", "previewUpdated": "プレビューが正常に更新されました",

View File

@@ -175,6 +175,9 @@
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.", "success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
},
"manageExcludedModels": {
"label": "제외된 모델 관리"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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이 있는 폴더를 엽니다",
@@ -667,6 +683,7 @@
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구", "repairMetadata": "메타데이터 복구",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"restoreModel": "모델 복원",
"deleteModel": "모델 삭제", "deleteModel": "모델 삭제",
"shareRecipe": "레시피 공유", "shareRecipe": "레시피 공유",
"viewAllLoras": "모든 LoRA 보기", "viewAllLoras": "모든 LoRA 보기",
@@ -1790,6 +1807,8 @@
"deleteFailed": "{type} 삭제 실패: {message}", "deleteFailed": "{type} 삭제 실패: {message}",
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다", "excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
"excludeFailed": "{type} 제외 실패: {message}", "excludeFailed": "{type} 제외 실패: {message}",
"restoreSuccess": "{type} 복원 완료",
"restoreFailed": "{type} 복원 실패: {message}",
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다", "fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
"fileRenameFailed": "파일 이름 변경 실패: {error}", "fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다", "previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",

View File

@@ -175,6 +175,9 @@
"success": "Успешно восстановлено {count} рецептов.", "success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
},
"manageExcludedModels": {
"label": "Управление исключёнными моделями"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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",
@@ -667,6 +683,7 @@
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные", "repairMetadata": "Восстановить метаданные",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель",
"deleteModel": "Удалить модель", "deleteModel": "Удалить модель",
"shareRecipe": "Поделиться рецептом", "shareRecipe": "Поделиться рецептом",
"viewAllLoras": "Посмотреть все LoRAs", "viewAllLoras": "Посмотреть все LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Не удалось удалить {type}: {message}", "deleteFailed": "Не удалось удалить {type}: {message}",
"excludeSuccess": "{type} успешно исключен", "excludeSuccess": "{type} успешно исключен",
"excludeFailed": "Не удалось исключить {type}: {message}", "excludeFailed": "Не удалось исключить {type}: {message}",
"restoreSuccess": "{type} успешно восстановлен",
"restoreFailed": "Не удалось восстановить {type}: {message}",
"fileNameUpdated": "Имя файла успешно обновлено", "fileNameUpdated": "Имя файла успешно обновлено",
"fileRenameFailed": "Не удалось переименовать файл: {error}", "fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено", "previewUpdated": "Превью успешно обновлено",

View File

@@ -175,6 +175,9 @@
"success": "成功修复了 {count} 个配方。", "success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。", "cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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 的文件夹",
@@ -667,6 +683,7 @@
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据", "repairMetadata": "修复元数据",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "恢复模型",
"deleteModel": "删除模型", "deleteModel": "删除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
"viewAllLoras": "查看所有 LoRA", "viewAllLoras": "查看所有 LoRA",
@@ -1790,6 +1807,8 @@
"deleteFailed": "删除 {type} 失败:{message}", "deleteFailed": "删除 {type} 失败:{message}",
"excludeSuccess": "{type} 排除成功", "excludeSuccess": "{type} 排除成功",
"excludeFailed": "排除 {type} 失败:{message}", "excludeFailed": "排除 {type} 失败:{message}",
"restoreSuccess": "{type} 已成功恢复",
"restoreFailed": "恢复 {type} 失败:{message}",
"fileNameUpdated": "文件名更新成功", "fileNameUpdated": "文件名更新成功",
"fileRenameFailed": "重命名文件失败:{error}", "fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功", "previewUpdated": "预览图片更新成功",

View File

@@ -175,6 +175,9 @@
"success": "成功修復 {count} 個配方。", "success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。", "cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
} }
}, },
"header": { "header": {
@@ -250,6 +253,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 的資料夾",
@@ -667,6 +683,7 @@
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據", "repairMetadata": "修復元數據",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "還原模型",
"deleteModel": "刪除模型", "deleteModel": "刪除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
"viewAllLoras": "檢視全部 LoRA", "viewAllLoras": "檢視全部 LoRA",
@@ -1790,6 +1807,8 @@
"deleteFailed": "刪除 {type} 失敗:{message}", "deleteFailed": "刪除 {type} 失敗:{message}",
"excludeSuccess": "{type} 已成功排除", "excludeSuccess": "{type} 已成功排除",
"excludeFailed": "排除 {type} 失敗:{message}", "excludeFailed": "排除 {type} 失敗:{message}",
"restoreSuccess": "{type} 已成功還原",
"restoreFailed": "還原 {type} 失敗:{message}",
"fileNameUpdated": "檔案名稱已成功更新", "fileNameUpdated": "檔案名稱已成功更新",
"fileRenameFailed": "重新命名檔案失敗:{error}", "fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新", "previewUpdated": "預覽圖片已成功更新",

View File

@@ -224,6 +224,42 @@ class ModelListingHandler:
) )
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def get_excluded_models(self, request: web.Request) -> web.Response:
start_time = time.perf_counter()
try:
params = self._parse_common_params(request)
result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter()
formatted_result = {
"items": [
await self._service.format_response(item)
for item in result["items"]
],
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"total_pages": result["total_pages"],
}
format_duration = time.perf_counter() - format_start
duration = time.perf_counter() - start_time
self._logger.debug(
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
self._service.model_type,
duration,
format_duration,
)
return web.json_response(formatted_result)
except Exception as exc:
self._logger.error(
"Error retrieving excluded %ss: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"error": str(exc)}, status=500)
def _parse_common_params(self, request: web.Request) -> Dict: def _parse_common_params(self, request: web.Request) -> Dict:
page = int(request.query.get("page", "1")) page = int(request.query.get("page", "1"))
page_size = min(int(request.query.get("page_size", "20")), 100) page_size = min(int(request.query.get("page_size", "20")), 100)
@@ -392,6 +428,21 @@ class ModelManagementHandler:
self._logger.error("Error excluding model: %s", exc, exc_info=True) self._logger.error("Error excluding model: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
async def unexclude_model(self, request: web.Request) -> web.Response:
try:
data = await request.json()
file_path = data.get("file_path")
if not file_path:
return web.Response(text="Model path is required", status=400)
result = await self._lifecycle_service.unexclude_model(file_path)
return web.json_response(result)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error restoring model: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500)
async def fetch_civitai(self, request: web.Request) -> web.Response: async def fetch_civitai(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -2437,8 +2488,10 @@ class ModelHandlerSet:
return { return {
"handle_models_page": self.page_view.handle, "handle_models_page": self.page_view.handle,
"get_models": self.listing.get_models, "get_models": self.listing.get_models,
"get_excluded_models": self.listing.get_excluded_models,
"delete_model": self.management.delete_model, "delete_model": self.management.delete_model,
"exclude_model": self.management.exclude_model, "exclude_model": self.management.exclude_model,
"unexclude_model": self.management.unexclude_model,
"fetch_civitai": self.management.fetch_civitai, "fetch_civitai": self.management.fetch_civitai,
"fetch_all_civitai": self.civitai.fetch_all_civitai, "fetch_all_civitai": self.civitai.fetch_all_civitai,
"relink_civitai": self.management.relink_civitai, "relink_civitai": self.management.relink_civitai,

View File

@@ -22,8 +22,10 @@ class RouteDefinition:
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"), RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"), RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"), RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),

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__)
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
) )
return paginated return paginated
async def get_excluded_paginated_data(
self,
page: int,
page_size: int,
sort_by: str = "name",
search: str = None,
fuzzy_search: bool = False,
search_options: dict = None,
**kwargs,
) -> Dict:
"""Get paginated excluded model data."""
excluded_paths = list(self.scanner.get_excluded_models())
excluded_entries: List[Dict[str, Any]] = []
stale_paths: List[str] = []
for file_path in excluded_paths:
if not file_path or not os.path.exists(file_path):
stale_paths.append(file_path)
continue
entry = await self._build_excluded_entry(file_path)
if entry:
excluded_entries.append(entry)
else:
stale_paths.append(file_path)
if stale_paths:
current_excluded = getattr(self.scanner, "_excluded_models", None)
if isinstance(current_excluded, list):
stale_set = set(stale_paths)
self.scanner._excluded_models = [
path for path in current_excluded if path not in stale_set
]
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
excluded_entries = self._sort_entries(excluded_entries, sort_by)
if search:
excluded_entries = await self._apply_search_filters(
excluded_entries,
search,
fuzzy_search,
search_options,
)
paginated = self._paginate(excluded_entries, page, page_size)
paginated["items"] = await self._annotate_update_flags(paginated["items"])
return paginated
async def _fetch_with_usage_sort(self, sort_params): async def _fetch_with_usage_sort(self, sort_params):
"""Fetch data sorted by usage count (desc/asc).""" """Fetch data sorted by usage count (desc/asc)."""
cache = await self.cache_repository.get_cache() cache = await self.cache_repository.get_cache()
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
) )
return annotated return annotated
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
sort_params = self.cache_repository.parse_sort(sort_by)
key_name = sort_params.key
if key_name == "date":
key_fn = lambda item: (
float(item.get("modified", 0.0) or 0.0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "size":
key_fn = lambda item: (
int(item.get("size", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "usage":
key_fn = lambda item: (
int(item.get("usage_count", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
else:
key_fn = lambda item: (
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
root_path = self.scanner._find_root_for_file(file_path)
if not root_path:
return None
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self.metadata_class,
)
if should_skip:
return None
if metadata is None:
metadata = await self.scanner._create_default_metadata(file_path)
if metadata is None:
return None
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
os.path.sep, "/"
)
entry = self.scanner._build_cache_entry(metadata, folder=folder)
entry = self.scanner.adjust_cached_entry(entry)
entry["exclude"] = True
return entry
async def _apply_hash_filters( async def _apply_hash_filters(
self, data: List[Dict], hash_filters: Dict self, data: List[Dict], hash_filters: Dict
) -> List[Dict]: ) -> List[Dict]:
@@ -774,9 +882,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

@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
"notes": checkpoint_data.get("notes", ""), "notes": checkpoint_data.get("notes", ""),
"sub_type": sub_type, "sub_type": sub_type,
"favorite": checkpoint_data.get("favorite", False), "favorite": checkpoint_data.get("favorite", False),
"exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)

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

@@ -9,7 +9,7 @@ from .model_metadata_provider import (
) )
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import extract_civitai_page_host, resolve_license_payload from ..utils.civitai_utils import resolve_license_payload
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,24 +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"
self._image_info_api_hosts = ("civitai.com", "civitai.red")
def _build_image_info_url(self, host: str, image_id: str) -> str: def _build_image_info_url(self, image_id: str) -> str:
return f"https://{host}/api/v1/images?imageId={image_id}&nsfw=X" return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
def _resolve_image_info_hosts(self, source_url: str | None) -> List[str]:
preferred_host = extract_civitai_page_host(source_url)
if preferred_host in self._image_info_api_hosts:
return [
preferred_host,
*[
host
for host in self._image_info_api_hosts
if host != preferred_host
],
]
return list(self._image_info_api_hosts)
async def _make_request( async def _make_request(
self, self,
@@ -207,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
@@ -363,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
@@ -375,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
@@ -388,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
@@ -470,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
@@ -503,99 +495,51 @@ class CivitaiClient:
Args: Args:
image_id: The Civitai image ID image_id: The Civitai image ID
source_url: Optional original image page URL used to prioritize source_url: Original image page URL. Accepted for caller compatibility;
``civitai.com`` vs ``civitai.red`` image lookups. 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:
requested_id = int(image_id) requested_id = int(image_id)
candidate_hosts = self._resolve_image_info_hosts(source_url) url = self._build_image_info_url(image_id)
last_error: Any = None success, result = await self._make_request("GET", url, use_auth=True)
logger.debug(
"Fetching image info for ID %s with host order %s",
image_id,
candidate_hosts,
)
for index, host in enumerate(candidate_hosts): if not success:
url = self._build_image_info_url(host, image_id) logger.error(
success, result = await self._make_request("GET", url, use_auth=True) "Failed to fetch image info for ID %s from civitai.red: %s",
image_id,
if not success: result,
last_error = result )
if index < len(candidate_hosts) - 1:
logger.warning(
"Failed to fetch image info for ID %s from %s: %s. Trying fallback host.",
image_id,
host,
result,
)
continue
logger.error(
"Failed to fetch image info for ID %s from %s: %s",
image_id,
host,
result,
)
return None
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 %s",
image_id,
host,
)
return item
returned_ids = [
item.get("id")
for item in items
if isinstance(item, dict) and "id" in item
]
if index < len(candidate_hosts) - 1:
logger.info(
"No matching image for requested ID %s from %s; trying fallback host. Returned %d item(s) with IDs: %s",
image_id,
host,
len(items),
returned_ids,
)
continue
logger.warning(
"CivitAI API returned no matching image for requested ID %s from %s. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
image_id,
host,
len(items),
returned_ids,
)
return None
if index < len(candidate_hosts) - 1:
logger.info(
"No image found with ID %s from %s; trying fallback host",
image_id,
host,
)
continue
logger.warning("No image found with ID: %s", image_id)
return None return None
if last_error is not None: 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 all candidate hosts: %s",
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, image_id,
last_error, len(items),
returned_ids,
) )
return None
logger.warning("No image found with ID: %s", image_id)
return None return None
except RateLimitError: except RateLimitError:
raise raise
@@ -614,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

@@ -16,7 +16,7 @@ from ..utils.constants import (
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -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
@@ -639,7 +644,9 @@ class DownloadManager:
if mirrors: if mirrors:
for mirror in mirrors: for mirror in mirrors:
if mirror.get("deletedAt") is None and mirror.get("url"): if mirror.get("deletedAt") is None and mirror.get("url"):
download_urls.append(mirror["url"]) download_urls.append(
normalize_civitai_download_url(mirror["url"])
)
# When source is 'civarchive', prioritize non-Civitai URLs # When source is 'civarchive', prioritize non-Civitai URLs
# This avoids failed downloads from deleted Civitai models # This avoids failed downloads from deleted Civitai models
@@ -647,18 +654,20 @@ 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:
download_url = file_info.get("downloadUrl") download_url = file_info.get("downloadUrl")
if download_url: if download_url:
download_urls.append(download_url) download_urls.append(
normalize_civitai_download_url(download_url)
)
if not download_urls: if not download_urls:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
@@ -1133,7 +1142,8 @@ 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/") download_url = normalize_civitai_download_url(download_url)
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

@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
"notes": embedding_data.get("notes", ""), "notes": embedding_data.get("notes", ""),
"sub_type": sub_type, "sub_type": sub_type,
"favorite": embedding_data.get("favorite", False), "favorite": embedding_data.get("favorite", False),
"exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)

View File

@@ -48,6 +48,7 @@ class LoraService(BaseModelService):
"usage_tips": lora_data.get("usage_tips", ""), "usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""), "notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False), "favorite": lora_data.get("favorite", False),
"exclude": bool(lora_data.get("exclude", False)),
"update_available": bool(lora_data.get("update_available", False)), "update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool( "skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False) lora_data.get("skip_metadata_refresh", False)

View File

@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..utils.constants import PREVIEW_EXTENSIONS from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -207,11 +208,56 @@ class ModelLifecycleService:
excluded = getattr(self._scanner, "_excluded_models", None) excluded = getattr(self._scanner, "_excluded_models", None)
if isinstance(excluded, list): if isinstance(excluded, list):
excluded.append(file_path) if file_path not in excluded:
excluded.append(file_path)
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
message = f"Model {os.path.basename(file_path)} excluded" message = f"Model {os.path.basename(file_path)} excluded"
return {"success": True, "message": message} return {"success": True, "message": message}
async def unexclude_model(self, file_path: str) -> Dict[str, object]:
"""Restore a previously excluded model to the active cache."""
if not file_path:
raise ValueError("Model path is required")
if not os.path.exists(file_path):
raise ValueError("Model file does not exist")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_payload = await self._metadata_loader(metadata_path)
metadata_payload["exclude"] = False
await self._metadata_manager.save_metadata(file_path, metadata_payload)
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self._scanner.model_class,
)
if should_skip:
metadata = None
if metadata is None:
metadata = metadata_payload
excluded = getattr(self._scanner, "_excluded_models", None)
if isinstance(excluded, list):
self._scanner._excluded_models = [
path for path in excluded if path != file_path
]
await self._scanner.update_single_model_cache(
file_path,
file_path,
metadata,
recalculate_type=True,
)
message = f"Model {os.path.basename(file_path)} restored"
return {"success": True, "message": message}
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]: async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
"""Delete a collection of models via the scanner bulk operation.""" """Delete a collection of models via the scanner bulk operation."""

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

@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"}) _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,
@@ -27,6 +28,44 @@ def is_supported_civitai_page_host(hostname: str | None) -> bool:
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS 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): def _parse_supported_civitai_page_url(url: str | None):
if not url: if not url:
return None return None
@@ -80,6 +119,24 @@ def extract_civitai_image_id(url: str | None) -> str | None:
return path_match.group(1) return path_match.group(1)
def normalize_civitai_download_url(url: str | None) -> str | None:
"""Rewrite Civitai download URLs to the canonical authenticated host."""
if not url:
return url
try:
parsed = urlparse(url)
except ValueError:
return url
hostname = parsed.hostname.lower() if parsed.hostname else None
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
return url
return urlunparse(parsed._replace(netloc="civitai.com"))
def extract_civitai_page_host(url: str | None) -> str | None: def extract_civitai_page_host(url: str | None) -> str | None:
"""Extract the supported Civitai page host from a URL.""" """Extract the supported Civitai page host from a 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.5"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -243,3 +243,58 @@
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
.excluded-view-banner {
margin-bottom: var(--space-2);
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: linear-gradient(
135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
var(--card-bg)
);
}
.excluded-view-banner__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.excluded-view-banner__title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 600;
color: var(--text-color);
}
.excluded-view-banner__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
border-radius: var(--border-radius-xs);
padding: 8px 12px;
cursor: pointer;
}
.excluded-view-banner__back:hover {
border-color: var(--lora-accent);
color: var(--lora-accent);
}
@media (max-width: 768px) {
.excluded-view-banner__content {
flex-direction: column;
align-items: stretch;
}
.excluded-view-banner__back {
justify-content: center;
}
}

View File

@@ -680,3 +680,22 @@
margin-left: 0; margin-left: 0;
line-height: 1; line-height: 1;
} }
.excluded-model {
border-style: dashed;
}
.model-excluded-badge {
width: 16px;
height: 16px;
padding: 0;
border-radius: 3px;
background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%);
color: white;
font-size: 0.65rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.9;
}

View File

@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
return { return {
// Base CRUD operations // Base CRUD operations
list: `/api/lm/${modelType}/list`, list: `/api/lm/${modelType}/list`,
excluded: `/api/lm/${modelType}/excluded`,
delete: `/api/lm/${modelType}/delete`, delete: `/api/lm/${modelType}/delete`,
exclude: `/api/lm/${modelType}/exclude`, exclude: `/api/lm/${modelType}/exclude`,
unexclude: `/api/lm/${modelType}/unexclude`,
rename: `/api/lm/${modelType}/rename`, rename: `/api/lm/${modelType}/rename`,
save: `/api/lm/${modelType}/save-metadata`, save: `/api/lm/${modelType}/save-metadata`,
cancelTask: `/api/lm/${modelType}/cancel-task`, cancelTask: `/api/lm/${modelType}/cancel-task`,

View File

@@ -51,6 +51,7 @@ export class BaseModelApiClient {
async fetchModelsPage(page = 1, pageSize = null) { async fetchModelsPage(page = 1, pageSize = null) {
const pageState = this.getPageState(); const pageState = this.getPageState();
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
const isExcludedView = pageState.viewMode === 'excluded';
try { try {
const params = this._buildQueryParams({ const params = this._buildQueryParams({
@@ -71,7 +72,10 @@ export class BaseModelApiClient {
}; };
} }
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`); const endpoint = isExcludedView
? this.apiConfig.endpoints.excluded
: this.apiConfig.endpoints.list;
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
} }
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
totalPages: data.total_pages, totalPages: data.total_pages,
currentPage: page, currentPage: page,
hasMore: page < data.total_pages, hasMore: page < data.total_pages,
folders: data.folders folders: data.folders || []
}; };
} catch (error) { } catch (error) {
@@ -212,6 +216,50 @@ export class BaseModelApiClient {
} }
} }
async unexcludeModel(filePath) {
try {
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
const response = await fetch(this.apiConfig.endpoints.unexclude, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
});
if (!response.ok) {
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast(
'toast.api.restoreSuccess',
{ type: this.apiConfig.config.displayName },
'success',
`Restored ${this.apiConfig.config.displayName}`
);
return true;
}
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
} catch (error) {
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
showToast(
'toast.api.restoreFailed',
{ type: this.apiConfig.config.singularName, message: error.message },
'error',
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
);
return false;
} finally {
state.loadingManager.hide();
}
}
async renameModelFile(filePath, newFileName) { async renameModelFile(filePath, newFileName) {
try { try {
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
_buildQueryParams(baseParams, pageState) { _buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams); const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';
if (pageState.activeFolder !== null) { if (!isExcludedView && pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder); params.append('folder', pageState.activeFolder);
} }
if (pageState.showFavoritesOnly) { if (!isExcludedView && pageState.showFavoritesOnly) {
params.append('favorites_only', 'true'); params.append('favorites_only', 'true');
} }
if (pageState.showUpdateAvailableOnly) { if (!isExcludedView && pageState.showUpdateAvailableOnly) {
params.append('update_available_only', 'true'); params.append('update_available_only', 'true');
} }
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter); params.append('first_letter', pageState.activeLetterFilter);
} }
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
if (pageState.filters) { if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
if (state === 'include') { if (state === 'include') {
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
} }
} }
this._addModelSpecificParams(params, pageState); if (!isExcludedView) {
this._addModelSpecificParams(params, pageState);
}
return params; return params;
} }

View File

@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
showMenu(x, y, card) { showMenu(x, y, card) {
super.showMenu(x, y, card); super.showMenu(x, y, card);
this.updateExcludeMenuItem();
// Update the "Move to other root" label based on current model type // Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]'); const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
} }
} }

View File

@@ -19,6 +19,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
return getModelApiClient().saveModelMetadata(filePath, data); return getModelApiClient().saveModelMetadata(filePath, data);
} }
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action) { handleMenuAction(action) {
// First try to handle with common actions // First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
} }
} }
} }

View File

@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]'); const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]'); const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
if (isRecipesPage) { if (isRecipesPage) {
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
licenseRefreshItem?.classList.add('hidden'); licenseRefreshItem?.classList.add('hidden');
downloadExamplesItem?.classList.add('hidden'); downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden'); repairRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
licenseRefreshItem?.classList.remove('hidden'); licenseRefreshItem?.classList.remove('hidden');
downloadExamplesItem?.classList.remove('hidden'); downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden'); repairRecipesItem?.classList.add('hidden');
} }
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to repair recipes:', error); console.error('Failed to repair recipes:', error);
}); });
break; break;
case 'manage-excluded-models':
this.manageExcludedModels();
break;
default: default:
console.warn(`Unhandled global context menu action: ${action}`); console.warn(`Unhandled global context menu action: ${action}`);
break; break;
} }
} }
manageExcludedModels() {
window.pageControls?.enterExcludedView?.().catch((error) => {
console.error('Failed to open excluded models view:', error);
});
}
async downloadExampleImages(menuItem) { async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path; const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) { if (!downloadPath) {

View File

@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
return getModelApiClient().saveModelMetadata(filePath, data); return getModelApiClient().saveModelMetadata(filePath, data);
} }
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action, menuItem) { handleMenuAction(action, menuItem) {
// First try to handle with common actions // First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
} }
} }

View File

@@ -10,6 +10,39 @@ 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 = {
isExcludedView() {
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
},
updateExcludeMenuItem() {
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
if (!excludeItem) {
return;
}
const isExcludedView = this.isExcludedView();
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
excludeItem.innerHTML = isExcludedView
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
},
async restoreExcludedModel(filePath) {
const restored = await getModelApiClient().unexcludeModel(filePath);
if (!restored) {
return;
}
if (window.pageControls?.exitExcludedView) {
await window.pageControls.exitExcludedView();
} else {
const resetFn = this.resetAndReload || resetAndReload;
if (typeof resetFn === 'function') {
await resetFn(true);
}
}
},
// NSFW Selector methods // NSFW Selector methods
initNSFWSelector() { initNSFWSelector() {
if (this._nsfwSelectorInitialized) { if (this._nsfwSelectorInitialized) {

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';
@@ -39,7 +39,11 @@ export class PageControls {
// Initialize favorites filter button state // Initialize favorites filter button state
this.initFavoritesFilter(); this.initFavoritesFilter();
this.initExcludedViewControls();
this.syncExcludedViewState();
console.log(`PageControls initialized for ${pageType} page`); console.log(`PageControls initialized for ${pageType} page`);
window.pageControls = this;
} }
/** /**
@@ -56,6 +60,19 @@ export class PageControls {
// Load sort preference // Load sort preference
this.loadSortPreference(); this.loadSortPreference();
if (!this.pageState.viewMode) {
this.pageState.viewMode = 'active';
}
if (!this.pageState.excludedViewState) {
this.pageState.excludedViewState = {
sortBy: 'name:asc',
search: '',
};
}
if (!this.pageState.filters?.search) {
this.pageState.filters.search = '';
}
} }
/** /**
@@ -117,6 +134,15 @@ export class PageControls {
this.initPageSpecificListeners(); this.initPageSpecificListeners();
} }
initExcludedViewControls() {
const backButton = document.getElementById('excludedViewBackBtn');
if (backButton) {
backButton.addEventListener('click', async () => {
await this.exitExcludedView();
});
}
}
/** /**
* Initialize dropdown functionality * Initialize dropdown functionality
*/ */
@@ -334,6 +360,13 @@ export class PageControls {
* @param {string} sortValue - The sort value to save * @param {string} sortValue - The sort value to save
*/ */
saveSortPreference(sortValue) { saveSortPreference(sortValue) {
if (this.pageState.viewMode === 'excluded') {
this.pageState.excludedViewState = {
...(this.pageState.excludedViewState || {}),
sortBy: sortValue,
};
return;
}
setStorageItem(`${this.pageType}_sort`, sortValue); setStorageItem(`${this.pageType}_sort`, sortValue);
} }
@@ -354,17 +387,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');
}
} }
/** /**
@@ -483,6 +506,8 @@ export class PageControls {
// Update app state // Update app state
this.pageState.showFavoritesOnly = showFavoritesOnly; this.pageState.showFavoritesOnly = showFavoritesOnly;
} }
this.updateActionButtonStates();
} }
/** /**
@@ -499,12 +524,17 @@ export class PageControls {
if (updateFilterBtn) { if (updateFilterBtn) {
updateFilterBtn.classList.toggle('active', showUpdatesOnly); updateFilterBtn.classList.toggle('active', showUpdatesOnly);
} }
this.updateActionButtonStates();
} }
/** /**
* Toggle favorites-only filter and reload models * Toggle favorites-only filter and reload models
*/ */
async toggleFavoritesOnly() { async toggleFavoritesOnly() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
// Toggle the filter state in storage // Toggle the filter state in storage
@@ -531,6 +561,9 @@ export class PageControls {
* Toggle update-available-only filter and reload models * Toggle update-available-only filter and reload models
*/ */
async toggleUpdateAvailableOnly() { async toggleUpdateAvailableOnly() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const updateFilterBtn = document.getElementById('updateFilterBtn'); const updateFilterBtn = document.getElementById('updateFilterBtn');
const storageKey = `show_update_available_only_${this.pageType}`; const storageKey = `show_update_available_only_${this.pageType}`;
const newState = !this.pageState.showUpdateAvailableOnly; const newState = !this.pageState.showUpdateAvailableOnly;
@@ -546,6 +579,234 @@ export class PageControls {
await this.resetAndReload(true); await this.resetAndReload(true);
} }
cloneFilters(filters = this.pageState.filters) {
return JSON.parse(JSON.stringify(filters || {}));
}
buildExcludedFilters(search = '') {
return {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search,
tagLogic: 'any',
};
}
applyFilterState(filters) {
this.pageState.filters = filters;
if (window.filterManager) {
window.filterManager.filters = window.filterManager.initializeFilters(filters);
window.filterManager.updateActiveFiltersCount();
if (typeof window.filterManager.updateSelections === 'function') {
window.filterManager.updateSelections();
}
window.filterManager.closeFilterPanel();
}
}
updateActionButtonStates() {
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
}
const updateFilterBtn = document.getElementById('updateFilterBtn');
if (updateFilterBtn) {
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
}
}
syncExcludedViewState() {
const isExcludedView = this.pageState.viewMode === 'excluded';
const sortSelect = document.getElementById('sortSelect');
const searchInput = document.getElementById('searchInput');
const excludedBanner = document.getElementById('excludedViewBanner');
const filterButton = document.getElementById('filterButton');
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
const duplicatesBanner = document.getElementById('duplicatesBanner');
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
const hiddenSelectors = [
'[data-action="fetch"]',
'[data-action="download"]',
'[data-action="bulk"]',
'[data-action="find-duplicates"]',
'#favoriteFilterBtn',
'.update-filter-group',
];
const customFilterIndicator = document.getElementById('customFilterIndicator');
document.body.classList.toggle('excluded-view-active', isExcludedView);
excludedBanner?.classList.toggle('hidden', !isExcludedView);
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
if (duplicatesBanner && isExcludedView) {
duplicatesBanner.style.display = 'none';
}
hiddenSelectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((element) => {
element.classList.toggle('hidden', isExcludedView);
});
});
if (customFilterIndicator && isExcludedView) {
customFilterIndicator.classList.add('hidden');
}
if (filterButton) {
filterButton.disabled = isExcludedView;
filterButton.classList.toggle('hidden', isExcludedView);
}
const activeFiltersCount = document.getElementById('activeFiltersCount');
if (activeFiltersCount && isExcludedView) {
activeFiltersCount.style.display = 'none';
}
if (sortSelect) {
sortSelect.value = this.pageState.sortBy;
}
if (searchInput) {
searchInput.value = this.pageState.filters?.search || '';
}
this.updateActionButtonStates();
if (this.sidebarManager) {
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to update sidebar visibility:', error);
});
}
}
suspendInteractiveModes() {
const snapshot = {
bulkMode: Boolean(state.bulkMode),
duplicatesMode: Boolean(this.pageState.duplicatesMode),
};
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
window.bulkManager.toggleBulkMode();
}
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
window.modelDuplicatesManager.exitDuplicateMode();
}
return snapshot;
}
async restoreInteractiveModes(snapshot = {}) {
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
window.bulkManager.toggleBulkMode();
}
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
return;
}
const duplicatesManager = window.modelDuplicatesManager;
if (!duplicatesManager) {
return;
}
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
Array.isArray(duplicatesManager.duplicateGroups) &&
duplicatesManager.duplicateGroups.length > 0) {
duplicatesManager.enterDuplicateMode();
return;
}
if (typeof duplicatesManager.findDuplicates === 'function') {
await duplicatesManager.findDuplicates();
}
}
syncCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator');
if (!indicator) {
return;
}
if (this.pageState.viewMode === 'excluded') {
indicator.classList.add('hidden');
return;
}
if (typeof this.checkCustomFilters === 'function') {
this.checkCustomFilters();
}
}
async enterExcludedView() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const interactionSnapshot = this.suspendInteractiveModes();
this.pageState.activeViewSnapshot = {
sortBy: this.pageState.sortBy,
activeFolder: this.pageState.activeFolder,
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
showFavoritesOnly: this.pageState.showFavoritesOnly,
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
bulkMode: interactionSnapshot.bulkMode,
duplicatesMode: interactionSnapshot.duplicatesMode,
filters: this.cloneFilters(),
};
const excludedState = this.pageState.excludedViewState || {
sortBy: 'name:asc',
search: '',
};
this.pageState.viewMode = 'excluded';
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
this.pageState.currentPage = 1;
this.pageState.activeFolder = null;
this.pageState.activeLetterFilter = null;
this.pageState.showFavoritesOnly = false;
this.pageState.showUpdateAvailableOnly = false;
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
this.syncExcludedViewState();
await this.resetAndReload(false);
}
async exitExcludedView() {
if (this.pageState.viewMode !== 'excluded') {
return;
}
this.pageState.excludedViewState = {
...(this.pageState.excludedViewState || {}),
sortBy: this.pageState.sortBy,
search: this.pageState.filters?.search || '',
};
const snapshot = this.pageState.activeViewSnapshot || {};
this.pageState.viewMode = 'active';
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
this.pageState.currentPage = 1;
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
this.pageState.activeViewSnapshot = null;
this.syncExcludedViewState();
await this.resetAndReload(true);
this.syncCustomFilterIndicator();
await this.restoreInteractiveModes(snapshot);
}
/** /**
* Find duplicate models * Find duplicate models
*/ */

View File

@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
card.dataset.usage_count = String(model.usage_count); card.dataset.usage_count = String(model.usage_count);
card.dataset.notes = model.notes || ''; card.dataset.notes = model.notes || '';
card.dataset.base_model = model.base_model || 'Unknown'; card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false'; card.dataset.favorite = model.favorite ? 'true' : 'false';
const hasUpdateAvailable = Boolean(model.update_available); card.dataset.exclude = model.exclude ? 'true' : 'false';
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false'; const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false'; card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// To only show usage_count when sorting by usage. // To only show usage_count when sorting by usage.
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
if (model.skip_metadata_refresh) { if (model.skip_metadata_refresh) {
card.classList.add('skip-refresh'); card.classList.add('skip-refresh');
} }
if (model.exclude) {
card.classList.add('excluded-model');
}
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only) // Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) { if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
@@ -619,6 +623,11 @@ export function createModelCard(model, modelType) {
<i class="fas fa-ban"></i> <i class="fas fa-ban"></i>
</span> </span>
` : ''} ` : ''}
${model.exclude ? `
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
<i class="fas fa-eye-slash"></i>
</span>
` : ''}
</div> </div>
<div class="card-actions"> <div class="card-actions">
${actionIcons} ${actionIcons}

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

@@ -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,
@@ -89,7 +90,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
bulkMode: false, bulkMode: false,
selectedLoras: new Set(), selectedLoras: new Set(),
@@ -97,6 +100,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}, },
recipes: { recipes: {
@@ -146,7 +155,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false, bulkMode: false,
@@ -155,6 +166,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
@@ -177,7 +194,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
bulkMode: false, bulkMode: false,
selectedModels: new Set(), selectedModels: new Set(),
@@ -185,6 +204,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
} }
}, },

View File

@@ -13,11 +13,64 @@ export const OptimizationMode = {
THUMBNAIL: 'thumbnail', THUMBNAIL: 'thumbnail',
}; };
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([ const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
'civitai.com', 'civitai.com',
'civitai.red', '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

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

@@ -111,6 +111,9 @@
<div class="context-menu-item" data-action="cleanup-example-images-folders"> <div class="context-menu-item" data-action="cleanup-example-images-folders">
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span> <i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
</div> </div>
<div class="context-menu-item" data-action="manage-excluded-models">
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
</div>
<div class="context-menu-item" data-action="repair-recipes"> <div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> <i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div> </div>

View File

@@ -1,4 +1,16 @@
<div class="controls"> <div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<div class="excluded-view-banner__content">
<div class="excluded-view-banner__title">
<i class="fas fa-eye-slash"></i>
<span>{{ t('globalContextMenu.manageExcludedModels.label', default='Excluded Models') }}</span>
</div>
<button id="excludedViewBackBtn" class="excluded-view-banner__back">
<i class="fas fa-arrow-left"></i>
<span>{{ t('common.actions.back', default='Back') }}</span>
</button>
</div>
</div>
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div title="{{ t('loras.controls.sort.title') }}" class="control-group"> <div title="{{ t('loras.controls.sort.title') }}" class="control-group">

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

@@ -2102,6 +2102,7 @@ describe('Interaction-level regression coverage', () => {
<div class="context-menu-item" data-action="check-model-updates"></div> <div class="context-menu-item" data-action="check-model-updates"></div>
<div class="context-menu-item" data-action="fetch-missing-licenses"></div> <div class="context-menu-item" data-action="fetch-missing-licenses"></div>
<div class="context-menu-item" data-action="cleanup-example-images-folders"></div> <div class="context-menu-item" data-action="cleanup-example-images-folders"></div>
<div class="context-menu-item" data-action="manage-excluded-models"></div>
</div> </div>
`; `;
@@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => {
startProgressUpdates: vi.fn(), startProgressUpdates: vi.fn(),
updateDownloadButtonText: vi.fn(), updateDownloadButtonText: vi.fn(),
}; };
window.pageControls = {
enterExcludedView: vi.fn().mockResolvedValue(undefined),
};
global.fetch = vi.fn() global.fetch = vi.fn()
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => {
); );
expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...'); expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...');
expect(fetchMissingItem.classList.contains('disabled')).toBe(false); expect(fetchMissingItem.classList.contains('disabled')).toBe(false);
menu.showMenu(560, 600);
const excludedItem = document.querySelector('[data-action="manage-excluded-models"]');
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
}); });
}); });

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,
})); }));
@@ -84,6 +86,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
delete window.bulkManager;
delete window.modelDuplicatesManager; delete window.modelDuplicatesManager;
delete global.fetch; delete global.fetch;
vi.useRealTimers(); vi.useRealTimers();
@@ -112,6 +115,9 @@ function renderControlsDom(pageKey) {
<button class="clear-filter"></button> <button class="clear-filter"></button>
</div> </div>
<div class="controls"> <div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<button id="excludedViewBackBtn">Back</button>
</div>
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div class="control-group"> <div class="control-group">
@@ -170,6 +176,9 @@ function renderControlsDom(pageKey) {
<i class="fas fa-times-circle clear-filter"></i> <i class="fas fa-times-circle clear-filter"></i>
</div> </div>
</div> </div>
<div id="breadcrumbContainer"></div>
<div id="duplicatesBanner" style="display: none;"></div>
<div class="alphabet-bar-container"></div>
`; `;
} }
@@ -574,4 +583,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
duplicateButton.click(); duplicateButton.click();
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1); expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
}); });
it.each([
['loras', 'LorasControls'],
['checkpoints', 'CheckpointsControls'],
['embeddings', 'EmbeddingsControls'],
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const pageState = stateModule.getCurrentPageState();
pageState.filters.search = 'active-search';
pageState.showFavoritesOnly = true;
pageState.showUpdateAvailableOnly = true;
const controlsModule = await import('../../../static/js/components/controls/index.js');
const ControlsClass = controlsModule[exportName];
const controls = new ControlsClass();
await controls.enterExcludedView();
expect(pageState.viewMode).toBe('excluded');
expect(pageState.filters.search).toBe('');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
expect(document.getElementById('filterButton').disabled).toBe(true);
pageState.filters.search = 'excluded-search';
await controls.exitExcludedView();
expect(pageState.viewMode).toBe('active');
expect(pageState.filters.search).toBe('active-search');
expect(pageState.excludedViewState.search).toBe('excluded-search');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
expect(document.getElementById('filterButton').disabled).toBe(false);
});
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const pageState = stateModule.getCurrentPageState();
stateModule.state.bulkMode = true;
pageState.duplicatesMode = true;
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
const toggleBulkMode = vi.fn(() => {
stateModule.state.bulkMode = !stateModule.state.bulkMode;
});
const exitDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = false;
});
const enterDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = true;
});
window.bulkManager = { toggleBulkMode };
window.modelDuplicatesManager = {
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
exitDuplicateMode,
enterDuplicateMode,
};
const controls = new LorasControls();
const indicator = document.getElementById('customFilterIndicator');
expect(indicator.classList.contains('hidden')).toBe(false);
await controls.enterExcludedView();
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(false);
expect(pageState.duplicatesMode).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true);
await controls.exitExcludedView();
expect(indicator.classList.contains('hidden')).toBe(false);
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true);
});
}); });

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,5 +1,10 @@
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,
@@ -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', () => { 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';

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

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

@@ -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,
@@ -551,36 +557,12 @@ async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downl
] ]
async def test_get_image_info_falls_back_from_com_to_red(monkeypatch, downloader): async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, downloader):
requested_urls = [] requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs): async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url) requested_urls.append(url)
if url.startswith("https://civitai.com/"): return True, {"items": [{"id": 124950237, "name": "target"}]}
return True, {"items": []}
return True, {"items": [{"id": 124950237, "name": "fallback"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info("124950237")
assert result == {"id": 124950237, "name": "fallback"}
assert requested_urls == [
"https://civitai.com/api/v1/images?imageId=124950237&nsfw=X",
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_falls_back_from_red_to_com(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
if url.startswith("https://civitai.red/"):
return True, {"items": []}
return True, {"items": [{"id": 124950237, "name": "fallback"}]}
downloader.make_request = fake_make_request downloader.make_request = fake_make_request
@@ -590,21 +572,18 @@ async def test_get_image_info_falls_back_from_red_to_com(monkeypatch, downloader
"124950237", source_url="https://civitai.red/images/124950237" "124950237", source_url="https://civitai.red/images/124950237"
) )
assert result == {"id": 124950237, "name": "fallback"} assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [ assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X", "https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
"https://civitai.com/api/v1/images?imageId=124950237&nsfw=X",
] ]
async def test_get_image_info_falls_back_after_request_failure(monkeypatch, downloader): async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypatch, downloader):
requested_urls = [] requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs): async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url) requested_urls.append(url)
if url.startswith("https://civitai.red/"): return False, "403 forbidden"
return False, "403 forbidden"
return True, {"items": [{"id": 124950237, "name": "fallback"}]}
downloader.make_request = fake_make_request downloader.make_request = fake_make_request
@@ -614,10 +593,9 @@ async def test_get_image_info_falls_back_after_request_failure(monkeypatch, down
"124950237", source_url="https://civitai.red/images/124950237" "124950237", source_url="https://civitai.red/images/124950237"
) )
assert result == {"id": 124950237, "name": "fallback"} assert result is None
assert requested_urls == [ assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X", "https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
"https://civitai.com/api/v1/images?imageId=124950237&nsfw=X",
] ]

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.com/api/download/models/119514", True)]
assert "https://civitai.com/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

@@ -5,6 +5,7 @@ import pytest
from py.services.model_lifecycle_service import ModelLifecycleService from py.services.model_lifecycle_service import ModelLifecycleService
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata
class DummyCache: class DummyCache:
@@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error():
await service.exclude_model("") await service.exclude_model("")
@pytest.mark.asyncio
async def test_unexclude_model_restores_cache_entry(tmp_path: Path):
"""Verify unexclude_model clears exclude metadata and restores cache entry."""
model_path = tmp_path / "restored_model.safetensors"
model_path.write_bytes(b"content")
metadata_payload = {
"file_name": "restored_model",
"model_name": "restored_model",
"file_path": str(model_path),
"sha256": "abc123",
"exclude": True,
"tags": ["tag1"],
}
metadata_path = tmp_path / "restored_model.metadata.json"
metadata_path.write_text(json.dumps(metadata_payload))
class RestoreScanner:
def __init__(self):
self.model_type = "lora"
self.model_class = LoraMetadata
self._excluded_models = [str(model_path)]
self.updated = []
async def update_single_model_cache(self, old_path, new_path, metadata, recalculate_type=False):
exclude_value = metadata.get("exclude") if isinstance(metadata, dict) else metadata.exclude
self.updated.append((old_path, new_path, exclude_value, recalculate_type))
saved_metadata = []
class SavingMetadataManager:
async def save_metadata(self, path: str, metadata: dict):
saved_metadata.append((path, metadata.copy()))
await MetadataManager.save_metadata(path, metadata)
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
scanner = RestoreScanner()
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=SavingMetadataManager(),
metadata_loader=metadata_loader,
)
result = await service.unexclude_model(str(model_path))
assert result["success"] is True
assert "restored" in result["message"].lower()
assert scanner._excluded_models == []
assert saved_metadata[0][1]["exclude"] is False
assert scanner.updated == [
(str(model_path), str(model_path), False, True)
]
# ============================================================================= # =============================================================================
# Tests for bulk_delete_models functionality # Tests for bulk_delete_models functionality
# ============================================================================= # =============================================================================

View File

@@ -3,6 +3,7 @@ from py.utils.civitai_utils import (
extract_civitai_image_id, extract_civitai_image_id,
extract_civitai_model_url_parts, extract_civitai_model_url_parts,
is_supported_civitai_page_host, is_supported_civitai_page_host,
normalize_civitai_download_url,
resolve_license_info, resolve_license_info,
resolve_license_payload, resolve_license_payload,
) )
@@ -122,3 +123,24 @@ def test_extract_civitai_image_id_supports_red():
def test_extract_civitai_image_id_rejects_non_civitai_host(): def test_extract_civitai_image_id_rejects_non_civitai_host():
assert extract_civitai_image_id("https://example.com/images/126920345") is None assert extract_civitai_image_id("https://example.com/images/126920345") is None
def test_normalize_civitai_download_url_rewrites_red_to_com():
url = "https://civitai.red/api/download/models/2786889?type=Model&format=SafeTensor"
assert (
normalize_civitai_download_url(url)
== "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
)
def test_normalize_civitai_download_url_keeps_non_download_red_urls():
url = "https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
assert normalize_civitai_download_url(url) == url
def test_normalize_civitai_download_url_keeps_existing_com_urls():
url = "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
assert normalize_civitai_download_url(url) == url