mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -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
|
- Context menu for quick actions
|
||||||
- Custom notes and usage tips
|
- Custom notes and usage tips
|
||||||
- Multi-folder support
|
- 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
|
- Visual progress indicators during initialization
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
||||||
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
|
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
|
||||||
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||||
"deleteAll": "Alle Modelle löschen",
|
"deleteAll": "Alle Modelle löschen",
|
||||||
|
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||||
"clear": "Auswahl löschen",
|
"clear": "Auswahl löschen",
|
||||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "Basis-Modell aktualisieren",
|
"save": "Basis-Modell aktualisieren",
|
||||||
"cancel": "Abbrechen"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Lokale Beispielbilder",
|
"title": "Lokale Beispielbilder",
|
||||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"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": {
|
"models": {
|
||||||
"noModelsSelected": "Keine Modelle ausgewählt",
|
"noModelsSelected": "Keine Modelle ausgewählt",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "Blur NSFW Content",
|
"blurNsfwContent": "Blur NSFW Content",
|
||||||
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
|
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
|
||||||
"showOnlySfw": "Show Only SFW Results",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Autoplay Videos on Hover",
|
"autoplayOnHover": "Autoplay Videos on Hover",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||||
"deleteAll": "Delete Selected Models",
|
"deleteAll": "Delete Selected Models",
|
||||||
|
"downloadMissingLoras": "Download Missing LoRAs",
|
||||||
"clear": "Clear Selection",
|
"clear": "Clear Selection",
|
||||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "Update Base Model",
|
"save": "Update Base Model",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Local Example Images",
|
"title": "Local Example Images",
|
||||||
"message": "No local example images found for this model. View options:",
|
"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",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"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": {
|
"models": {
|
||||||
"noModelsSelected": "No models selected",
|
"noModelsSelected": "No models selected",
|
||||||
@@ -1746,4 +1766,4 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "Difuminar contenido NSFW",
|
"blurNsfwContent": "Difuminar contenido NSFW",
|
||||||
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
|
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
|
||||||
"showOnlySfw": "Mostrar solo resultados SFW",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||||
"deleteAll": "Eliminar todos los modelos",
|
"deleteAll": "Eliminar todos los modelos",
|
||||||
|
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||||
"clear": "Limpiar selección",
|
"clear": "Limpiar selección",
|
||||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "Actualizar modelo base",
|
"save": "Actualizar modelo base",
|
||||||
"cancel": "Cancelar"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Imágenes de ejemplo locales",
|
"title": "Imágenes de ejemplo locales",
|
||||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
"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",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"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": {
|
"models": {
|
||||||
"noModelsSelected": "No hay modelos seleccionados",
|
"noModelsSelected": "No hay modelos seleccionados",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||||
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
|
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
|
||||||
"showOnlySfw": "Afficher uniquement les résultats SFW",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||||
"resumeMetadataRefresh": "Reprendre 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",
|
"deleteAll": "Supprimer tous les modèles",
|
||||||
|
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||||
"clear": "Effacer la sélection",
|
"clear": "Effacer la sélection",
|
||||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "Mettre à jour le modèle de base",
|
"save": "Mettre à jour le modèle de base",
|
||||||
"cancel": "Annuler"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Images d'exemple locales",
|
"title": "Images d'exemple locales",
|
||||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
"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",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"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": {
|
"models": {
|
||||||
"noModelsSelected": "Aucun modèle sélectionné",
|
"noModelsSelected": "Aucun modèle sélectionné",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "טשטש תוכן NSFW",
|
"blurNsfwContent": "טשטש תוכן NSFW",
|
||||||
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
||||||
"showOnlySfw": "הצג רק תוצאות SFW",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||||
"deleteAll": "מחק את כל המודלים",
|
"deleteAll": "מחק את כל המודלים",
|
||||||
|
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||||
"clear": "נקה בחירה",
|
"clear": "נקה בחירה",
|
||||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "עדכן מודל בסיס",
|
"save": "עדכן מודל בסיס",
|
||||||
"cancel": "ביטול"
|
"cancel": "ביטול"
|
||||||
},
|
},
|
||||||
|
"bulkDownloadMissingLoras": {
|
||||||
|
"title": "הורדת LoRAs חסרים",
|
||||||
|
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
|
||||||
|
"previewTitle": "LoRAs להורדה:",
|
||||||
|
"moreItems": "...ועוד {count}",
|
||||||
|
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||||
|
"downloadButton": "הורד {count} LoRA(s)"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "תמונות דוגמה מקומיות",
|
"title": "תמונות דוגמה מקומיות",
|
||||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
|
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||||
|
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "לא נבחרו מודלים",
|
"noModelsSelected": "לא נבחרו מודלים",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||||
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
||||||
"showOnlySfw": "SFWコンテンツのみ表示",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||||
"deleteAll": "すべてのモデルを削除",
|
"deleteAll": "すべてのモデルを削除",
|
||||||
|
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||||
"clear": "選択をクリア",
|
"clear": "選択をクリア",
|
||||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "ベースモデルを更新",
|
"save": "ベースモデルを更新",
|
||||||
"cancel": "キャンセル"
|
"cancel": "キャンセル"
|
||||||
},
|
},
|
||||||
|
"bulkDownloadMissingLoras": {
|
||||||
|
"title": "不足している LoRA をダウンロード",
|
||||||
|
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
|
||||||
|
"previewTitle": "ダウンロードする LoRA:",
|
||||||
|
"moreItems": "...あと {count} 個",
|
||||||
|
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||||
|
"downloadButton": "{count} 個の LoRA をダウンロード"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "ローカル例画像",
|
"title": "ローカル例画像",
|
||||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
|
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||||
|
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "モデルが選択されていません",
|
"noModelsSelected": "モデルが選択されていません",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||||
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
||||||
"showOnlySfw": "SFW 결과만 표시",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||||
"deleteAll": "모든 모델 삭제",
|
"deleteAll": "모든 모델 삭제",
|
||||||
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
"clear": "선택 지우기",
|
"clear": "선택 지우기",
|
||||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "베이스 모델 업데이트",
|
"save": "베이스 모델 업데이트",
|
||||||
"cancel": "취소"
|
"cancel": "취소"
|
||||||
},
|
},
|
||||||
|
"bulkDownloadMissingLoras": {
|
||||||
|
"title": "누락된 LoRA 다운로드",
|
||||||
|
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
|
||||||
|
"previewTitle": "다운로드할 LoRA:",
|
||||||
|
"moreItems": "...그리고 {count}개 더",
|
||||||
|
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||||
|
"downloadButton": "{count}개 LoRA 다운로드"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "로컬 예시 이미지",
|
"title": "로컬 예시 이미지",
|
||||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
|
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||||
|
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "선택된 모델이 없습니다",
|
"noModelsSelected": "선택된 모델이 없습니다",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "Размывать NSFW контент",
|
"blurNsfwContent": "Размывать NSFW контент",
|
||||||
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
||||||
"showOnlySfw": "Показывать только SFW результаты",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||||
"deleteAll": "Удалить все модели",
|
"deleteAll": "Удалить все модели",
|
||||||
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
"clear": "Очистить выбор",
|
"clear": "Очистить выбор",
|
||||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "Обновить базовую модель",
|
"save": "Обновить базовую модель",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена"
|
||||||
},
|
},
|
||||||
|
"bulkDownloadMissingLoras": {
|
||||||
|
"title": "Скачать отсутствующие LoRAs",
|
||||||
|
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
|
||||||
|
"previewTitle": "LoRAs для скачивания:",
|
||||||
|
"moreItems": "...и еще {count}",
|
||||||
|
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||||
|
"downloadButton": "Скачать {count} LoRA(s)"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Локальные примеры изображений",
|
"title": "Локальные примеры изображений",
|
||||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||||
"batchImportNoDirectory": "Please enter a directory path",
|
"batchImportNoDirectory": "Please enter a directory path",
|
||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}"
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
|
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||||
|
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "Модели не выбраны",
|
"noModelsSelected": "Модели не выбраны",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "模糊 NSFW 内容",
|
"blurNsfwContent": "模糊 NSFW 内容",
|
||||||
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
||||||
"showOnlySfw": "仅显示 SFW 结果",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "悬停时自动播放视频",
|
"autoplayOnHover": "悬停时自动播放视频",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||||
"deleteAll": "删除选中模型",
|
"deleteAll": "删除选中模型",
|
||||||
|
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "更新基础模型",
|
"save": "更新基础模型",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
|
"bulkDownloadMissingLoras": {
|
||||||
|
"title": "下载缺失的 LoRAs",
|
||||||
|
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs(从选定配方中的 {totalCount} 个总数)。",
|
||||||
|
"previewTitle": "要下载的 LoRAs:",
|
||||||
|
"moreItems": "...还有 {count} 个",
|
||||||
|
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||||
|
"downloadButton": "下载 {count} 个 LoRA(s)"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "本地示例图片",
|
"title": "本地示例图片",
|
||||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
|
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
|
||||||
"batchImportNoDirectory": "请输入目录路径",
|
"batchImportNoDirectory": "请输入目录路径",
|
||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}"
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
|
"noRecipesSelected": "未选择任何配方",
|
||||||
|
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||||
|
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "未选中模型",
|
"noModelsSelected": "未选中模型",
|
||||||
|
|||||||
@@ -291,7 +291,15 @@
|
|||||||
"blurNsfwContent": "模糊 NSFW 內容",
|
"blurNsfwContent": "模糊 NSFW 內容",
|
||||||
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
||||||
"showOnlySfw": "僅顯示 SFW 結果",
|
"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": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||||
@@ -575,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||||
"deleteAll": "刪除全部模型",
|
"deleteAll": "刪除全部模型",
|
||||||
|
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||||
"clear": "清除選取",
|
"clear": "清除選取",
|
||||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||||
@@ -983,6 +992,14 @@
|
|||||||
"save": "更新基礎模型",
|
"save": "更新基礎模型",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
|
"bulkDownloadMissingLoras": {
|
||||||
|
"title": "下載缺失的 LoRAs",
|
||||||
|
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取食譜中的 {totalCount} 個總數)。",
|
||||||
|
"previewTitle": "要下載的 LoRAs:",
|
||||||
|
"moreItems": "...還有 {count} 個",
|
||||||
|
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||||
|
"downloadButton": "下載 {count} 個 LoRA(s)"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "本機範例圖片",
|
"title": "本機範例圖片",
|
||||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||||
@@ -1507,7 +1524,10 @@
|
|||||||
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
|
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
|
||||||
"batchImportNoDirectory": "請輸入目錄路徑",
|
"batchImportNoDirectory": "請輸入目錄路徑",
|
||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}"
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
|
"noRecipesSelected": "未選取任何食譜",
|
||||||
|
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||||
|
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "未選擇模型",
|
"noModelsSelected": "未選擇模型",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
|||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
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.utils import sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
@@ -846,9 +846,13 @@ class DownloadManager:
|
|||||||
blur_mature_content = bool(
|
blur_mature_content = bool(
|
||||||
settings_manager.get("blur_mature_content", True)
|
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(
|
selected_image, nsfw_level = select_preview_media(
|
||||||
images,
|
images,
|
||||||
blur_mature_content=blur_mature_content,
|
blur_mature_content=blur_mature_content,
|
||||||
|
mature_threshold=mature_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
preview_url = selected_image.get("url") if selected_image else None
|
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 .errors import RateLimitError, ResourceNotFoundError
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
blur_mature_content = True
|
blur_mature_content = True
|
||||||
|
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||||
settings = getattr(self, "_settings", None)
|
settings = getattr(self, "_settings", None)
|
||||||
if settings is not None and hasattr(settings, "get"):
|
if settings is not None and hasattr(settings, "get"):
|
||||||
try:
|
try:
|
||||||
blur_mature_content = bool(settings.get("blur_mature_content", True))
|
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
|
except Exception: # pragma: no cover - defensive guard
|
||||||
blur_mature_content = True
|
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:
|
if not selected:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
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
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -49,9 +49,13 @@ class PreviewAssetService:
|
|||||||
blur_mature_content = bool(
|
blur_mature_content = bool(
|
||||||
settings_manager.get("blur_mature_content", True)
|
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(
|
first_preview, nsfw_level = select_preview_media(
|
||||||
images,
|
images,
|
||||||
blur_mature_content=blur_mature_content,
|
blur_mature_content=blur_mature_content,
|
||||||
|
mature_threshold=mature_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not first_preview:
|
if not first_preview:
|
||||||
@@ -216,4 +220,3 @@ class PreviewAssetService:
|
|||||||
if "webm" in content_type:
|
if "webm" in content_type:
|
||||||
return ".webm"
|
return ".webm"
|
||||||
return ".mp4"
|
return ".mp4"
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
|
|||||||
from platformdirs import user_config_dir
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
|
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.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
|
||||||
from ..utils.tag_priorities import (
|
from ..utils.tag_priorities import (
|
||||||
PriorityTagEntry,
|
PriorityTagEntry,
|
||||||
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"optimize_example_images": True,
|
"optimize_example_images": True,
|
||||||
"auto_download_example_images": False,
|
"auto_download_example_images": False,
|
||||||
"blur_mature_content": True,
|
"blur_mature_content": True,
|
||||||
|
"mature_blur_level": "R",
|
||||||
"autoplay_on_hover": False,
|
"autoplay_on_hover": False,
|
||||||
"display_density": "default",
|
"display_density": "default",
|
||||||
"card_info_display": "always",
|
"card_info_display": "always",
|
||||||
@@ -274,6 +276,16 @@ class SettingsManager:
|
|||||||
self.settings["metadata_refresh_skip_paths"] = []
|
self.settings["metadata_refresh_skip_paths"] = []
|
||||||
inserted_defaults = True
|
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():
|
for key, value in defaults.items():
|
||||||
if key == "priority_tags":
|
if key == "priority_tags":
|
||||||
continue
|
continue
|
||||||
@@ -608,6 +620,7 @@ class SettingsManager:
|
|||||||
'optimizeExampleImages': 'optimize_example_images',
|
'optimizeExampleImages': 'optimize_example_images',
|
||||||
'autoDownloadExampleImages': 'auto_download_example_images',
|
'autoDownloadExampleImages': 'auto_download_example_images',
|
||||||
'blurMatureContent': 'blur_mature_content',
|
'blurMatureContent': 'blur_mature_content',
|
||||||
|
'matureBlurLevel': 'mature_blur_level',
|
||||||
'autoplayOnHover': 'autoplay_on_hover',
|
'autoplayOnHover': 'autoplay_on_hover',
|
||||||
'displayDensity': 'display_density',
|
'displayDensity': 'display_density',
|
||||||
'cardInfoDisplay': 'card_info_display',
|
'cardInfoDisplay': 'card_info_display',
|
||||||
@@ -860,6 +873,13 @@ class SettingsManager:
|
|||||||
|
|
||||||
return normalized
|
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]:
|
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return []
|
return []
|
||||||
@@ -1012,6 +1032,8 @@ class SettingsManager:
|
|||||||
value = self.normalize_auto_organize_exclusions(value)
|
value = self.normalize_auto_organize_exclusions(value)
|
||||||
elif key == "metadata_refresh_skip_paths":
|
elif key == "metadata_refresh_skip_paths":
|
||||||
value = self.normalize_metadata_refresh_skip_paths(value)
|
value = self.normalize_metadata_refresh_skip_paths(value)
|
||||||
|
elif key == "mature_blur_level":
|
||||||
|
value = self.normalize_mature_blur_level(value)
|
||||||
self.settings[key] = value
|
self.settings[key] = value
|
||||||
portable_switch_pending = False
|
portable_switch_pending = False
|
||||||
if key == "use_portable_settings" and isinstance(value, bool):
|
if key == "use_portable_settings" and isinstance(value, bool):
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Mapping, Optional, Sequence, Tuple
|
from typing import Any, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
from .constants import NSFW_LEVELS
|
from .constants import NSFW_LEVELS
|
||||||
|
|
||||||
PreviewMedia = Mapping[str, object]
|
PreviewMedia = Mapping[str, object]
|
||||||
|
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
|
||||||
|
|
||||||
|
|
||||||
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||||
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
|||||||
return 0
|
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(
|
def select_preview_media(
|
||||||
images: Sequence[Mapping[str, object]] | None,
|
images: Sequence[Mapping[str, object]] | None,
|
||||||
*,
|
*,
|
||||||
blur_mature_content: bool,
|
blur_mature_content: bool,
|
||||||
|
mature_threshold: int | None = None,
|
||||||
) -> Tuple[Optional[PreviewMedia], int]:
|
) -> Tuple[Optional[PreviewMedia], int]:
|
||||||
"""Select the most appropriate preview media entry.
|
"""Select the most appropriate preview media entry.
|
||||||
|
|
||||||
When ``blur_mature_content`` is enabled we first try to return the first media
|
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
|
item with an ``nsfwLevel`` lower than the configured mature threshold
|
||||||
available we return the media entry with the lowest NSFW level. When the
|
(defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
|
||||||
setting is disabled we simply return the first entry.
|
the media entry with the lowest NSFW level. When the setting is disabled we
|
||||||
|
simply return the first entry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not images:
|
if not images:
|
||||||
@@ -45,7 +65,9 @@ def select_preview_media(
|
|||||||
if not blur_mature_content:
|
if not blur_mature_content:
|
||||||
return selected, selected_level
|
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:
|
for candidate in candidates:
|
||||||
level = _extract_nsfw_level(candidate)
|
level = _extract_nsfw_level(candidate)
|
||||||
if level < safe_threshold:
|
if level < safe_threshold:
|
||||||
@@ -60,4 +82,4 @@ def select_preview_media(
|
|||||||
return selected, selected_level
|
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"] .changelog-section,
|
||||||
[data-theme="dark"] .update-info,
|
[data-theme="dark"] .update-info,
|
||||||
[data-theme="dark"] .info-item,
|
[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);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
@@ -349,3 +350,87 @@ button:disabled,
|
|||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
text-align: center;
|
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 { state } from '../../state/index.js';
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { updateElementText, translate } from '../../utils/i18nHelpers.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 {
|
export class BulkContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
||||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||||
|
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||||
|
|
||||||
if (sendToWorkflowAppendItem) {
|
if (sendToWorkflowAppendItem) {
|
||||||
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||||
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
if (setContentRatingItem) {
|
if (setContentRatingItem) {
|
||||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
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 skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||||
@@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
|
case 'download-missing-loras':
|
||||||
|
this.handleDownloadMissingLoras();
|
||||||
|
break;
|
||||||
case 'clear':
|
case 'clear':
|
||||||
bulkManager.clearSelection();
|
bulkManager.clearSelection();
|
||||||
break;
|
break;
|
||||||
@@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
console.warn(`Unknown bulk action: ${action}`);
|
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 { getCurrentPageState } from '../state/index.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.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 {
|
class RecipeCard {
|
||||||
constructor(recipe, clickHandler) {
|
constructor(recipe, clickHandler) {
|
||||||
@@ -74,7 +74,8 @@ class RecipeCard {
|
|||||||
|
|
||||||
// NSFW blur logic - similar to LoraCard
|
// NSFW blur logic - similar to LoraCard
|
||||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
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) {
|
if (shouldBlur) {
|
||||||
card.classList.add('nsfw-content');
|
card.classList.add('nsfw-content');
|
||||||
|
|||||||
@@ -1299,7 +1299,6 @@ class RecipeModal {
|
|||||||
|
|
||||||
// New method to navigate to the LoRAs page
|
// New method to navigate to the LoRAs page
|
||||||
navigateToLorasPage(specificLoraIndex = null) {
|
navigateToLorasPage(specificLoraIndex = null) {
|
||||||
debugger;
|
|
||||||
// Close the current modal
|
// Close the current modal
|
||||||
modalManager.closeModal('recipeModal');
|
modalManager.closeModal('recipeModal');
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
|
|||||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.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 { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.nsfwLevel = nsfwLevel;
|
card.dataset.nsfwLevel = nsfwLevel;
|
||||||
|
|
||||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
// 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) {
|
if (shouldBlur) {
|
||||||
card.classList.add('nsfw-content');
|
card.classList.add('nsfw-content');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||||
import { state } from '../../../state/index.js';
|
import { state } from '../../../state/index.js';
|
||||||
import { getModelApiClient } from '../../../api/modelApiFactory.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';
|
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
|
|||||||
}
|
}
|
||||||
mediaWrapper.dataset.nsfwLevel = String(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 overlay = mediaWrapper.querySelector('.nsfw-overlay');
|
||||||
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
|
|||||||
import { state } from '../../../state/index.js';
|
import { state } from '../../../state/index.js';
|
||||||
import { modalManager } from '../../../managers/ModalManager.js';
|
import { modalManager } from '../../../managers/ModalManager.js';
|
||||||
import { translate } from '../../../utils/i18nHelpers.js';
|
import { translate } from '../../../utils/i18nHelpers.js';
|
||||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||||
import {
|
import {
|
||||||
initLazyLoading,
|
initLazyLoading,
|
||||||
initNsfwBlurHandlers,
|
initNsfwBlurHandlers,
|
||||||
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
|
|
||||||
// Check if media should be blurred
|
// Check if media should be blurred
|
||||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
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
|
// Determine NSFW warning text based on level
|
||||||
let nsfwText = "Mature Content";
|
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);
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
|
|||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
import { sidebarManager } from '../components/SidebarManager.js';
|
import { sidebarManager } from '../components/SidebarManager.js';
|
||||||
|
|
||||||
|
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
@@ -137,11 +139,25 @@ export class SettingsManager {
|
|||||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
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));
|
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
|
||||||
|
|
||||||
return merged;
|
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) {
|
normalizePatternList(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const sanitized = value
|
const sanitized = value
|
||||||
@@ -682,6 +698,13 @@ export class SettingsManager {
|
|||||||
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
|
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');
|
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
||||||
if (usePortableCheckbox) {
|
if (usePortableCheckbox) {
|
||||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||||
@@ -1811,7 +1834,9 @@ export class SettingsManager {
|
|||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
const value = element.value;
|
const value = settingKey === 'mature_blur_level'
|
||||||
|
? this.normalizeMatureBlurLevel(element.value)
|
||||||
|
: element.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update frontend state with mapped keys
|
// Update frontend state with mapped keys
|
||||||
@@ -1834,7 +1859,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
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();
|
this.reloadContent();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
optimize_example_images: true,
|
optimize_example_images: true,
|
||||||
auto_download_example_images: false,
|
auto_download_example_images: false,
|
||||||
blur_mature_content: true,
|
blur_mature_content: true,
|
||||||
|
mature_blur_level: 'R',
|
||||||
autoplay_on_hover: false,
|
autoplay_on_hover: false,
|
||||||
display_density: 'default',
|
display_density: 'default',
|
||||||
card_info_display: 'always',
|
card_info_display: 'always',
|
||||||
|
|||||||
@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
|
|||||||
BLOCKED: 32
|
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
|
// Node type constants
|
||||||
export const NODE_TYPES = {
|
export const NODE_TYPES = {
|
||||||
LORA_LOADER: 1,
|
LORA_LOADER: 1,
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-separator"></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">
|
<div class="context-menu-item" data-action="move-all">
|
||||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,4 +80,32 @@
|
|||||||
<button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button>
|
<button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
@@ -281,6 +281,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Video Settings -->
|
<!-- Video Settings -->
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ describe('state module', () => {
|
|||||||
expect(defaultSettings).toMatchObject({
|
expect(defaultSettings).toMatchObject({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
blur_mature_content: true
|
blur_mature_content: true,
|
||||||
|
mature_blur_level: 'R'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
|
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)
|
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
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(
|
result = await manager._execute_download(
|
||||||
download_urls=download_urls,
|
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()
|
assert not zip_path.exists()
|
||||||
extracted = save_dir / "model.safetensors"
|
extracted = save_dir / "model.safetensors"
|
||||||
assert extracted.exists()
|
assert extracted.exists()
|
||||||
assert hash_calculator.await_args.args[0] == str(extracted)
|
|
||||||
saved_call = MetadataManager.save_metadata.await_args
|
saved_call = MetadataManager.save_metadata.await_args
|
||||||
assert saved_call.args[0] == str(extracted)
|
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
|
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)
|
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
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(
|
result = await manager._execute_download(
|
||||||
download_urls=download_urls,
|
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_one.exists()
|
||||||
assert extracted_two.exists()
|
assert extracted_two.exists()
|
||||||
|
|
||||||
assert hash_calculator.await_count == 2
|
|
||||||
assert MetadataManager.save_metadata.await_count == 2
|
assert MetadataManager.save_metadata.await_count == 2
|
||||||
assert dummy_scanner.add_model_to_cache.await_count == 2
|
assert dummy_scanner.add_model_to_cache.await_count == 2
|
||||||
|
|
||||||
metadata_calls = MetadataManager.save_metadata.await_args_list
|
metadata_calls = MetadataManager.save_metadata.await_args_list
|
||||||
assert metadata_calls[0].args[0] == str(extracted_one)
|
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[0] == str(extracted_two)
|
||||||
assert metadata_calls[1].args[1].sha256 == "hash-two"
|
assert metadata_calls[1].args[1].sha256 == "sha256"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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)
|
ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner)
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
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(
|
result = await manager._execute_download(
|
||||||
download_urls=download_urls,
|
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()
|
assert not zip_path.exists()
|
||||||
extracted = save_dir / "embedding.pt"
|
extracted = save_dir / "embedding.pt"
|
||||||
assert extracted.exists()
|
assert extracted.exists()
|
||||||
assert hash_calculator.await_args.args[0] == str(extracted)
|
|
||||||
saved_call = MetadataManager.save_metadata.await_args
|
saved_call = MetadataManager.save_metadata.await_args
|
||||||
assert saved_call.args[0] == str(extracted)
|
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
|
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,95 +9,99 @@ from unittest.mock import AsyncMock, patch, MagicMock
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from py.services.downloader import Downloader, DownloadStalledError, DownloadRestartRequested
|
from py.services.downloader import (
|
||||||
|
Downloader,
|
||||||
|
DownloadStalledError,
|
||||||
|
DownloadRestartRequested,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadStreamControl:
|
class TestDownloadStreamControl:
|
||||||
"""Test DownloadStreamControl functionality."""
|
"""Test DownloadStreamControl functionality."""
|
||||||
|
|
||||||
def test_pause_clears_event(self):
|
def test_pause_clears_event(self):
|
||||||
"""Verify pause() clears the event."""
|
"""Verify pause() clears the event."""
|
||||||
from py.services.downloader import DownloadStreamControl
|
from py.services.downloader import DownloadStreamControl
|
||||||
|
|
||||||
control = DownloadStreamControl()
|
control = DownloadStreamControl()
|
||||||
assert control.is_set() is True # Initially set
|
assert control.is_set() is True # Initially set
|
||||||
|
|
||||||
control.pause()
|
control.pause()
|
||||||
assert control.is_set() is False
|
assert control.is_set() is False
|
||||||
assert control.is_paused() is True
|
assert control.is_paused() is True
|
||||||
|
|
||||||
def test_resume_sets_event(self):
|
def test_resume_sets_event(self):
|
||||||
"""Verify resume() sets the event."""
|
"""Verify resume() sets the event."""
|
||||||
from py.services.downloader import DownloadStreamControl
|
from py.services.downloader import DownloadStreamControl
|
||||||
|
|
||||||
control = DownloadStreamControl()
|
control = DownloadStreamControl()
|
||||||
control.pause()
|
control.pause()
|
||||||
assert control.is_set() is False
|
assert control.is_set() is False
|
||||||
|
|
||||||
control.resume()
|
control.resume()
|
||||||
assert control.is_set() is True
|
assert control.is_set() is True
|
||||||
assert control.is_paused() is False
|
assert control.is_paused() is False
|
||||||
|
|
||||||
def test_reconnect_request_tracking(self):
|
def test_reconnect_request_tracking(self):
|
||||||
"""Verify reconnect request tracking works correctly."""
|
"""Verify reconnect request tracking works correctly."""
|
||||||
from py.services.downloader import DownloadStreamControl
|
from py.services.downloader import DownloadStreamControl
|
||||||
|
|
||||||
control = DownloadStreamControl()
|
control = DownloadStreamControl()
|
||||||
assert control.has_reconnect_request() is False
|
assert control.has_reconnect_request() is False
|
||||||
|
|
||||||
control.request_reconnect()
|
control.request_reconnect()
|
||||||
assert control.has_reconnect_request() is True
|
assert control.has_reconnect_request() is True
|
||||||
|
|
||||||
# Consume the request
|
# Consume the request
|
||||||
consumed = control.consume_reconnect_request()
|
consumed = control.consume_reconnect_request()
|
||||||
assert consumed is True
|
assert consumed is True
|
||||||
assert control.has_reconnect_request() is False
|
assert control.has_reconnect_request() is False
|
||||||
|
|
||||||
def test_mark_progress_clears_reconnect(self):
|
def test_mark_progress_clears_reconnect(self):
|
||||||
"""Verify mark_progress clears reconnect requests."""
|
"""Verify mark_progress clears reconnect requests."""
|
||||||
from py.services.downloader import DownloadStreamControl
|
from py.services.downloader import DownloadStreamControl
|
||||||
|
|
||||||
control = DownloadStreamControl()
|
control = DownloadStreamControl()
|
||||||
control.request_reconnect()
|
control.request_reconnect()
|
||||||
assert control.has_reconnect_request() is True
|
assert control.has_reconnect_request() is True
|
||||||
|
|
||||||
control.mark_progress()
|
control.mark_progress()
|
||||||
assert control.has_reconnect_request() is False
|
assert control.has_reconnect_request() is False
|
||||||
assert control.last_progress_timestamp is not None
|
assert control.last_progress_timestamp is not None
|
||||||
|
|
||||||
def test_time_since_last_progress(self):
|
def test_time_since_last_progress(self):
|
||||||
"""Verify time_since_last_progress calculation."""
|
"""Verify time_since_last_progress calculation."""
|
||||||
from py.services.downloader import DownloadStreamControl
|
from py.services.downloader import DownloadStreamControl
|
||||||
import time
|
import time
|
||||||
|
|
||||||
control = DownloadStreamControl()
|
control = DownloadStreamControl()
|
||||||
|
|
||||||
# Initially None
|
# Initially None
|
||||||
assert control.time_since_last_progress() is None
|
assert control.time_since_last_progress() is None
|
||||||
|
|
||||||
# After marking progress
|
# After marking progress
|
||||||
now = time.time()
|
now = time.time()
|
||||||
control.mark_progress(timestamp=now)
|
control.mark_progress(timestamp=now)
|
||||||
|
|
||||||
elapsed = control.time_since_last_progress(now=now + 5)
|
elapsed = control.time_since_last_progress(now=now + 5)
|
||||||
assert elapsed == 5.0
|
assert elapsed == 5.0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wait_for_resume(self):
|
async def test_wait_for_resume(self):
|
||||||
"""Verify wait() blocks until resumed."""
|
"""Verify wait() blocks until resumed."""
|
||||||
from py.services.downloader import DownloadStreamControl
|
from py.services.downloader import DownloadStreamControl
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
control = DownloadStreamControl()
|
control = DownloadStreamControl()
|
||||||
control.pause()
|
control.pause()
|
||||||
|
|
||||||
# Start a task that will wait
|
# Start a task that will wait
|
||||||
wait_task = asyncio.create_task(control.wait())
|
wait_task = asyncio.create_task(control.wait())
|
||||||
|
|
||||||
# Give it a moment to start waiting
|
# Give it a moment to start waiting
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
assert not wait_task.done()
|
assert not wait_task.done()
|
||||||
|
|
||||||
# Resume should unblock
|
# Resume should unblock
|
||||||
control.resume()
|
control.resume()
|
||||||
await asyncio.wait_for(wait_task, timeout=0.1)
|
await asyncio.wait_for(wait_task, timeout=0.1)
|
||||||
@@ -105,75 +109,76 @@ class TestDownloadStreamControl:
|
|||||||
|
|
||||||
class TestDownloaderConfiguration:
|
class TestDownloaderConfiguration:
|
||||||
"""Test downloader configuration and initialization."""
|
"""Test downloader configuration and initialization."""
|
||||||
|
|
||||||
def test_downloader_singleton_pattern(self):
|
def test_downloader_singleton_pattern(self):
|
||||||
"""Verify Downloader follows singleton pattern."""
|
"""Verify Downloader follows singleton pattern."""
|
||||||
# Reset first
|
# Reset first
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
# Both should return same instance
|
# Both should return same instance
|
||||||
async def get_instances():
|
async def get_instances():
|
||||||
instance1 = await Downloader.get_instance()
|
instance1 = await Downloader.get_instance()
|
||||||
instance2 = await Downloader.get_instance()
|
instance2 = await Downloader.get_instance()
|
||||||
return instance1, instance2
|
return instance1, instance2
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
instance1, instance2 = asyncio.run(get_instances())
|
instance1, instance2 = asyncio.run(get_instances())
|
||||||
|
|
||||||
assert instance1 is instance2
|
assert instance1 is instance2
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_default_configuration_values(self):
|
def test_default_configuration_values(self):
|
||||||
"""Verify default configuration values are set correctly."""
|
"""Verify default configuration values are set correctly."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
downloader = Downloader()
|
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.max_retries == 5
|
||||||
assert downloader.base_delay == 2.0
|
assert downloader.base_delay == 2.0
|
||||||
assert downloader.session_timeout == 300
|
assert downloader.session_timeout == 300
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_default_headers_include_user_agent(self):
|
def test_default_headers_include_user_agent(self):
|
||||||
"""Verify default headers include User-Agent."""
|
"""Verify default headers include User-Agent."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
assert 'User-Agent' in downloader.default_headers
|
assert "User-Agent" in downloader.default_headers
|
||||||
assert 'ComfyUI-LoRA-Manager' in downloader.default_headers['User-Agent']
|
assert "ComfyUI-LoRA-Manager" in downloader.default_headers["User-Agent"]
|
||||||
assert downloader.default_headers['Accept-Encoding'] == 'identity'
|
assert downloader.default_headers["Accept-Encoding"] == "identity"
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_stall_timeout_resolution(self):
|
def test_stall_timeout_resolution(self):
|
||||||
"""Verify stall timeout is resolved correctly."""
|
"""Verify stall timeout is resolved correctly."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
timeout = downloader._resolve_stall_timeout()
|
timeout = downloader._resolve_stall_timeout()
|
||||||
|
|
||||||
# Should be at least 30 seconds
|
# Should be at least 30 seconds
|
||||||
assert timeout >= 30.0
|
assert timeout >= 30.0
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadProgress:
|
class TestDownloadProgress:
|
||||||
"""Test DownloadProgress dataclass."""
|
"""Test DownloadProgress dataclass."""
|
||||||
|
|
||||||
def test_download_progress_creation(self):
|
def test_download_progress_creation(self):
|
||||||
"""Verify DownloadProgress can be created with correct values."""
|
"""Verify DownloadProgress can be created with correct values."""
|
||||||
from py.services.downloader import DownloadProgress
|
from py.services.downloader import DownloadProgress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
progress = DownloadProgress(
|
progress = DownloadProgress(
|
||||||
percent_complete=50.0,
|
percent_complete=50.0,
|
||||||
bytes_downloaded=500,
|
bytes_downloaded=500,
|
||||||
@@ -181,7 +186,7 @@ class TestDownloadProgress:
|
|||||||
bytes_per_second=100.5,
|
bytes_per_second=100.5,
|
||||||
timestamp=datetime.now().timestamp(),
|
timestamp=datetime.now().timestamp(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert progress.percent_complete == 50.0
|
assert progress.percent_complete == 50.0
|
||||||
assert progress.bytes_downloaded == 500
|
assert progress.bytes_downloaded == 500
|
||||||
assert progress.total_bytes == 1000
|
assert progress.total_bytes == 1000
|
||||||
@@ -191,121 +196,130 @@ class TestDownloadProgress:
|
|||||||
|
|
||||||
class TestDownloaderExceptions:
|
class TestDownloaderExceptions:
|
||||||
"""Test custom exception classes."""
|
"""Test custom exception classes."""
|
||||||
|
|
||||||
def test_download_stalled_error(self):
|
def test_download_stalled_error(self):
|
||||||
"""Verify DownloadStalledError can be raised and caught."""
|
"""Verify DownloadStalledError can be raised and caught."""
|
||||||
with pytest.raises(DownloadStalledError) as exc_info:
|
with pytest.raises(DownloadStalledError) as exc_info:
|
||||||
raise DownloadStalledError("Download stalled for 120 seconds")
|
raise DownloadStalledError("Download stalled for 120 seconds")
|
||||||
|
|
||||||
assert "stalled" in str(exc_info.value).lower()
|
assert "stalled" in str(exc_info.value).lower()
|
||||||
|
|
||||||
def test_download_restart_requested_error(self):
|
def test_download_restart_requested_error(self):
|
||||||
"""Verify DownloadRestartRequested can be raised and caught."""
|
"""Verify DownloadRestartRequested can be raised and caught."""
|
||||||
with pytest.raises(DownloadRestartRequested) as exc_info:
|
with pytest.raises(DownloadRestartRequested) as exc_info:
|
||||||
raise DownloadRestartRequested("Reconnect requested after resume")
|
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:
|
class TestDownloaderAuthHeaders:
|
||||||
"""Test authentication header generation."""
|
"""Test authentication header generation."""
|
||||||
|
|
||||||
def test_get_auth_headers_without_auth(self):
|
def test_get_auth_headers_without_auth(self):
|
||||||
"""Verify auth headers without authentication."""
|
"""Verify auth headers without authentication."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
headers = downloader._get_auth_headers(use_auth=False)
|
headers = downloader._get_auth_headers(use_auth=False)
|
||||||
|
|
||||||
assert 'User-Agent' in headers
|
assert "User-Agent" in headers
|
||||||
assert 'Authorization' not in headers
|
assert "Authorization" not in headers
|
||||||
|
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_get_auth_headers_with_auth_no_api_key(self, monkeypatch):
|
def test_get_auth_headers_with_auth_no_api_key(self, monkeypatch):
|
||||||
"""Verify auth headers with auth but no API key configured."""
|
"""Verify auth headers with auth but no API key configured."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
# Mock settings manager to return no API key
|
# Mock settings manager to return no API key
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.get.return_value = None
|
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)
|
headers = downloader._get_auth_headers(use_auth=True)
|
||||||
|
|
||||||
# Should still have User-Agent but no Authorization
|
# Should still have User-Agent but no Authorization
|
||||||
assert 'User-Agent' in headers
|
assert "User-Agent" in headers
|
||||||
assert 'Authorization' not in headers
|
assert "Authorization" not in headers
|
||||||
|
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_get_auth_headers_with_auth_and_api_key(self, monkeypatch):
|
def test_get_auth_headers_with_auth_and_api_key(self, monkeypatch):
|
||||||
"""Verify auth headers with auth and API key configured."""
|
"""Verify auth headers with auth and API key configured."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
# Mock settings manager to return API key
|
# Mock settings manager to return API key
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.get.return_value = "test-api-key-12345"
|
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)
|
headers = downloader._get_auth_headers(use_auth=True)
|
||||||
|
|
||||||
# Should have both User-Agent and Authorization
|
# Should have both User-Agent and Authorization
|
||||||
assert 'User-Agent' in headers
|
assert "User-Agent" in headers
|
||||||
assert 'Authorization' in headers
|
assert "Authorization" in headers
|
||||||
assert 'test-api-key-12345' in headers['Authorization']
|
assert "test-api-key-12345" in headers["Authorization"]
|
||||||
assert headers['Content-Type'] == 'application/json'
|
assert headers["Content-Type"] == "application/json"
|
||||||
|
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
|
|
||||||
class TestDownloaderSessionManagement:
|
class TestDownloaderSessionManagement:
|
||||||
"""Test session management functionality."""
|
"""Test session management functionality."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_should_refresh_session_when_none(self):
|
async def test_should_refresh_session_when_none(self):
|
||||||
"""Verify session refresh is needed when session is None."""
|
"""Verify session refresh is needed when session is None."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
# Initially should need refresh
|
# Initially should need refresh
|
||||||
assert downloader._should_refresh_session() is True
|
assert downloader._should_refresh_session() is True
|
||||||
|
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_should_not_refresh_new_session(self):
|
def test_should_not_refresh_new_session(self):
|
||||||
"""Verify new session doesn't need refresh."""
|
"""Verify new session doesn't need refresh."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
# Simulate a fresh session
|
# Simulate a fresh session
|
||||||
downloader._session_created_at = MagicMock()
|
downloader._session_created_at = MagicMock()
|
||||||
downloader._session = MagicMock()
|
downloader._session = MagicMock()
|
||||||
|
|
||||||
# Mock datetime to return current time
|
# Mock datetime to return current time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
current_time = datetime.now()
|
current_time = datetime.now()
|
||||||
downloader._session_created_at = current_time
|
downloader._session_created_at = current_time
|
||||||
|
|
||||||
# Should not need refresh for new session
|
# Should not need refresh for new session
|
||||||
assert downloader._should_refresh_session() is False
|
assert downloader._should_refresh_session() is False
|
||||||
|
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|
||||||
def test_should_refresh_old_session(self):
|
def test_should_refresh_old_session(self):
|
||||||
"""Verify old session needs refresh."""
|
"""Verify old session needs refresh."""
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
|
|
||||||
# Simulate an old session (older than timeout)
|
# Simulate an old session (older than timeout)
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1)
|
old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1)
|
||||||
downloader._session_created_at = old_time
|
downloader._session_created_at = old_time
|
||||||
downloader._session = MagicMock()
|
downloader._session = MagicMock()
|
||||||
|
|
||||||
# Should need refresh for old session
|
# Should need refresh for old session
|
||||||
assert downloader._should_refresh_session() is True
|
assert downloader._should_refresh_session() is True
|
||||||
|
|
||||||
Downloader._instance = None
|
Downloader._instance = None
|
||||||
|
|||||||
@@ -265,6 +265,32 @@ def test_delete_setting(manager):
|
|||||||
assert manager.get("example") is None
|
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):
|
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
||||||
initial = {
|
initial = {
|
||||||
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},
|
"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
|
||||||
|
|
||||||
|
from py.utils.constants import NSFW_LEVELS
|
||||||
def test_select_preview_prefers_safe_media_when_blurred():
|
from py.utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_select_preview_returns_first_when_blur_disabled():
|
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 selected["url"] == "nsfw"
|
||||||
assert level == 32
|
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