mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
3 Commits
de3d0571f8
...
ceeab0c998
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceeab0c998 | ||
|
|
3b001a6cd8 | ||
|
|
95e5bc26d1 |
@@ -179,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
- Context menu for quick actions
|
||||
- Custom notes and usage tips
|
||||
- Multi-folder support
|
||||
- Configurable mature blur threshold (`PG13` / `R` / `X` / `XXX`, default `R+`)
|
||||
- Example: setting threshold to `PG13` blurs `PG13`, `R`, `X`, and `XXX` previews when blur is enabled
|
||||
- Visual progress indicators during initialization
|
||||
|
||||
---
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
||||
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
|
||||
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
|
||||
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern"
|
||||
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"clear": "Auswahl löschen",
|
||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "Basis-Modell aktualisieren",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Fehlende LoRAs herunterladen",
|
||||
"message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).",
|
||||
"previewTitle": "Zu herunterladende LoRAs:",
|
||||
"moreItems": "...und {count} weitere",
|
||||
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
||||
"downloadButton": "{count} LoRA(s) herunterladen"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Lokale Beispielbilder",
|
||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Keine Modelle ausgewählt",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Blur NSFW Content",
|
||||
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
|
||||
"showOnlySfw": "Show Only SFW Results",
|
||||
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching"
|
||||
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
|
||||
"matureBlurThreshold": "Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "PG13 and above",
|
||||
"r": "R and above (default)",
|
||||
"x": "X and above",
|
||||
"xxx": "XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Autoplay Videos on Hover",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||
"deleteAll": "Delete Selected Models",
|
||||
"downloadMissingLoras": "Download Missing LoRAs",
|
||||
"clear": "Clear Selection",
|
||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "Update Base Model",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Download Missing LoRAs",
|
||||
"message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).",
|
||||
"previewTitle": "LoRAs to download:",
|
||||
"moreItems": "...and {count} more",
|
||||
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
|
||||
"downloadButton": "Download {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Local Example Images",
|
||||
"message": "No local example images found for this model. View options:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No recipes selected",
|
||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No models selected",
|
||||
@@ -1746,4 +1766,4 @@
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Difuminar contenido NSFW",
|
||||
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
|
||||
"showOnlySfw": "Mostrar solo resultados SFW",
|
||||
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar"
|
||||
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"clear": "Limpiar selección",
|
||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "Actualizar modelo base",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Descargar LoRAs faltantes",
|
||||
"message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).",
|
||||
"previewTitle": "LoRAs para descargar:",
|
||||
"moreItems": "...y {count} más",
|
||||
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
|
||||
"downloadButton": "Descargar {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Imágenes de ejemplo locales",
|
||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No se han seleccionado recetas",
|
||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No hay modelos seleccionados",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
|
||||
"showOnlySfw": "Afficher uniquement les résultats SFW",
|
||||
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche"
|
||||
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
||||
"deleteAll": "Supprimer tous les modèles",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"clear": "Effacer la sélection",
|
||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "Mettre à jour le modèle de base",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Télécharger les LoRAs manquants",
|
||||
"message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).",
|
||||
"previewTitle": "LoRAs à télécharger :",
|
||||
"moreItems": "...et {count} de plus",
|
||||
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
|
||||
"downloadButton": "Télécharger {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Images d'exemple locales",
|
||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Aucune recette sélectionnée",
|
||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Aucun modèle sélectionné",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "טשטש תוכן NSFW",
|
||||
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
||||
"showOnlySfw": "הצג רק תוצאות SFW",
|
||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש"
|
||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||
"deleteAll": "מחק את כל המודלים",
|
||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||
"clear": "נקה בחירה",
|
||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "עדכן מודל בסיס",
|
||||
"cancel": "ביטול"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "הורדת LoRAs חסרים",
|
||||
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
|
||||
"previewTitle": "LoRAs להורדה:",
|
||||
"moreItems": "...ועוד {count}",
|
||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||
"downloadButton": "הורד {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "תמונות דוגמה מקומיות",
|
||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "לא נבחרו מודלים",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
||||
"showOnlySfw": "SFWコンテンツのみ表示",
|
||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します"
|
||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||
"clear": "選択をクリア",
|
||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "ベースモデルを更新",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "不足している LoRA をダウンロード",
|
||||
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
|
||||
"previewTitle": "ダウンロードする LoRA:",
|
||||
"moreItems": "...あと {count} 個",
|
||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||
"downloadButton": "{count} 個の LoRA をダウンロード"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "ローカル例画像",
|
||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "モデルが選択されていません",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
||||
"showOnlySfw": "SFW 결과만 표시",
|
||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다"
|
||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"clear": "선택 지우기",
|
||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "베이스 모델 업데이트",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "누락된 LoRA 다운로드",
|
||||
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
|
||||
"previewTitle": "다운로드할 LoRA:",
|
||||
"moreItems": "...그리고 {count}개 더",
|
||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||
"downloadButton": "{count}개 LoRA 다운로드"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "로컬 예시 이미지",
|
||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "선택된 모델이 없습니다",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Размывать NSFW контент",
|
||||
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
||||
"showOnlySfw": "Показывать только SFW результаты",
|
||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске"
|
||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||
"deleteAll": "Удалить все модели",
|
||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||
"clear": "Очистить выбор",
|
||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "Обновить базовую модель",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Скачать отсутствующие LoRAs",
|
||||
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
|
||||
"previewTitle": "LoRAs для скачивания:",
|
||||
"moreItems": "...и еще {count}",
|
||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||
"downloadButton": "Скачать {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Локальные примеры изображений",
|
||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Модели не выбраны",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "模糊 NSFW 内容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
||||
"showOnlySfw": "仅显示 SFW 结果",
|
||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容"
|
||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "悬停时自动播放视频",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||
"deleteAll": "删除选中模型",
|
||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||
"clear": "清除选择",
|
||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "更新基础模型",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "下载缺失的 LoRAs",
|
||||
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs(从选定配方中的 {totalCount} 个总数)。",
|
||||
"previewTitle": "要下载的 LoRAs:",
|
||||
"moreItems": "...还有 {count} 个",
|
||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||
"downloadButton": "下载 {count} 个 LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本地示例图片",
|
||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
|
||||
"batchImportNoDirectory": "请输入目录路径",
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}"
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未选中模型",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "模糊 NSFW 內容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
||||
"showOnlySfw": "僅顯示 SFW 結果",
|
||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容"
|
||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||
@@ -575,6 +583,7 @@
|
||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||
"deleteAll": "刪除全部模型",
|
||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||
"clear": "清除選取",
|
||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||
@@ -983,6 +992,14 @@
|
||||
"save": "更新基礎模型",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "下載缺失的 LoRAs",
|
||||
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取食譜中的 {totalCount} 個總數)。",
|
||||
"previewTitle": "要下載的 LoRAs:",
|
||||
"moreItems": "...還有 {count} 個",
|
||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||
"downloadButton": "下載 {count} 個 LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本機範例圖片",
|
||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||
@@ -1507,7 +1524,10 @@
|
||||
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
|
||||
"batchImportNoDirectory": "請輸入目錄路徑",
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}"
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何食譜",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未選擇模型",
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
@@ -846,9 +846,13 @@ class DownloadManager:
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
|
||||
)
|
||||
selected_image, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
|
||||
preview_url = selected_image.get("url") if selected_image else None
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
|
||||
return None
|
||||
|
||||
blur_mature_content = True
|
||||
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||
settings = getattr(self, "_settings", None)
|
||||
if settings is not None and hasattr(settings, "get"):
|
||||
try:
|
||||
blur_mature_content = bool(settings.get("blur_mature_content", True))
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings.get("mature_blur_level", "R")}
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive guard
|
||||
blur_mature_content = True
|
||||
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||
|
||||
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content)
|
||||
selected, _ = select_preview_media(
|
||||
candidates,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
if not selected:
|
||||
return None
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,9 +49,13 @@ class PreviewAssetService:
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
|
||||
)
|
||||
first_preview, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
|
||||
if not first_preview:
|
||||
@@ -216,4 +220,3 @@ class PreviewAssetService:
|
||||
if "webm" in content_type:
|
||||
return ".webm"
|
||||
return ".mp4"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
|
||||
from ..utils.tag_priorities import (
|
||||
PriorityTagEntry,
|
||||
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"optimize_example_images": True,
|
||||
"auto_download_example_images": False,
|
||||
"blur_mature_content": True,
|
||||
"mature_blur_level": "R",
|
||||
"autoplay_on_hover": False,
|
||||
"display_density": "default",
|
||||
"card_info_display": "always",
|
||||
@@ -274,6 +276,16 @@ class SettingsManager:
|
||||
self.settings["metadata_refresh_skip_paths"] = []
|
||||
inserted_defaults = True
|
||||
|
||||
had_mature_level = "mature_blur_level" in self.settings
|
||||
raw_mature_level = self.settings.get("mature_blur_level")
|
||||
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
|
||||
if normalized_mature_level != raw_mature_level:
|
||||
self.settings["mature_blur_level"] = normalized_mature_level
|
||||
if had_mature_level:
|
||||
updated_existing = True
|
||||
else:
|
||||
inserted_defaults = True
|
||||
|
||||
for key, value in defaults.items():
|
||||
if key == "priority_tags":
|
||||
continue
|
||||
@@ -608,6 +620,7 @@ class SettingsManager:
|
||||
'optimizeExampleImages': 'optimize_example_images',
|
||||
'autoDownloadExampleImages': 'auto_download_example_images',
|
||||
'blurMatureContent': 'blur_mature_content',
|
||||
'matureBlurLevel': 'mature_blur_level',
|
||||
'autoplayOnHover': 'autoplay_on_hover',
|
||||
'displayDensity': 'display_density',
|
||||
'cardInfoDisplay': 'card_info_display',
|
||||
@@ -860,6 +873,13 @@ class SettingsManager:
|
||||
|
||||
return normalized
|
||||
|
||||
def normalize_mature_blur_level(self, value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().upper()
|
||||
if normalized in VALID_MATURE_BLUR_LEVELS:
|
||||
return normalized
|
||||
return "R"
|
||||
|
||||
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
@@ -1012,6 +1032,8 @@ class SettingsManager:
|
||||
value = self.normalize_auto_organize_exclusions(value)
|
||||
elif key == "metadata_refresh_skip_paths":
|
||||
value = self.normalize_metadata_refresh_skip_paths(value)
|
||||
elif key == "mature_blur_level":
|
||||
value = self.normalize_mature_blur_level(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Optional, Sequence, Tuple
|
||||
from typing import Any, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from .constants import NSFW_LEVELS
|
||||
|
||||
PreviewMedia = Mapping[str, object]
|
||||
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
|
||||
|
||||
|
||||
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
|
||||
"""Resolve the configured mature blur threshold from settings.
|
||||
|
||||
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
|
||||
missing value falls back to ``R``.
|
||||
"""
|
||||
|
||||
if not isinstance(settings, Mapping):
|
||||
return NSFW_LEVELS.get("R", 4)
|
||||
|
||||
raw_level = settings.get("mature_blur_level", "R")
|
||||
normalized = str(raw_level).strip().upper()
|
||||
if normalized not in VALID_MATURE_BLUR_LEVELS:
|
||||
normalized = "R"
|
||||
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
|
||||
|
||||
|
||||
def select_preview_media(
|
||||
images: Sequence[Mapping[str, object]] | None,
|
||||
*,
|
||||
blur_mature_content: bool,
|
||||
mature_threshold: int | None = None,
|
||||
) -> Tuple[Optional[PreviewMedia], int]:
|
||||
"""Select the most appropriate preview media entry.
|
||||
|
||||
When ``blur_mature_content`` is enabled we first try to return the first media
|
||||
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
|
||||
available we return the media entry with the lowest NSFW level. When the
|
||||
setting is disabled we simply return the first entry.
|
||||
item with an ``nsfwLevel`` lower than the configured mature threshold
|
||||
(defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
|
||||
the media entry with the lowest NSFW level. When the setting is disabled we
|
||||
simply return the first entry.
|
||||
"""
|
||||
|
||||
if not images:
|
||||
@@ -45,7 +65,9 @@ def select_preview_media(
|
||||
if not blur_mature_content:
|
||||
return selected, selected_level
|
||||
|
||||
safe_threshold = NSFW_LEVELS.get("R", 4)
|
||||
safe_threshold = (
|
||||
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
|
||||
)
|
||||
for candidate in candidates:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < safe_threshold:
|
||||
@@ -60,4 +82,4 @@ def select_preview_media(
|
||||
return selected, selected_level
|
||||
|
||||
|
||||
__all__ = ["select_preview_media"]
|
||||
__all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]
|
||||
|
||||
@@ -151,7 +151,8 @@ body.modal-open {
|
||||
[data-theme="dark"] .changelog-section,
|
||||
[data-theme="dark"] .update-info,
|
||||
[data-theme="dark"] .info-item,
|
||||
[data-theme="dark"] .path-preview {
|
||||
[data-theme="dark"] .path-preview,
|
||||
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
@@ -349,3 +350,87 @@ button:disabled,
|
||||
margin-top: var(--space-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Bulk Download Missing LoRAs Modal */
|
||||
#bulkDownloadMissingLorasModal .modal-body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .preview-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-1) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .lora-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .lora-version {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-note i {
|
||||
color: var(--lora-accent);
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
export class BulkContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||
|
||||
if (sendToWorkflowAppendItem) {
|
||||
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (setContentRatingItem) {
|
||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||
}
|
||||
if (downloadMissingLorasItem) {
|
||||
// Only show for recipes page
|
||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||
@@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
case 'download-missing-loras':
|
||||
this.handleDownloadMissingLoras();
|
||||
break;
|
||||
case 'clear':
|
||||
bulkManager.clearSelection();
|
||||
break;
|
||||
@@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
console.warn(`Unknown bulk action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle downloading missing LoRAs for selected recipes
|
||||
*/
|
||||
async handleDownloadMissingLoras() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selected recipes from the virtual scroller
|
||||
const selectedRecipes = [];
|
||||
state.selectedModels.forEach(filePath => {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`);
|
||||
if (card && card.recipeData) {
|
||||
selectedRecipes.push(card.recipeData);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedRecipes.length === 0) {
|
||||
// Try to get recipes from virtual scroller state
|
||||
const items = state.virtualScroller?.items || [];
|
||||
items.forEach(recipe => {
|
||||
if (recipe.file_path && state.selectedModels.has(recipe.file_path)) {
|
||||
selectedRecipes.push(recipe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedRecipes.length === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
@@ -74,7 +74,8 @@ class RecipeCard {
|
||||
|
||||
// NSFW blur logic - similar to LoraCard
|
||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
|
||||
@@ -1299,7 +1299,6 @@ class RecipeModal {
|
||||
|
||||
// New method to navigate to the LoRAs page
|
||||
navigateToLorasPage(specificLoraIndex = null) {
|
||||
debugger;
|
||||
// Close the current modal
|
||||
modalManager.closeModal('recipeModal');
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.nsfwLevel = nsfwLevel;
|
||||
|
||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
|
||||
|
||||
/**
|
||||
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
|
||||
}
|
||||
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
|
||||
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
|
||||
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { modalManager } from '../../../managers/ModalManager.js';
|
||||
import { translate } from '../../../utils/i18nHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import {
|
||||
initLazyLoading,
|
||||
initNsfwBlurHandlers,
|
||||
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
|
||||
// Check if media should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
|
||||
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
/**
|
||||
* Manager for downloading missing LoRAs for selected recipes in bulk
|
||||
*/
|
||||
export class BulkMissingLoraDownloadManager {
|
||||
constructor() {
|
||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
this.pendingLoras = [];
|
||||
this.pendingRecipes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect missing LoRAs from selected recipes with deduplication
|
||||
* @param {Array} selectedRecipes - Array of selected recipe objects
|
||||
* @returns {Object} - Object containing unique missing LoRAs and statistics
|
||||
*/
|
||||
collectMissingLoras(selectedRecipes) {
|
||||
const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object
|
||||
const missingLorasByRecipe = new Map();
|
||||
let totalMissingCount = 0;
|
||||
|
||||
selectedRecipes.forEach(recipe => {
|
||||
const missingLoras = [];
|
||||
|
||||
if (recipe.loras && Array.isArray(recipe.loras)) {
|
||||
recipe.loras.forEach(lora => {
|
||||
// Only include LoRAs not in library and not deleted
|
||||
if (!lora.inLibrary && !lora.isDeleted) {
|
||||
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
|
||||
|
||||
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
|
||||
// Store the LoRA info
|
||||
uniqueLoras.set(uniqueKey, {
|
||||
...lora,
|
||||
modelId: lora.modelId || lora.model_id,
|
||||
id: lora.id || lora.modelVersionId,
|
||||
});
|
||||
}
|
||||
|
||||
missingLoras.push(lora);
|
||||
totalMissingCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (missingLoras.length > 0) {
|
||||
missingLorasByRecipe.set(recipe.id || recipe.file_path, {
|
||||
recipe,
|
||||
missingLoras
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
uniqueLoras: Array.from(uniqueLoras.values()),
|
||||
uniqueCount: uniqueLoras.size,
|
||||
totalMissingCount,
|
||||
missingLorasByRecipe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation modal for downloading missing LoRAs
|
||||
* @param {Object} stats - Statistics about missing LoRAs
|
||||
* @returns {Promise<boolean>} - Whether user confirmed
|
||||
*/
|
||||
async showConfirmationModal(stats) {
|
||||
const { uniqueCount, totalMissingCount, uniqueLoras } = stats;
|
||||
|
||||
if (uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLoras', {}, 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store pending data for confirmation
|
||||
this.pendingLoras = uniqueLoras;
|
||||
|
||||
// Update modal content
|
||||
const messageEl = document.getElementById('bulkDownloadMissingLorasMessage');
|
||||
const listEl = document.getElementById('bulkDownloadMissingLorasList');
|
||||
const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn');
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', {
|
||||
uniqueCount,
|
||||
totalCount: totalMissingCount
|
||||
}, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`);
|
||||
}
|
||||
|
||||
if (listEl) {
|
||||
listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => `
|
||||
<li>
|
||||
<span class="lora-name">${lora.name || lora.file_name || 'Unknown'}</span>
|
||||
${lora.version ? `<span class="lora-version">${lora.version}</span>` : ''}
|
||||
</li>
|
||||
`).join('') +
|
||||
(uniqueLoras.length > 10 ? `
|
||||
<li class="more-items">${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}</li>
|
||||
` : '');
|
||||
}
|
||||
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = `
|
||||
<i class="fas fa-download"></i>
|
||||
${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)}
|
||||
`;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
modalManager.showModal('bulkDownloadMissingLorasModal');
|
||||
|
||||
// Return a promise that will be resolved when user confirms or cancels
|
||||
return new Promise((resolve) => {
|
||||
this.confirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user confirms download in modal
|
||||
*/
|
||||
async confirmDownload() {
|
||||
modalManager.closeModal('bulkDownloadMissingLorasModal');
|
||||
|
||||
if (this.confirmResolve) {
|
||||
this.confirmResolve(true);
|
||||
this.confirmResolve = null;
|
||||
}
|
||||
|
||||
// Execute download
|
||||
await this.executeDownload(this.pendingLoras);
|
||||
this.pendingLoras = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Download missing LoRAs for selected recipes
|
||||
* @param {Array} selectedRecipes - Array of selected recipe objects
|
||||
*/
|
||||
async downloadMissingLoras(selectedRecipes) {
|
||||
if (!selectedRecipes || selectedRecipes.length === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store selected recipes
|
||||
this.pendingRecipes = selectedRecipes;
|
||||
|
||||
// Collect missing LoRAs with deduplication
|
||||
const stats = this.collectMissingLoras(selectedRecipes);
|
||||
|
||||
if (stats.uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation modal
|
||||
const confirmed = await this.showConfirmationModal(stats);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the download process
|
||||
* @param {Array} lorasToDownload - Array of unique LoRAs to download
|
||||
*/
|
||||
async executeDownload(lorasToDownload) {
|
||||
const totalLoras = lorasToDownload.length;
|
||||
|
||||
// Get LoRA root directory
|
||||
const loraRoot = await this.getLoraRoot();
|
||||
if (!loraRoot) {
|
||||
showToast('toast.recipes.noLoraRootConfigured', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate batch download ID
|
||||
const batchDownloadId = Date.now().toString();
|
||||
|
||||
// Use default paths
|
||||
const useDefaultPaths = true;
|
||||
|
||||
// Set up WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
||||
|
||||
// Show download progress UI
|
||||
const loadingManager = state.loadingManager;
|
||||
const updateProgress = loadingManager.showDownloadProgress(totalLoras);
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let currentLoraProgress = 0;
|
||||
|
||||
// Set up WebSocket message handler
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Handle download ID confirmation
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process progress updates
|
||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||
currentLoraProgress = data.progress;
|
||||
|
||||
const currentLora = lorasToDownload[completedDownloads + failedDownloads];
|
||||
const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : '';
|
||||
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second
|
||||
};
|
||||
|
||||
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
|
||||
|
||||
// Update status message
|
||||
if (currentLoraProgress < 3) {
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.startingDownload',
|
||||
{ current: completedDownloads + failedDownloads + 1, total: totalLoras },
|
||||
`Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}`
|
||||
)
|
||||
);
|
||||
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for WebSocket to connect
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
// Download each LoRA sequentially
|
||||
for (let i = 0; i < lorasToDownload.length; i++) {
|
||||
const lora = lorasToDownload[i];
|
||||
|
||||
currentLoraProgress = 0;
|
||||
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.startingDownload',
|
||||
{ current: i + 1, total: totalLoras },
|
||||
`Starting download for LoRA ${i + 1}/${totalLoras}`
|
||||
)
|
||||
);
|
||||
updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown');
|
||||
|
||||
try {
|
||||
const modelId = lora.modelId || lora.model_id;
|
||||
const versionId = lora.id || lora.modelVersionId;
|
||||
|
||||
if (!modelId && !versionId) {
|
||||
console.warn(`Skipping LoRA without model/version ID:`, lora);
|
||||
failedDownloads++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await this.loraApiClient.downloadModel(
|
||||
modelId,
|
||||
versionId,
|
||||
loraRoot,
|
||||
'', // Empty relative path, use default paths
|
||||
useDefaultPaths,
|
||||
batchDownloadId
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
failedDownloads++;
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
ws.close();
|
||||
|
||||
// Hide loading UI
|
||||
loadingManager.hide();
|
||||
|
||||
// Show completion message
|
||||
if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
completed: completedDownloads,
|
||||
total: totalLoras
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
// Refresh the recipes list to update LoRA status
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.loadRecipes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA root directory from API
|
||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||
*/
|
||||
async getLoraRoot() {
|
||||
try {
|
||||
// Fetch available LoRA roots from API
|
||||
const rootsData = await this.loraApiClient.fetchModelRoots();
|
||||
|
||||
if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) {
|
||||
console.error('No LoRA roots available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to get default root from settings
|
||||
const defaultRootKey = 'default_lora_root';
|
||||
const defaultRoot = state.global?.settings?.[defaultRootKey];
|
||||
|
||||
// If default root is set and exists in available roots, use it
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
return defaultRoot;
|
||||
}
|
||||
|
||||
// Otherwise, return the first available root
|
||||
return rootsData.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting LoRA root:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager();
|
||||
|
||||
// Make available globally for HTML onclick handlers
|
||||
if (typeof window !== 'undefined') {
|
||||
window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager;
|
||||
}
|
||||
@@ -291,6 +291,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register bulkDownloadMissingLorasModal
|
||||
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
|
||||
if (bulkDownloadMissingLorasModal) {
|
||||
this.registerModal('bulkDownloadMissingLorasModal', {
|
||||
element: bulkDownloadMissingLorasModal,
|
||||
onClose: () => {
|
||||
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
@@ -137,11 +139,25 @@ export class SettingsManager {
|
||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||
);
|
||||
|
||||
merged.mature_blur_level = this.normalizeMatureBlurLevel(
|
||||
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
|
||||
);
|
||||
|
||||
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
normalizeMatureBlurLevel(value) {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return 'R';
|
||||
}
|
||||
|
||||
normalizePatternList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const sanitized = value
|
||||
@@ -682,6 +698,13 @@ export class SettingsManager {
|
||||
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
|
||||
}
|
||||
|
||||
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
|
||||
if (matureBlurLevelSelect) {
|
||||
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
|
||||
state.global.settings.mature_blur_level
|
||||
);
|
||||
}
|
||||
|
||||
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
||||
if (usePortableCheckbox) {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
@@ -1811,7 +1834,9 @@ export class SettingsManager {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const value = element.value;
|
||||
const value = settingKey === 'mature_blur_level'
|
||||
? this.normalizeMatureBlurLevel(element.value)
|
||||
: element.value;
|
||||
|
||||
try {
|
||||
// Update frontend state with mapped keys
|
||||
@@ -1834,7 +1859,12 @@ export class SettingsManager {
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') {
|
||||
if (
|
||||
settingKey === 'model_name_display'
|
||||
|| settingKey === 'model_card_footer_action'
|
||||
|| settingKey === 'update_flag_strategy'
|
||||
|| settingKey === 'mature_blur_level'
|
||||
) {
|
||||
this.reloadContent();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
optimize_example_images: true,
|
||||
auto_download_example_images: false,
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R',
|
||||
autoplay_on_hover: false,
|
||||
display_density: 'default',
|
||||
card_info_display: 'always',
|
||||
|
||||
@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
|
||||
BLOCKED: 32
|
||||
};
|
||||
|
||||
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
|
||||
|
||||
export function getMatureBlurThreshold(settings = {}) {
|
||||
const rawValue = settings?.mature_blur_level;
|
||||
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
|
||||
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
|
||||
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
|
||||
}
|
||||
|
||||
// Node type constants
|
||||
export const NODE_TYPES = {
|
||||
LORA_LOADER: 1,
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="download-missing-loras">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="move-all">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -80,4 +80,32 @@
|
||||
<button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
|
||||
<div id="bulkDownloadMissingLorasModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ t('modals.bulkDownloadMissingLoras.title') }}</h2>
|
||||
<span class="close" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="confirmation-message" id="bulkDownloadMissingLorasMessage"></p>
|
||||
<div class="bulk-download-loras-preview" id="bulkDownloadMissingLorasPreview">
|
||||
<p class="preview-title">{{ t('modals.bulkDownloadMissingLoras.previewTitle') }}</p>
|
||||
<ul class="bulk-download-loras-list" id="bulkDownloadMissingLorasList"></ul>
|
||||
</div>
|
||||
<p class="confirmation-note">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{{ t('modals.bulkDownloadMissingLoras.note') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" id="bulkDownloadMissingLorasConfirmBtn" onclick="bulkMissingLoraDownloadManager.confirmDownload()">
|
||||
<i class="fas fa-download"></i>
|
||||
{{ t('modals.bulkDownloadMissingLoras.downloadButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,6 +281,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="matureBlurLevel">
|
||||
{{ t('settings.contentFiltering.matureBlurThreshold') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="matureBlurLevel"
|
||||
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
|
||||
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
|
||||
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
|
||||
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
|
||||
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Settings -->
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('state module', () => {
|
||||
expect(defaultSettings).toMatchObject({
|
||||
civitai_api_key: '',
|
||||
language: 'en',
|
||||
blur_mature_content: true
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R'
|
||||
});
|
||||
|
||||
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
|
||||
|
||||
18
tests/frontend/utils/constants.matureBlurThreshold.test.js
Normal file
18
tests/frontend/utils/constants.matureBlurThreshold.test.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../static/js/utils/constants.js';
|
||||
|
||||
describe('getMatureBlurThreshold', () => {
|
||||
it('returns configured PG13 threshold', () => {
|
||||
expect(getMatureBlurThreshold({ mature_blur_level: 'PG13' })).toBe(NSFW_LEVELS.PG13);
|
||||
});
|
||||
|
||||
it('normalizes lowercase values', () => {
|
||||
expect(getMatureBlurThreshold({ mature_blur_level: 'x' })).toBe(NSFW_LEVELS.X);
|
||||
});
|
||||
|
||||
it('falls back to R when value is invalid or missing', () => {
|
||||
expect(getMatureBlurThreshold({ mature_blur_level: 'invalid' })).toBe(NSFW_LEVELS.R);
|
||||
expect(getMatureBlurThreshold({})).toBe(NSFW_LEVELS.R);
|
||||
});
|
||||
});
|
||||
@@ -281,8 +281,6 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
hash_calculator = AsyncMock(return_value="hash-single")
|
||||
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
@@ -299,10 +297,10 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
|
||||
assert not zip_path.exists()
|
||||
extracted = save_dir / "model.safetensors"
|
||||
assert extracted.exists()
|
||||
assert hash_calculator.await_args.args[0] == str(extracted)
|
||||
saved_call = MetadataManager.save_metadata.await_args
|
||||
assert saved_call.args[0] == str(extracted)
|
||||
assert saved_call.args[1].sha256 == "hash-single"
|
||||
# SHA256 comes from metadata (API value), not recalculated
|
||||
assert saved_call.args[1].sha256 == "sha256"
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||
|
||||
|
||||
@@ -351,8 +349,6 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
hash_calculator = AsyncMock(side_effect=["hash-one", "hash-two"])
|
||||
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
@@ -372,15 +368,15 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
|
||||
assert extracted_one.exists()
|
||||
assert extracted_two.exists()
|
||||
|
||||
assert hash_calculator.await_count == 2
|
||||
assert MetadataManager.save_metadata.await_count == 2
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 2
|
||||
|
||||
metadata_calls = MetadataManager.save_metadata.await_args_list
|
||||
assert metadata_calls[0].args[0] == str(extracted_one)
|
||||
assert metadata_calls[0].args[1].sha256 == "hash-one"
|
||||
# SHA256 comes from metadata (API value), not recalculated
|
||||
assert metadata_calls[0].args[1].sha256 == "sha256"
|
||||
assert metadata_calls[1].args[0] == str(extracted_two)
|
||||
assert metadata_calls[1].args[1].sha256 == "hash-two"
|
||||
assert metadata_calls[1].args[1].sha256 == "sha256"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -427,8 +423,6 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
|
||||
ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
hash_calculator = AsyncMock(return_value="hash-pt")
|
||||
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
@@ -445,10 +439,10 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
|
||||
assert not zip_path.exists()
|
||||
extracted = save_dir / "embedding.pt"
|
||||
assert extracted.exists()
|
||||
assert hash_calculator.await_args.args[0] == str(extracted)
|
||||
saved_call = MetadataManager.save_metadata.await_args
|
||||
assert saved_call.args[0] == str(extracted)
|
||||
assert saved_call.args[1].sha256 == "hash-pt"
|
||||
# SHA256 comes from metadata (API value), not recalculated
|
||||
assert saved_call.args[1].sha256 == "sha256"
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||
|
||||
|
||||
|
||||
@@ -9,95 +9,99 @@ from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import aiohttp
|
||||
|
||||
from py.services.downloader import Downloader, DownloadStalledError, DownloadRestartRequested
|
||||
from py.services.downloader import (
|
||||
Downloader,
|
||||
DownloadStalledError,
|
||||
DownloadRestartRequested,
|
||||
)
|
||||
|
||||
|
||||
class TestDownloadStreamControl:
|
||||
"""Test DownloadStreamControl functionality."""
|
||||
|
||||
|
||||
def test_pause_clears_event(self):
|
||||
"""Verify pause() clears the event."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
|
||||
control = DownloadStreamControl()
|
||||
assert control.is_set() is True # Initially set
|
||||
|
||||
|
||||
control.pause()
|
||||
assert control.is_set() is False
|
||||
assert control.is_paused() is True
|
||||
|
||||
|
||||
def test_resume_sets_event(self):
|
||||
"""Verify resume() sets the event."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
|
||||
control = DownloadStreamControl()
|
||||
control.pause()
|
||||
assert control.is_set() is False
|
||||
|
||||
|
||||
control.resume()
|
||||
assert control.is_set() is True
|
||||
assert control.is_paused() is False
|
||||
|
||||
|
||||
def test_reconnect_request_tracking(self):
|
||||
"""Verify reconnect request tracking works correctly."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
|
||||
control = DownloadStreamControl()
|
||||
assert control.has_reconnect_request() is False
|
||||
|
||||
|
||||
control.request_reconnect()
|
||||
assert control.has_reconnect_request() is True
|
||||
|
||||
|
||||
# Consume the request
|
||||
consumed = control.consume_reconnect_request()
|
||||
assert consumed is True
|
||||
assert control.has_reconnect_request() is False
|
||||
|
||||
|
||||
def test_mark_progress_clears_reconnect(self):
|
||||
"""Verify mark_progress clears reconnect requests."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
|
||||
control = DownloadStreamControl()
|
||||
control.request_reconnect()
|
||||
assert control.has_reconnect_request() is True
|
||||
|
||||
|
||||
control.mark_progress()
|
||||
assert control.has_reconnect_request() is False
|
||||
assert control.last_progress_timestamp is not None
|
||||
|
||||
|
||||
def test_time_since_last_progress(self):
|
||||
"""Verify time_since_last_progress calculation."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
import time
|
||||
|
||||
|
||||
control = DownloadStreamControl()
|
||||
|
||||
|
||||
# Initially None
|
||||
assert control.time_since_last_progress() is None
|
||||
|
||||
|
||||
# After marking progress
|
||||
now = time.time()
|
||||
control.mark_progress(timestamp=now)
|
||||
|
||||
|
||||
elapsed = control.time_since_last_progress(now=now + 5)
|
||||
assert elapsed == 5.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resume(self):
|
||||
"""Verify wait() blocks until resumed."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
import asyncio
|
||||
|
||||
|
||||
control = DownloadStreamControl()
|
||||
control.pause()
|
||||
|
||||
|
||||
# Start a task that will wait
|
||||
wait_task = asyncio.create_task(control.wait())
|
||||
|
||||
|
||||
# Give it a moment to start waiting
|
||||
await asyncio.sleep(0.01)
|
||||
assert not wait_task.done()
|
||||
|
||||
|
||||
# Resume should unblock
|
||||
control.resume()
|
||||
await asyncio.wait_for(wait_task, timeout=0.1)
|
||||
@@ -105,75 +109,76 @@ class TestDownloadStreamControl:
|
||||
|
||||
class TestDownloaderConfiguration:
|
||||
"""Test downloader configuration and initialization."""
|
||||
|
||||
|
||||
def test_downloader_singleton_pattern(self):
|
||||
"""Verify Downloader follows singleton pattern."""
|
||||
# Reset first
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
# Both should return same instance
|
||||
async def get_instances():
|
||||
instance1 = await Downloader.get_instance()
|
||||
instance2 = await Downloader.get_instance()
|
||||
return instance1, instance2
|
||||
|
||||
|
||||
import asyncio
|
||||
|
||||
instance1, instance2 = asyncio.run(get_instances())
|
||||
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_default_configuration_values(self):
|
||||
"""Verify default configuration values are set correctly."""
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
downloader = Downloader()
|
||||
|
||||
assert downloader.chunk_size == 4 * 1024 * 1024 # 4MB
|
||||
|
||||
assert downloader.chunk_size == 16 * 1024 * 1024 # 16MB
|
||||
assert downloader.max_retries == 5
|
||||
assert downloader.base_delay == 2.0
|
||||
assert downloader.session_timeout == 300
|
||||
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_default_headers_include_user_agent(self):
|
||||
"""Verify default headers include User-Agent."""
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
downloader = Downloader()
|
||||
|
||||
assert 'User-Agent' in downloader.default_headers
|
||||
assert 'ComfyUI-LoRA-Manager' in downloader.default_headers['User-Agent']
|
||||
assert downloader.default_headers['Accept-Encoding'] == 'identity'
|
||||
|
||||
|
||||
assert "User-Agent" in downloader.default_headers
|
||||
assert "ComfyUI-LoRA-Manager" in downloader.default_headers["User-Agent"]
|
||||
assert downloader.default_headers["Accept-Encoding"] == "identity"
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_stall_timeout_resolution(self):
|
||||
"""Verify stall timeout is resolved correctly."""
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
downloader = Downloader()
|
||||
timeout = downloader._resolve_stall_timeout()
|
||||
|
||||
|
||||
# Should be at least 30 seconds
|
||||
assert timeout >= 30.0
|
||||
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
class TestDownloadProgress:
|
||||
"""Test DownloadProgress dataclass."""
|
||||
|
||||
|
||||
def test_download_progress_creation(self):
|
||||
"""Verify DownloadProgress can be created with correct values."""
|
||||
from py.services.downloader import DownloadProgress
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
progress = DownloadProgress(
|
||||
percent_complete=50.0,
|
||||
bytes_downloaded=500,
|
||||
@@ -181,7 +186,7 @@ class TestDownloadProgress:
|
||||
bytes_per_second=100.5,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
|
||||
|
||||
assert progress.percent_complete == 50.0
|
||||
assert progress.bytes_downloaded == 500
|
||||
assert progress.total_bytes == 1000
|
||||
@@ -191,121 +196,130 @@ class TestDownloadProgress:
|
||||
|
||||
class TestDownloaderExceptions:
|
||||
"""Test custom exception classes."""
|
||||
|
||||
|
||||
def test_download_stalled_error(self):
|
||||
"""Verify DownloadStalledError can be raised and caught."""
|
||||
with pytest.raises(DownloadStalledError) as exc_info:
|
||||
raise DownloadStalledError("Download stalled for 120 seconds")
|
||||
|
||||
|
||||
assert "stalled" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_download_restart_requested_error(self):
|
||||
"""Verify DownloadRestartRequested can be raised and caught."""
|
||||
with pytest.raises(DownloadRestartRequested) as exc_info:
|
||||
raise DownloadRestartRequested("Reconnect requested after resume")
|
||||
|
||||
assert "reconnect" in str(exc_info.value).lower() or "restart" in str(exc_info.value).lower()
|
||||
|
||||
assert (
|
||||
"reconnect" in str(exc_info.value).lower()
|
||||
or "restart" in str(exc_info.value).lower()
|
||||
)
|
||||
|
||||
|
||||
class TestDownloaderAuthHeaders:
|
||||
"""Test authentication header generation."""
|
||||
|
||||
|
||||
def test_get_auth_headers_without_auth(self):
|
||||
"""Verify auth headers without authentication."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
|
||||
headers = downloader._get_auth_headers(use_auth=False)
|
||||
|
||||
assert 'User-Agent' in headers
|
||||
assert 'Authorization' not in headers
|
||||
|
||||
|
||||
assert "User-Agent" in headers
|
||||
assert "Authorization" not in headers
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_get_auth_headers_with_auth_no_api_key(self, monkeypatch):
|
||||
"""Verify auth headers with auth but no API key configured."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
|
||||
# Mock settings manager to return no API key
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.get.return_value = None
|
||||
|
||||
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings):
|
||||
|
||||
with patch(
|
||||
"py.services.downloader.get_settings_manager", return_value=mock_settings
|
||||
):
|
||||
headers = downloader._get_auth_headers(use_auth=True)
|
||||
|
||||
|
||||
# Should still have User-Agent but no Authorization
|
||||
assert 'User-Agent' in headers
|
||||
assert 'Authorization' not in headers
|
||||
|
||||
assert "User-Agent" in headers
|
||||
assert "Authorization" not in headers
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_get_auth_headers_with_auth_and_api_key(self, monkeypatch):
|
||||
"""Verify auth headers with auth and API key configured."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
|
||||
# Mock settings manager to return API key
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.get.return_value = "test-api-key-12345"
|
||||
|
||||
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings):
|
||||
|
||||
with patch(
|
||||
"py.services.downloader.get_settings_manager", return_value=mock_settings
|
||||
):
|
||||
headers = downloader._get_auth_headers(use_auth=True)
|
||||
|
||||
|
||||
# Should have both User-Agent and Authorization
|
||||
assert 'User-Agent' in headers
|
||||
assert 'Authorization' in headers
|
||||
assert 'test-api-key-12345' in headers['Authorization']
|
||||
assert headers['Content-Type'] == 'application/json'
|
||||
|
||||
assert "User-Agent" in headers
|
||||
assert "Authorization" in headers
|
||||
assert "test-api-key-12345" in headers["Authorization"]
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
class TestDownloaderSessionManagement:
|
||||
"""Test session management functionality."""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_refresh_session_when_none(self):
|
||||
"""Verify session refresh is needed when session is None."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
|
||||
# Initially should need refresh
|
||||
assert downloader._should_refresh_session() is True
|
||||
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_should_not_refresh_new_session(self):
|
||||
"""Verify new session doesn't need refresh."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
|
||||
# Simulate a fresh session
|
||||
downloader._session_created_at = MagicMock()
|
||||
downloader._session = MagicMock()
|
||||
|
||||
|
||||
# Mock datetime to return current time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
current_time = datetime.now()
|
||||
downloader._session_created_at = current_time
|
||||
|
||||
|
||||
# Should not need refresh for new session
|
||||
assert downloader._should_refresh_session() is False
|
||||
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
def test_should_refresh_old_session(self):
|
||||
"""Verify old session needs refresh."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
|
||||
# Simulate an old session (older than timeout)
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1)
|
||||
downloader._session_created_at = old_time
|
||||
downloader._session = MagicMock()
|
||||
|
||||
|
||||
# Should need refresh for old session
|
||||
assert downloader._should_refresh_session() is True
|
||||
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
@@ -265,6 +265,32 @@ def test_delete_setting(manager):
|
||||
assert manager.get("example") is None
|
||||
|
||||
|
||||
def test_missing_mature_blur_level_defaults_to_r(tmp_path, monkeypatch):
|
||||
manager = _create_manager_with_settings(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
{
|
||||
"blur_mature_content": True,
|
||||
"folder_paths": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert manager.get("mature_blur_level") == "R"
|
||||
|
||||
|
||||
def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
|
||||
manager = _create_manager_with_settings(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
{
|
||||
"mature_blur_level": "unsafe",
|
||||
"folder_paths": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert manager.get("mature_blur_level") == "R"
|
||||
|
||||
|
||||
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
||||
initial = {
|
||||
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
from py.utils.preview_selection import select_preview_media
|
||||
import pytest
|
||||
|
||||
|
||||
def test_select_preview_prefers_safe_media_when_blurred():
|
||||
images = [
|
||||
{"url": "nsfw", "type": "image", "nsfwLevel": 8},
|
||||
{"url": "mid", "type": "image", "nsfwLevel": 4},
|
||||
{"url": "safe", "type": "image", "nsfwLevel": 1},
|
||||
]
|
||||
|
||||
selected, level = select_preview_media(images, blur_mature_content=True)
|
||||
|
||||
assert selected["url"] == "safe"
|
||||
assert level == 1
|
||||
|
||||
|
||||
def test_select_preview_returns_lowest_when_no_safe_media():
|
||||
images = [
|
||||
{"url": "x", "type": "image", "nsfwLevel": 16},
|
||||
{"url": "r", "type": "image", "nsfwLevel": 4},
|
||||
{"url": "xx", "type": "image", "nsfwLevel": 8},
|
||||
]
|
||||
|
||||
selected, level = select_preview_media(images, blur_mature_content=True)
|
||||
|
||||
assert selected["url"] == "r"
|
||||
assert level == 4
|
||||
from py.utils.constants import NSFW_LEVELS
|
||||
from py.utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
|
||||
def test_select_preview_returns_first_when_blur_disabled():
|
||||
@@ -37,3 +14,36 @@ def test_select_preview_returns_first_when_blur_disabled():
|
||||
|
||||
assert selected["url"] == "nsfw"
|
||||
assert level == 32
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("threshold_name", "expected_url"),
|
||||
[
|
||||
("PG13", "pg"),
|
||||
("R", "pg13"),
|
||||
("X", "r"),
|
||||
("XXX", "x"),
|
||||
],
|
||||
)
|
||||
def test_select_preview_respects_configurable_threshold(threshold_name, expected_url):
|
||||
images = [
|
||||
{"url": "xxx", "type": "image", "nsfwLevel": NSFW_LEVELS["XXX"]},
|
||||
{"url": "x", "type": "image", "nsfwLevel": NSFW_LEVELS["X"]},
|
||||
{"url": "r", "type": "image", "nsfwLevel": NSFW_LEVELS["R"]},
|
||||
{"url": "pg13", "type": "image", "nsfwLevel": NSFW_LEVELS["PG13"]},
|
||||
{"url": "pg", "type": "image", "nsfwLevel": NSFW_LEVELS["PG"]},
|
||||
]
|
||||
|
||||
selected, level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=True,
|
||||
mature_threshold=NSFW_LEVELS[threshold_name],
|
||||
)
|
||||
|
||||
assert selected["url"] == expected_url
|
||||
assert level == next(item["nsfwLevel"] for item in images if item["url"] == expected_url)
|
||||
|
||||
|
||||
def test_resolve_mature_threshold_falls_back_to_r_for_invalid_value():
|
||||
assert resolve_mature_threshold({"mature_blur_level": "invalid"}) == NSFW_LEVELS["R"]
|
||||
assert resolve_mature_threshold({}) == NSFW_LEVELS["R"]
|
||||
|
||||
Reference in New Issue
Block a user