Compare commits

...

13 Commits

Author SHA1 Message Date
mudknight
0ced53c059 Use flex gap for header spacing (#901)
* Use flex gap for header spacing

* Remove extra margin
2026-04-18 19:33:39 +08:00
Will Miao
67ad68a23f fix(filters): apply preset base models from full list 2026-04-18 07:00:24 +08:00
pixelpaws
d9ec9c512e Merge pull request #899 from Phinease/fix/resumable-download-retries
fix: preserve resumable downloads across retries
2026-04-17 20:46:22 +08:00
Will Miao
0bcd8e09a9 fix(filters): improve base model filtering UX 2026-04-17 20:27:48 +08:00
Shuangrui CHEN
fa049a28c8 fix: preserve resumable downloads across retries 2026-04-17 03:35:41 +08:00
Will Miao
89fd2b43d6 chore(release): bump version to v1.0.5 and add release notes 2026-04-16 21:52:34 +08:00
Will Miao
c53f44e7ef feat(excluded-models): add excluded management view 2026-04-16 21:40:59 +08:00
Will Miao
ae7bfdb517 fix(download): normalize civitai.red download URLs (#898) 2026-04-16 18:25:16 +08:00
Will Miao
68bf8442eb chore(release): bump version to v1.0.4 and add release notes 2026-04-16 14:26:28 +08:00
Will Miao
605fbf4117 feat(civitai): add host preference for view links 2026-04-16 13:28:51 +08:00
Will Miao
406d5fea6a fix(civitai): use red-only api host (#897) 2026-04-16 12:08:07 +08:00
Will Miao
af2146f96c fix(civitai): fallback image info hosts on request failure 2026-04-16 09:29:03 +08:00
Will Miao
bdc8dec860 fix(civitai): support civitai.red URLs (#897) 2026-04-16 08:54:12 +08:00
79 changed files with 2997 additions and 237 deletions

View File

@@ -56,6 +56,18 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes ## Release Notes
### v1.0.5
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
### v1.0.4
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
* **Civitai API Host Migration** - Updated core Civitai API requests to use `civitai.red` for compatibility with Civitai's current API host.
* **Configurable Civitai View Host** - Added a setting to choose which Civitai site opens by default for model, search, and view links.
* **401 Unauthorized Reminder** - Some users have reported `401 Unauthorized` errors. If you run into this, try generating a new API key on `civitai.red` and updating it in LoRA Manager settings.
### v1.0.3 ### v1.0.3
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations. * **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.

View File

@@ -175,6 +175,9 @@
"success": "{count} Rezepte erfolgreich repariert.", "success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-Reparatur fehlgeschlagen: {message}"
},
"manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?", "presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?",
"presetNamePlaceholder": "Voreinstellungsname...", "presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell", "baseModel": "Basis-Modell",
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Modelltypen", "modelTypes": "Modelltypen",
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags", "noTags": "Keine Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen", "clearAll": "Alle Filter löschen",
"any": "Beliebig", "any": "Beliebig",
"all": "Alle", "all": "Alle",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein", "civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet", "civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiHost": {
"label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
"options": {
"com": "civitai.com (nur SFW)",
"red": "civitai.red (uneingeschränkt)"
}
},
"civitaiHostBanner": {
"title": "Civitai-Host-Einstellung verfügbar",
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
"openSettings": "Einstellungen öffnen"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Einstellungsordner öffnen", "label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen", "tooltip": "Den Ordner mit der settings.json öffnen",
@@ -667,6 +685,7 @@
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren", "repairMetadata": "Metadaten reparieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen",
"deleteModel": "Modell löschen", "deleteModel": "Modell löschen",
"shareRecipe": "Rezept teilen", "shareRecipe": "Rezept teilen",
"viewAllLoras": "Alle LoRAs anzeigen", "viewAllLoras": "Alle LoRAs anzeigen",
@@ -1790,6 +1809,8 @@
"deleteFailed": "Fehler beim Löschen von {type}: {message}", "deleteFailed": "Fehler beim Löschen von {type}: {message}",
"excludeSuccess": "{type} erfolgreich ausgeschlossen", "excludeSuccess": "{type} erfolgreich ausgeschlossen",
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}", "excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
"fileNameUpdated": "Dateiname erfolgreich aktualisiert", "fileNameUpdated": "Dateiname erfolgreich aktualisiert",
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}", "fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert", "previewUpdated": "Vorschau erfolgreich aktualisiert",

View File

@@ -175,6 +175,9 @@
"success": "Successfully repaired {count} recipes.", "success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.", "cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}" "error": "Recipe repair failed: {message}"
},
"manageExcludedModels": {
"label": "Manage Excluded Models"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?", "presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?",
"presetNamePlaceholder": "Preset name...", "presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model", "baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Model Types",
"license": "License", "license": "License",
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling", "allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags", "noTags": "No tags",
"noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
"any": "Any", "any": "Any",
"all": "All", "all": "All",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key", "civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiHost": {
"label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.",
"options": {
"com": "civitai.com (SFW)",
"red": "civitai.red (unrestricted)"
}
},
"civitaiHostBanner": {
"title": "Civitai host preference available",
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
"openSettings": "Open Settings"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Open settings folder", "label": "Open settings folder",
"tooltip": "Open folder containing settings.json", "tooltip": "Open folder containing settings.json",
@@ -667,6 +685,7 @@
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata", "repairMetadata": "Repair metadata",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"restoreModel": "Restore Model",
"deleteModel": "Delete Model", "deleteModel": "Delete Model",
"shareRecipe": "Share Recipe", "shareRecipe": "Share Recipe",
"viewAllLoras": "View All LoRAs", "viewAllLoras": "View All LoRAs",
@@ -685,9 +704,9 @@
"title": "Import a recipe from image or URL", "title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path", "urlLocalPath": "URL / Local Path",
"uploadImage": "Upload Image", "uploadImage": "Upload Image",
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.", "urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
"imageUrlOrPath": "Image URL or File Path:", "imageUrlOrPath": "Image URL or File Path:",
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
"fetchImage": "Fetch Image", "fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.", "uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image", "selectImage": "Select Image",
@@ -1090,9 +1109,9 @@
}, },
"proceedText": "Only proceed if you're sure this is what you want.", "proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:", "urlLabel": "Civitai Model URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": { "helpText": {
"title": "Paste any Civitai model URL. Supported formats:", "title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1790,6 +1809,8 @@
"deleteFailed": "Failed to delete {type}: {message}", "deleteFailed": "Failed to delete {type}: {message}",
"excludeSuccess": "{type} excluded successfully", "excludeSuccess": "{type} excluded successfully",
"excludeFailed": "Failed to exclude {type}: {message}", "excludeFailed": "Failed to exclude {type}: {message}",
"restoreSuccess": "{type} restored successfully",
"restoreFailed": "Failed to restore {type}: {message}",
"fileNameUpdated": "File name updated successfully", "fileNameUpdated": "File name updated successfully",
"fileRenameFailed": "Failed to rename file: {error}", "fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully", "previewUpdated": "Preview updated successfully",

View File

@@ -175,6 +175,9 @@
"success": "Se repararon con éxito {count} recetas.", "success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}" "error": "Error al reparar recetas: {message}"
},
"manageExcludedModels": {
"label": "Gestionar modelos excluidos"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?", "presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?",
"presetNamePlaceholder": "Nombre del preajuste...", "presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base", "baseModel": "Modelo base",
"baseModelSearchPlaceholder": "Buscar modelos base...",
"modelTags": "Etiquetas (Top 20)", "modelTags": "Etiquetas (Top 20)",
"modelTypes": "Tipos de modelos", "modelTypes": "Tipos de modelos",
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas", "noTags": "Sin etiquetas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros", "clearAll": "Limpiar todos los filtros",
"any": "Cualquiera", "any": "Cualquiera",
"all": "Todos", "all": "Todos",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Clave API de Civitai", "civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai", "civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai", "civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiHost": {
"label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
"options": {
"com": "civitai.com (solo SFW)",
"red": "civitai.red (sin restricciones)"
}
},
"civitaiHostBanner": {
"title": "Preferencia de host de Civitai disponible",
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
"openSettings": "Abrir ajustes"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Abrir carpeta de ajustes", "label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json", "tooltip": "Abrir la carpeta que contiene settings.json",
@@ -667,6 +685,7 @@
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos", "repairMetadata": "Reparar metadatos",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo",
"deleteModel": "Eliminar modelo", "deleteModel": "Eliminar modelo",
"shareRecipe": "Compartir receta", "shareRecipe": "Compartir receta",
"viewAllLoras": "Ver todos los LoRAs", "viewAllLoras": "Ver todos los LoRAs",
@@ -1790,6 +1809,8 @@
"deleteFailed": "Error al eliminar {type}: {message}", "deleteFailed": "Error al eliminar {type}: {message}",
"excludeSuccess": "{type} excluido exitosamente", "excludeSuccess": "{type} excluido exitosamente",
"excludeFailed": "Error al excluir {type}: {message}", "excludeFailed": "Error al excluir {type}: {message}",
"restoreSuccess": "{type} restaurado correctamente",
"restoreFailed": "No se pudo restaurar {type}: {message}",
"fileNameUpdated": "Nombre de archivo actualizado exitosamente", "fileNameUpdated": "Nombre de archivo actualizado exitosamente",
"fileRenameFailed": "Error al renombrar archivo: {error}", "fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente", "previewUpdated": "Vista previa actualizada exitosamente",

View File

@@ -175,6 +175,9 @@
"success": "{count} recettes réparées avec succès.", "success": "{count} recettes réparées avec succès.",
"cancelled": "Réparation annulée. {count} recettes ont été réparées.", "cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}" "error": "Échec de la réparation des recettes : {message}"
},
"manageExcludedModels": {
"label": "Gérer les modèles exclus"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?", "presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?",
"presetNamePlaceholder": "Nom du préréglage...", "presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base", "baseModel": "Modèle de base",
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Types de modèles", "modelTypes": "Types de modèles",
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag", "noTags": "Aucun tag",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres", "clearAll": "Effacer tous les filtres",
"any": "N'importe quel", "any": "N'importe quel",
"all": "Tous", "all": "Tous",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Clé API Civitai", "civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai", "civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai", "civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiHost": {
"label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
"options": {
"com": "civitai.com (SFW uniquement)",
"red": "civitai.red (sans restriction)"
}
},
"civitaiHostBanner": {
"title": "Préférence dhôte Civitai disponible",
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
"openSettings": "Ouvrir les paramètres"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Ouvrir le dossier des paramètres", "label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json", "tooltip": "Ouvrir le dossier contenant settings.json",
@@ -667,6 +685,7 @@
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées", "repairMetadata": "Réparer les métadonnées",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle",
"deleteModel": "Supprimer le modèle", "deleteModel": "Supprimer le modèle",
"shareRecipe": "Partager la recipe", "shareRecipe": "Partager la recipe",
"viewAllLoras": "Voir tous les LoRAs", "viewAllLoras": "Voir tous les LoRAs",
@@ -1790,6 +1809,8 @@
"deleteFailed": "Échec de la suppression de {type} : {message}", "deleteFailed": "Échec de la suppression de {type} : {message}",
"excludeSuccess": "{type} exclu avec succès", "excludeSuccess": "{type} exclu avec succès",
"excludeFailed": "Échec de l'exclusion de {type} : {message}", "excludeFailed": "Échec de l'exclusion de {type} : {message}",
"restoreSuccess": "{type} restauré avec succès",
"restoreFailed": "Échec de la restauration de {type} : {message}",
"fileNameUpdated": "Nom de fichier mis à jour avec succès", "fileNameUpdated": "Nom de fichier mis à jour avec succès",
"fileRenameFailed": "Échec du renommage du fichier : {error}", "fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès", "previewUpdated": "Aperçu mis à jour avec succès",

View File

@@ -175,6 +175,9 @@
"success": "תוקנו בהצלחה {count} מתכונים.", "success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
},
"manageExcludedModels": {
"label": "ניהול מודלים מוחרגים"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?", "presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?",
"presetNamePlaceholder": "שם קביעה מראש...", "presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס", "baseModel": "מודל בסיס",
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
"modelTags": "תגיות (20 המובילות)", "modelTags": "תגיות (20 המובילות)",
"modelTypes": "סוגי מודלים", "modelTypes": "סוגי מודלים",
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות", "noTags": "ללא תגיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים", "clearAll": "נקה את כל המסננים",
"any": "כלשהו", "any": "כלשהו",
"all": "כל התגים", "all": "כל התגים",
@@ -250,6 +255,19 @@
"civitaiApiKey": "מפתח API של Civitai", "civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai", "civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai", "civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiHost": {
"label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
"options": {
"com": "civitai.com (SFW בלבד)",
"red": "civitai.red (ללא הגבלות)"
}
},
"civitaiHostBanner": {
"title": "העדפת מארח Civitai זמינה",
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
"openSettings": "פתח הגדרות"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "פתח תיקיית הגדרות", "label": "פתח תיקיית הגדרות",
"tooltip": "פתח את התיקייה שמכילה את settings.json", "tooltip": "פתח את התיקייה שמכילה את settings.json",
@@ -667,6 +685,7 @@
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה", "repairMetadata": "תיקון מטא-דאטה",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל",
"deleteModel": "מחק מודל", "deleteModel": "מחק מודל",
"shareRecipe": "שתף מתכון", "shareRecipe": "שתף מתכון",
"viewAllLoras": "הצג את כל ה-LoRAs", "viewAllLoras": "הצג את כל ה-LoRAs",
@@ -1790,6 +1809,8 @@
"deleteFailed": "מחיקת {type} נכשלה: {message}", "deleteFailed": "מחיקת {type} נכשלה: {message}",
"excludeSuccess": "{type} הוחרג בהצלחה", "excludeSuccess": "{type} הוחרג בהצלחה",
"excludeFailed": "החרגת {type} נכשלה: {message}", "excludeFailed": "החרגת {type} נכשלה: {message}",
"restoreSuccess": "{type} שוחזר בהצלחה",
"restoreFailed": "שחזור {type} נכשל: {message}",
"fileNameUpdated": "שם הקובץ עודכן בהצלחה", "fileNameUpdated": "שם הקובץ עודכן בהצלחה",
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}", "fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה", "previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",

View File

@@ -175,6 +175,9 @@
"success": "{count} 件のレシピを正常に修復しました。", "success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
},
"manageExcludedModels": {
"label": "除外モデルを管理"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?", "presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?",
"presetNamePlaceholder": "プリセット名...", "presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル", "baseModel": "ベースモデル",
"baseModelSearchPlaceholder": "ベースモデルを検索...",
"modelTags": "タグ上位20", "modelTags": "タグ上位20",
"modelTypes": "モデルタイプ", "modelTypes": "モデルタイプ",
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし", "noTags": "タグなし",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア", "clearAll": "すべてのフィルタをクリア",
"any": "いずれか", "any": "いずれか",
"all": "すべて", "all": "すべて",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Civitai APIキー", "civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください", "civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます", "civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiHost": {
"label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
"options": {
"com": "civitai.comSFW のみ)",
"red": "civitai.red制限なし"
}
},
"civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
"openSettings": "設定を開く"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "設定フォルダーを開く", "label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます", "tooltip": "settings.json を含むフォルダーを開きます",
@@ -667,6 +685,7 @@
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復", "repairMetadata": "メタデータを修復",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"restoreModel": "モデルを復元",
"deleteModel": "モデルを削除", "deleteModel": "モデルを削除",
"shareRecipe": "レシピを共有", "shareRecipe": "レシピを共有",
"viewAllLoras": "すべてのLoRAを表示", "viewAllLoras": "すべてのLoRAを表示",
@@ -1790,6 +1809,8 @@
"deleteFailed": "{type}の削除に失敗しました:{message}", "deleteFailed": "{type}の削除に失敗しました:{message}",
"excludeSuccess": "{type}が正常に除外されました", "excludeSuccess": "{type}が正常に除外されました",
"excludeFailed": "{type}の除外に失敗しました:{message}", "excludeFailed": "{type}の除外に失敗しました:{message}",
"restoreSuccess": "{type}を復元しました",
"restoreFailed": "{type}の復元に失敗しました: {message}",
"fileNameUpdated": "ファイル名が正常に更新されました", "fileNameUpdated": "ファイル名が正常に更新されました",
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}", "fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました", "previewUpdated": "プレビューが正常に更新されました",

View File

@@ -175,6 +175,9 @@
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.", "success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
},
"manageExcludedModels": {
"label": "제외된 모델 관리"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?", "presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?",
"presetNamePlaceholder": "프리셋 이름...", "presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델", "baseModel": "베이스 모델",
"baseModelSearchPlaceholder": "베이스 모델 검색...",
"modelTags": "태그 (상위 20개)", "modelTags": "태그 (상위 20개)",
"modelTypes": "모델 유형", "modelTypes": "모델 유형",
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음", "noTags": "태그 없음",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기", "clearAll": "모든 필터 지우기",
"any": "아무", "any": "아무",
"all": "모두", "all": "모두",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Civitai API 키", "civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요", "civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다", "civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiHost": {
"label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
"options": {
"com": "civitai.com(SFW 전용)",
"red": "civitai.red(무제한)"
}
},
"civitaiHostBanner": {
"title": "Civitai 호스트 기본 설정 사용 가능",
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
"openSettings": "설정 열기"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "설정 폴더 열기", "label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다", "tooltip": "settings.json이 있는 폴더를 엽니다",
@@ -667,6 +685,7 @@
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구", "repairMetadata": "메타데이터 복구",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"restoreModel": "모델 복원",
"deleteModel": "모델 삭제", "deleteModel": "모델 삭제",
"shareRecipe": "레시피 공유", "shareRecipe": "레시피 공유",
"viewAllLoras": "모든 LoRA 보기", "viewAllLoras": "모든 LoRA 보기",
@@ -1790,6 +1809,8 @@
"deleteFailed": "{type} 삭제 실패: {message}", "deleteFailed": "{type} 삭제 실패: {message}",
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다", "excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
"excludeFailed": "{type} 제외 실패: {message}", "excludeFailed": "{type} 제외 실패: {message}",
"restoreSuccess": "{type} 복원 완료",
"restoreFailed": "{type} 복원 실패: {message}",
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다", "fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
"fileRenameFailed": "파일 이름 변경 실패: {error}", "fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다", "previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",

View File

@@ -175,6 +175,9 @@
"success": "Успешно восстановлено {count} рецептов.", "success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
},
"manageExcludedModels": {
"label": "Управление исключёнными моделями"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?", "presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?",
"presetNamePlaceholder": "Имя пресета...", "presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель", "baseModel": "Базовая модель",
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
"modelTags": "Теги (Топ 20)", "modelTags": "Теги (Топ 20)",
"modelTypes": "Типы моделей", "modelTypes": "Типы моделей",
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов", "noTags": "Без тегов",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры", "clearAll": "Очистить все фильтры",
"any": "Любой", "any": "Любой",
"all": "Все", "all": "Все",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Ключ API Civitai", "civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai", "civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai", "civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiHost": {
"label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
"options": {
"com": "civitai.com (только SFW)",
"red": "civitai.red (без ограничений)"
}
},
"civitaiHostBanner": {
"title": "Доступна настройка хоста Civitai",
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
"openSettings": "Открыть настройки"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Открыть папку настроек", "label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json", "tooltip": "Открыть папку, содержащую settings.json",
@@ -667,6 +685,7 @@
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные", "repairMetadata": "Восстановить метаданные",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель",
"deleteModel": "Удалить модель", "deleteModel": "Удалить модель",
"shareRecipe": "Поделиться рецептом", "shareRecipe": "Поделиться рецептом",
"viewAllLoras": "Посмотреть все LoRAs", "viewAllLoras": "Посмотреть все LoRAs",
@@ -1790,6 +1809,8 @@
"deleteFailed": "Не удалось удалить {type}: {message}", "deleteFailed": "Не удалось удалить {type}: {message}",
"excludeSuccess": "{type} успешно исключен", "excludeSuccess": "{type} успешно исключен",
"excludeFailed": "Не удалось исключить {type}: {message}", "excludeFailed": "Не удалось исключить {type}: {message}",
"restoreSuccess": "{type} успешно восстановлен",
"restoreFailed": "Не удалось восстановить {type}: {message}",
"fileNameUpdated": "Имя файла успешно обновлено", "fileNameUpdated": "Имя файла успешно обновлено",
"fileRenameFailed": "Не удалось переименовать файл: {error}", "fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено", "previewUpdated": "Превью успешно обновлено",

View File

@@ -175,6 +175,9 @@
"success": "成功修复了 {count} 个配方。", "success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。", "cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?", "presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?",
"presetNamePlaceholder": "预设名称...", "presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型", "baseModel": "基础模型",
"baseModelSearchPlaceholder": "搜索基础模型...",
"modelTags": "标签前20", "modelTags": "标签前20",
"modelTypes": "模型类型", "modelTypes": "模型类型",
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
"noTags": "无标签", "noTags": "无标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选", "clearAll": "清除所有筛选",
"any": "任一", "any": "任一",
"all": "全部", "all": "全部",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Civitai API 密钥", "civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥", "civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证", "civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiHost": {
"label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
"options": {
"com": "civitai.com仅 SFW",
"red": "civitai.red无限制"
}
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
"openSettings": "打开设置"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "打开设置文件夹", "label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹", "tooltip": "打开包含 settings.json 的文件夹",
@@ -667,6 +685,7 @@
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据", "repairMetadata": "修复元数据",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "恢复模型",
"deleteModel": "删除模型", "deleteModel": "删除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
"viewAllLoras": "查看所有 LoRA", "viewAllLoras": "查看所有 LoRA",
@@ -685,9 +704,9 @@
"title": "从图片或 URL 导入配方", "title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径", "urlLocalPath": "URL / 本地路径",
"uploadImage": "上传图片", "uploadImage": "上传图片",
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。", "urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL或本地文件路径以导入为配方。",
"imageUrlOrPath": "图片 URL 或文件路径:", "imageUrlOrPath": "图片 URL 或文件路径:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "获取图片", "fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。", "uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片", "selectImage": "选择图片",
@@ -1090,9 +1109,9 @@
}, },
"proceedText": "仅在你确定需要此操作时继续。", "proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL", "urlLabel": "Civitai 模型 URL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": { "helpText": {
"title": "粘贴任意 Civitai 模型 URL。支持格式", "title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1790,6 +1809,8 @@
"deleteFailed": "删除 {type} 失败:{message}", "deleteFailed": "删除 {type} 失败:{message}",
"excludeSuccess": "{type} 排除成功", "excludeSuccess": "{type} 排除成功",
"excludeFailed": "排除 {type} 失败:{message}", "excludeFailed": "排除 {type} 失败:{message}",
"restoreSuccess": "{type} 已成功恢复",
"restoreFailed": "恢复 {type} 失败:{message}",
"fileNameUpdated": "文件名更新成功", "fileNameUpdated": "文件名更新成功",
"fileRenameFailed": "重命名文件失败:{error}", "fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功", "previewUpdated": "预览图片更新成功",

View File

@@ -175,6 +175,9 @@
"success": "成功修復 {count} 個配方。", "success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。", "cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
} }
}, },
"header": { "header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?", "presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?",
"presetNamePlaceholder": "預設名稱...", "presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型", "baseModel": "基礎模型",
"baseModelSearchPlaceholder": "搜尋基礎模型...",
"modelTags": "標籤(前 20", "modelTags": "標籤(前 20",
"modelTypes": "模型類型", "modelTypes": "模型類型",
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤", "noTags": "無標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選", "clearAll": "清除所有篩選",
"any": "任一", "any": "任一",
"all": "全部", "all": "全部",
@@ -250,6 +255,19 @@
"civitaiApiKey": "Civitai API 金鑰", "civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰", "civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證", "civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiHost": {
"label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
"options": {
"com": "civitai.com僅 SFW",
"red": "civitai.red無限制"
}
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
"openSettings": "開啟設定"
},
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "開啟設定資料夾", "label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾", "tooltip": "開啟包含 settings.json 的資料夾",
@@ -667,6 +685,7 @@
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據", "repairMetadata": "修復元數據",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "還原模型",
"deleteModel": "刪除模型", "deleteModel": "刪除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
"viewAllLoras": "檢視全部 LoRA", "viewAllLoras": "檢視全部 LoRA",
@@ -1790,6 +1809,8 @@
"deleteFailed": "刪除 {type} 失敗:{message}", "deleteFailed": "刪除 {type} 失敗:{message}",
"excludeSuccess": "{type} 已成功排除", "excludeSuccess": "{type} 已成功排除",
"excludeFailed": "排除 {type} 失敗:{message}", "excludeFailed": "排除 {type} 失敗:{message}",
"restoreSuccess": "{type} 已成功還原",
"restoreFailed": "還原 {type} 失敗:{message}",
"fileNameUpdated": "檔案名稱已成功更新", "fileNameUpdated": "檔案名稱已成功更新",
"fileRenameFailed": "重新命名檔案失敗:{error}", "fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新", "previewUpdated": "預覽圖片已成功更新",

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ from ...services.recipes import (
RecipeValidationError, RecipeValidationError,
) )
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
@@ -329,6 +329,7 @@ class RecipeQueryHandler:
if recipe_scanner is None: if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20"))
cache = await recipe_scanner.get_cached_data() cache = await recipe_scanner.get_cached_data()
base_model_counts: Dict[str, int] = {} base_model_counts: Dict[str, int] = {}
@@ -344,6 +345,8 @@ class RecipeQueryHandler:
for model, count in base_model_counts.items() for model, count in base_model_counts.items()
] ]
sorted_models.sort(key=lambda entry: entry["count"], reverse=True) sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
if limit > 0:
sorted_models = sorted_models[:limit]
return web.json_response({"success": True, "base_models": sorted_models}) return web.json_response({"success": True, "base_models": sorted_models})
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving base models: %s", exc, exc_info=True) self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
@@ -1196,13 +1199,15 @@ class RecipeManagementHandler:
temp_path = temp_file.name temp_path = temp_file.name
download_url = image_url download_url = image_url
image_info = None image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url) civitai_image_id = extract_civitai_image_id(image_url)
if civitai_match: if civitai_image_id:
if civitai_client is None: if civitai_client is None:
raise RecipeDownloadError( raise RecipeDownloadError(
"Civitai client unavailable for image download" "Civitai client unavailable for image download"
) )
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=image_url
)
if not image_info: if not image_info:
raise RecipeDownloadError( raise RecipeDownloadError(
"Failed to fetch image information from Civitai" "Failed to fetch image information from Civitai"
@@ -1236,7 +1241,7 @@ class RecipeManagementHandler:
return ( return (
file_obj.read(), file_obj.read(),
extension, extension,
image_info.get("meta") if civitai_match and image_info else None, image_info.get("meta") if civitai_image_id and image_info else None,
) )
except RecipeDownloadError: except RecipeDownloadError:
raise raise

View File

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

View File

@@ -20,6 +20,7 @@ from .model_query import (
resolve_sub_type, resolve_sub_type,
) )
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
) )
return paginated return paginated
async def get_excluded_paginated_data(
self,
page: int,
page_size: int,
sort_by: str = "name",
search: str = None,
fuzzy_search: bool = False,
search_options: dict = None,
**kwargs,
) -> Dict:
"""Get paginated excluded model data."""
excluded_paths = list(self.scanner.get_excluded_models())
excluded_entries: List[Dict[str, Any]] = []
stale_paths: List[str] = []
for file_path in excluded_paths:
if not file_path or not os.path.exists(file_path):
stale_paths.append(file_path)
continue
entry = await self._build_excluded_entry(file_path)
if entry:
excluded_entries.append(entry)
else:
stale_paths.append(file_path)
if stale_paths:
current_excluded = getattr(self.scanner, "_excluded_models", None)
if isinstance(current_excluded, list):
stale_set = set(stale_paths)
self.scanner._excluded_models = [
path for path in current_excluded if path not in stale_set
]
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
excluded_entries = self._sort_entries(excluded_entries, sort_by)
if search:
excluded_entries = await self._apply_search_filters(
excluded_entries,
search,
fuzzy_search,
search_options,
)
paginated = self._paginate(excluded_entries, page, page_size)
paginated["items"] = await self._annotate_update_flags(paginated["items"])
return paginated
async def _fetch_with_usage_sort(self, sort_params): async def _fetch_with_usage_sort(self, sort_params):
"""Fetch data sorted by usage count (desc/asc).""" """Fetch data sorted by usage count (desc/asc)."""
cache = await self.cache_repository.get_cache() cache = await self.cache_repository.get_cache()
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
) )
return annotated return annotated
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
sort_params = self.cache_repository.parse_sort(sort_by)
key_name = sort_params.key
if key_name == "date":
key_fn = lambda item: (
float(item.get("modified", 0.0) or 0.0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "size":
key_fn = lambda item: (
int(item.get("size", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "usage":
key_fn = lambda item: (
int(item.get("usage_count", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
else:
key_fn = lambda item: (
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
root_path = self.scanner._find_root_for_file(file_path)
if not root_path:
return None
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self.metadata_class,
)
if should_skip:
return None
if metadata is None:
metadata = await self.scanner._create_default_metadata(file_path)
if metadata is None:
return None
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
os.path.sep, "/"
)
entry = self.scanner._build_cache_entry(metadata, folder=folder)
entry = self.scanner.adjust_cached_entry(entry)
entry["exclude"] = True
return entry
async def _apply_hash_filters( async def _apply_hash_filters(
self, data: List[Dict], hash_filters: Dict self, data: List[Dict], hash_filters: Dict
) -> List[Dict]: ) -> List[Dict]:
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
version_id = civitai_data.get("id") version_id = civitai_data.get("id")
if model_id: if model_id:
civitai_url = f"https://civitai.com/models/{model_id}" civitai_host = self.settings.get("civitai_host", "civitai.com")
if version_id: civitai_url = build_civitai_model_page_url(
civitai_url += f"?modelVersionId={version_id}" model_id,
version_id,
host=civitai_host,
)
return { return {
"civitai_url": civitai_url, "civitai_url": civitai_url,

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from ..utils.constants import (
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -31,6 +31,11 @@ import tempfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/",
"https://civitai.red/api/download/",
)
class DownloadManager: class DownloadManager:
_instance = None _instance = None
@@ -639,7 +644,9 @@ class DownloadManager:
if mirrors: if mirrors:
for mirror in mirrors: for mirror in mirrors:
if mirror.get("deletedAt") is None and mirror.get("url"): if mirror.get("deletedAt") is None and mirror.get("url"):
download_urls.append(mirror["url"]) download_urls.append(
normalize_civitai_download_url(mirror["url"])
)
# When source is 'civarchive', prioritize non-Civitai URLs # When source is 'civarchive', prioritize non-Civitai URLs
# This avoids failed downloads from deleted Civitai models # This avoids failed downloads from deleted Civitai models
@@ -647,18 +654,20 @@ class DownloadManager:
civitai_urls = [ civitai_urls = [
u u
for u in download_urls for u in download_urls
if u.startswith("https://civitai.com/api/download/") if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
] ]
non_civitai_urls = [ non_civitai_urls = [
u u
for u in download_urls for u in download_urls
if not u.startswith("https://civitai.com/api/download/") if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
] ]
download_urls = non_civitai_urls + civitai_urls download_urls = non_civitai_urls + civitai_urls
else: else:
download_url = file_info.get("downloadUrl") download_url = file_info.get("downloadUrl")
if download_url: if download_url:
download_urls.append(download_url) download_urls.append(
normalize_civitai_download_url(download_url)
)
if not download_urls: if not download_urls:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
@@ -1133,7 +1142,8 @@ class DownloadManager:
pause_control.update_stall_timeout(downloader.stall_timeout) pause_control.update_stall_timeout(downloader.stall_timeout)
last_error = None last_error = None
for download_url in download_urls: for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/") download_url = normalize_civitai_download_url(download_url)
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
download_kwargs = { download_kwargs = {
"progress_callback": lambda progress, snapshot=None: ( "progress_callback": lambda progress, snapshot=None: (
self._handle_download_progress( self._handle_download_progress(

View File

@@ -138,7 +138,7 @@ class Downloader:
self.chunk_size = ( self.chunk_size = (
16 * 1024 * 1024 16 * 1024 * 1024
) # 16MB chunks to balance I/O reduction and memory usage ) # 16MB chunks to balance I/O reduction and memory usage
self.max_retries = 5 self.max_retries = self._resolve_max_retries()
self.base_delay = 2.0 # Base delay for exponential backoff self.base_delay = 2.0 # Base delay for exponential backoff
self.session_timeout = 300 # 5 minutes self.session_timeout = 300 # 5 minutes
self.stall_timeout = self._resolve_stall_timeout() self.stall_timeout = self._resolve_stall_timeout()
@@ -192,6 +192,18 @@ class Downloader:
return max(30.0, timeout_value) return max(30.0, timeout_value)
def _resolve_max_retries(self) -> int:
"""Determine max retry count from environment while preserving defaults."""
default_retries = 5
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
try:
retries = int(raw_value)
except (TypeError, ValueError):
retries = default_retries
return max(0, retries)
def _should_refresh_session(self) -> bool: def _should_refresh_session(self) -> bool:
"""Check if session should be refreshed""" """Check if session should be refreshed"""
if self._session is None: if self._session is None:
@@ -334,6 +346,7 @@ class Downloader:
logger.info(f"Resuming download from offset {resume_offset} bytes") logger.info(f"Resuming download from offset {resume_offset} bytes")
total_size = 0 total_size = 0
range_redirect_retry_urls: set[str] = set()
while retry_count <= self.max_retries: while retry_count <= self.max_retries:
try: try:
@@ -372,6 +385,23 @@ class Downloader:
if response.status == 200: if response.status == 200:
# Full content response # Full content response
if resume_offset > 0: if resume_offset > 0:
redirected_url = str(response.url)
if (
allow_resume
and response.history
and redirected_url
and redirected_url != url
and redirected_url not in range_redirect_retry_urls
):
range_redirect_retry_urls.add(redirected_url)
logger.info(
"Range request was not honored after redirect; retrying final URL directly: %s",
redirected_url,
)
url = redirected_url
response.release()
continue
# Server doesn't support ranges, restart from beginning # Server doesn't support ranges, restart from beginning
logger.warning( logger.warning(
"Server doesn't support range requests, restarting download" "Server doesn't support range requests, restarting download"
@@ -571,37 +601,53 @@ class Downloader:
expected_size = total_size if total_size > 0 else None expected_size = total_size if total_size > 0 else None
integrity_error: Optional[str] = None integrity_error: Optional[str] = None
resumable_incomplete = False
if final_size <= 0: if final_size <= 0:
integrity_error = "Downloaded file is empty" integrity_error = "Downloaded file is empty"
elif expected_size is not None and final_size != expected_size: elif expected_size is not None and final_size != expected_size:
integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}" integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
resumable_incomplete = (
allow_resume
and part_path != save_path
and final_size > 0
and final_size < expected_size
)
if integrity_error is not None: if integrity_error is not None:
logger.error( log_fn = logger.warning if resumable_incomplete else logger.error
log_fn(
"Download integrity check failed for %s: %s", "Download integrity check failed for %s: %s",
save_path, save_path,
integrity_error, integrity_error,
) )
# Remove the corrupted payload so future attempts start fresh if resumable_incomplete:
if os.path.exists(part_path): logger.info(
try: "Preserving incomplete download for resume: %s (%s/%s bytes)",
os.remove(part_path) part_path,
except OSError as remove_error: final_size,
logger.warning( expected_size,
"Failed to delete corrupted download %s: %s", )
part_path, else:
remove_error, # Remove corrupted payloads that cannot be safely resumed.
) if os.path.exists(part_path):
if part_path != save_path and os.path.exists(save_path): try:
try: os.remove(part_path)
os.remove(save_path) except OSError as remove_error:
except OSError as remove_error: logger.warning(
logger.warning( "Failed to delete corrupted download %s: %s",
"Failed to delete target file %s after integrity error: %s", part_path,
save_path, remove_error,
remove_error, )
) if part_path != save_path and os.path.exists(save_path):
try:
os.remove(save_path)
except OSError as remove_error:
logger.warning(
"Failed to delete target file %s after integrity error: %s",
save_path,
remove_error,
)
retry_count += 1 retry_count += 1
if retry_count <= self.max_retries: if retry_count <= self.max_retries:
@@ -611,8 +657,16 @@ class Downloader:
delay, delay,
) )
await asyncio.sleep(delay) await asyncio.sleep(delay)
resume_offset = 0 if resumable_incomplete and os.path.exists(part_path):
total_size = 0 resume_offset = os.path.getsize(part_path)
total_size = expected_size or 0
logger.info(
"Will resume incomplete download from byte %s",
resume_offset,
)
else:
resume_offset = 0
total_size = 0
await self._create_session() await self._create_session()
continue continue

View File

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

View File

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

View File

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

View File

@@ -1535,7 +1535,7 @@ class ModelScanner:
return sorted_tags[:limit] return sorted_tags[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models sorted by frequency""" """Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data() cache = await self.get_cached_data()
base_model_counts = {} base_model_counts = {}
@@ -1547,6 +1547,8 @@ class ModelScanner:
sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()] sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()]
sorted_models.sort(key=lambda x: x['count'], reverse=True) sorted_models.sort(key=lambda x: x['count'], reverse=True)
if limit == 0:
return sorted_models
return sorted_models[:limit] return sorted_models[:limit]
async def get_model_info_by_name(self, name): async def get_model_info_by_name(self, name):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 100%; height: 100%;
gap: 1rem;
} }
/* Responsive header container for larger screens */ /* Responsive header container for larger screens */
@@ -65,7 +66,6 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
margin-right: 1rem;
} }
.nav-item { .nav-item {
@@ -101,7 +101,6 @@
.header-search { .header-search {
flex: 1; flex: 1;
max-width: 400px; max-width: 400px;
margin: 0 1rem;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }

View File

@@ -145,7 +145,7 @@
position: fixed; position: fixed;
right: 20px; right: 20px;
top: 50px; /* Position below header */ top: 50px; /* Position below header */
width: 320px; width: 366px;
background-color: var(--card-bg); background-color: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
@@ -197,6 +197,31 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.filter-search-input {
width: 100%;
box-sizing: border-box;
margin-bottom: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 13px;
}
.filter-search-input:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 76, 175, 80), 0.15);
}
.filter-empty-state {
margin-top: 8px;
font-size: 13px;
color: var(--text-color);
opacity: 0.7;
}
.filter-section h4 { .filter-section h4 {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 14px; font-size: 14px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,43 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js'; import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = { export const ModelContextMenuMixin = {
isExcludedView() {
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
},
updateExcludeMenuItem() {
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
if (!excludeItem) {
return;
}
const isExcludedView = this.isExcludedView();
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
excludeItem.innerHTML = isExcludedView
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
},
async restoreExcludedModel(filePath) {
const restored = await getModelApiClient().unexcludeModel(filePath);
if (!restored) {
return;
}
if (window.pageControls?.exitExcludedView) {
await window.pageControls.exitExcludedView();
} else {
const resetFn = this.resetAndReload || resetAndReload;
if (typeof resetFn === 'function') {
await resetFn(true);
}
}
},
// NSFW Selector methods // NSFW Selector methods
initNSFWSelector() { initNSFWSelector() {
if (this._nsfwSelectorInitialized) { if (this._nsfwSelectorInitialized) {
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
}, },
extractModelVersionId(url) { extractModelVersionId(url) {
try { return extractCivitaiModelUrlParts(url);
// Handle all three URL formats:
// 1. https://civitai.com/models/649516
// 2. https://civitai.com/models/649516?modelVersionId=726676
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
const parsedUrl = new URL(url);
// Extract model ID from path
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
// Extract model version ID from query parameters
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
}, },
parseModelId(value) { parseModelId(value) {

View File

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

View File

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

View File

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

View File

@@ -240,9 +240,7 @@ export class BulkManager {
*/ */
handleGlobalKeyboard(e) { handleGlobalKeyboard(e) {
// Skip if modal is open (handled by event manager conditions) // Skip if modal is open (handled by event manager conditions)
// Skip if search input is focused if (this.isEditingTextInputContext(e.target)) {
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
return false; // Don't handle, allow default behavior return false; // Don't handle, allow default behavior
} }
@@ -266,6 +264,26 @@ export class BulkManager {
return false; // Continue with other handlers return false; // Continue with other handlers
} }
isEditingTextInputContext(target) {
const activeElement = document.activeElement;
const candidate = target instanceof Element ? target : activeElement;
if (!candidate) {
return false;
}
const tagName = candidate.tagName?.toLowerCase();
if (
candidate.isContentEditable
|| tagName === 'input'
|| tagName === 'textarea'
|| tagName === 'select'
) {
return true;
}
return Boolean(candidate.closest?.('#filterPanel'));
}
toggleBulkMode() { toggleBulkMode() {
state.bulkMode = !state.bulkMode; state.bulkMode = !state.bulkMode;

View File

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

View File

@@ -20,6 +20,8 @@ export class FilterManager {
this.filterPanel = document.getElementById('filterPanel'); this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton'); this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount'); this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.baseModelSearchInput = document.getElementById('baseModelSearchInput');
this.baseModelOptions = [];
this.tagsLoaded = false; this.tagsLoaded = false;
// Initialize preset manager // Initialize preset manager
@@ -49,6 +51,8 @@ export class FilterManager {
} }
initialize() { initialize() {
this.initializeFilterSearchInputs();
// Create base model filter tags if they exist // Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) { if (document.getElementById('baseModelTags')) {
this.createBaseModelTags(); this.createBaseModelTags();
@@ -110,6 +114,18 @@ export class FilterManager {
this.updateTagLogicToggleUI(); this.updateTagLogicToggleUI();
} }
initializeFilterSearchInputs() {
if (this.baseModelSearchInput) {
this.baseModelSearchInput.addEventListener('input', () => {
this.renderBaseModelTags();
});
}
}
getNormalizedSearchQuery(input) {
return (input?.value || '').trim().toLowerCase();
}
updateTagLogicToggleUI() { updateTagLogicToggleUI() {
const toggleContainer = document.getElementById('tagLogicToggle'); const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return; if (!toggleContainer) return;
@@ -164,11 +180,6 @@ export class FilterManager {
tagsContainer.innerHTML = ''; tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
// Collect existing tag names from the API response // Collect existing tag names from the API response
const existingTagNames = new Set(tags.map(t => t.tag)); const existingTagNames = new Set(tags.map(t => t.tag));
@@ -186,6 +197,11 @@ export class FilterManager {
}); });
} }
if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
tags.forEach(tag => { tags.forEach(tag => {
const tagEl = document.createElement('div'); const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter'; tagEl.className = 'filter-tag tag-filter';
@@ -212,7 +228,6 @@ export class FilterManager {
await this.applyFilters(false); await this.applyFilters(false);
}); });
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
tagsContainer.appendChild(tagEl); tagsContainer.appendChild(tagEl);
}); });
@@ -235,8 +250,8 @@ export class FilterManager {
await this.applyFilters(false); await this.applyFilters(false);
}); });
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
tagsContainer.appendChild(noTagsEl); tagsContainer.appendChild(noTagsEl);
this.updateTagSelections();
} }
initializeLicenseFilters() { initializeLicenseFilters() {
@@ -323,44 +338,15 @@ export class FilterManager {
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page // Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`; const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
// Fetch base models // Fetch base models
fetch(apiEndpoint) fetch(apiEndpoint)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success && data.base_models) { if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = ''; this.baseModelOptions = data.base_models;
this.renderBaseModelTags();
data.base_models.forEach(model => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
} }
}) })
.catch(error => { .catch(error => {
@@ -369,6 +355,57 @@ export class FilterManager {
}); });
} }
renderBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags');
const emptyState = document.getElementById('baseModelEmptyState');
if (!baseModelTagsContainer) return;
baseModelTagsContainer.innerHTML = '';
if (!this.baseModelOptions.length) {
baseModelTagsContainer.innerHTML = '<div class="no-tags">No base models available</div>';
if (emptyState) {
emptyState.hidden = true;
}
return;
}
const query = this.getNormalizedSearchQuery(this.baseModelSearchInput);
const filteredModels = query
? this.baseModelOptions.filter(model => model.name.toLowerCase().includes(query))
: this.baseModelOptions;
filteredModels.forEach(model => {
const tag = document.createElement('div');
tag.className = 'filter-tag base-model-tag';
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
if (emptyState) {
emptyState.hidden = filteredModels.length > 0;
}
this.updateTagSelections();
}
async createModelTypeTags() { async createModelTypeTags() {
const modelTypeContainer = document.getElementById('modelTypeTags'); const modelTypeContainer = document.getElementById('modelTypeTags');
if (!modelTypeContainer) return; if (!modelTypeContainer) return;
@@ -453,6 +490,7 @@ export class FilterManager {
this.filterPanel.classList.remove('hidden'); this.filterPanel.classList.remove('hidden');
this.filterButton.classList.add('active'); this.filterButton.classList.add('active');
this.baseModelSearchInput?.focus();
// Load tags if they haven't been loaded yet // Load tags if they haven't been loaded yet
if (!this.tagsLoaded) { if (!this.tagsLoaded) {

View File

@@ -232,7 +232,7 @@ export class FilterPresetManager {
try { try {
const fetchOptions = signal ? { signal } : {}; const fetchOptions = signal ? { signal } : {};
const response = await fetch(`/api/lm/${this.currentPage}/base-models`, fetchOptions); const response = await fetch(`/api/lm/${this.currentPage}/base-models?limit=0`, fetchOptions);
if (!response.ok) throw new Error('Failed to fetch base models'); if (!response.ok) throw new Error('Failed to fetch base models');

View File

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

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
civitai_host: 'civitai.com',
use_portable_settings: false, use_portable_settings: false,
language: 'en', language: 'en',
show_only_sfw: false, show_only_sfw: false,
@@ -89,7 +90,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
bulkMode: false, bulkMode: false,
selectedLoras: new Set(), selectedLoras: new Set(),
@@ -97,6 +100,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}, },
recipes: { recipes: {
@@ -146,7 +155,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false, bulkMode: false,
@@ -155,6 +166,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
@@ -177,7 +194,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
bulkMode: false, bulkMode: false,
selectedModels: new Set(), selectedModels: new Set(),
@@ -185,6 +204,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
} }
}, },

View File

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

View File

@@ -25,6 +25,7 @@ export function initializeEventManagement() {
setupPageUnloadCleanup(); setupPageUnloadCleanup();
// Register global event handlers that need coordination // Register global event handlers that need coordination
registerGlobalEventHandlers();
registerContextMenuEvents(); registerContextMenuEvents();
registerGlobalClickHandlers(); registerGlobalClickHandlers();
@@ -148,6 +149,10 @@ function registerGlobalClickHandlers() {
* Register common application-wide event handlers * Register common application-wide event handlers
*/ */
export function registerGlobalEventHandlers() { export function registerGlobalEventHandlers() {
eventManager.removeHandler('keydown', 'global-escape');
eventManager.removeHandler('focusin', 'global-focus');
eventManager.removeHandler('click', 'global-analytics');
// Escape key handler for closing modals/panels // Escape key handler for closing modals/panels
eventManager.addHandler('keydown', 'global-escape', (e) => { eventManager.addHandler('keydown', 'global-escape', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -157,6 +162,14 @@ export function registerGlobalEventHandlers() {
return true; // Stop propagation return true; // Stop propagation
} }
if (
window.filterManager?.filterPanel
&& !window.filterManager.filterPanel.classList.contains('hidden')
) {
window.filterManager.closeFilterPanel();
return true; // Stop propagation
}
// Check if node selector is active and close it // Check if node selector is active and close it
if (eventManager.getState('nodeSelectorActive')) { if (eventManager.getState('nodeSelectorActive')) {
// The node selector should handle its own escape key // The node selector should handle its own escape key

View File

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

View File

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

View File

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

View File

@@ -145,9 +145,22 @@
<div class="filter-section"> <div class="filter-section">
<h4>{{ t('header.filter.baseModel') }}</h4> <h4>{{ t('header.filter.baseModel') }}</h4>
<input
type="text"
id="baseModelSearchInput"
class="filter-search-input"
placeholder="{{ t('header.filter.baseModelSearchPlaceholder') }}"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
>
<div class="filter-tags" id="baseModelTags"> <div class="filter-tags" id="baseModelTags">
<!-- Tags will be dynamically inserted here --> <!-- Tags will be dynamically inserted here -->
</div> </div>
<div id="baseModelEmptyState" class="filter-empty-state" hidden>
{{ t('header.filter.noBaseModelMatches') }}
</div>
</div> </div>
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-header"> <div class="filter-section-header">

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ const apiClientMock = {
}; };
const showToastMock = vi.fn(); const showToastMock = vi.fn();
const openCivitaiByMetadataMock = vi.fn();
const updatePanelPositionsMock = vi.fn(); const updatePanelPositionsMock = vi.fn();
const downloadManagerMock = { const downloadManagerMock = {
showDownloadModal: vi.fn(), showDownloadModal: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock, showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock, updatePanelPositions: updatePanelPositionsMock,
})); }));
@@ -84,6 +86,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
delete window.bulkManager;
delete window.modelDuplicatesManager; delete window.modelDuplicatesManager;
delete global.fetch; delete global.fetch;
vi.useRealTimers(); vi.useRealTimers();
@@ -107,11 +110,17 @@ function renderControlsDom(pageKey) {
<div class="search-option-tag active" data-option="filename"></div> <div class="search-option-tag active" data-option="filename"></div>
</div> </div>
<div id="filterPanel" class="filter-panel hidden"> <div id="filterPanel" class="filter-panel hidden">
<input id="baseModelSearchInput" />
<div id="baseModelTags" class="filter-tags"></div> <div id="baseModelTags" class="filter-tags"></div>
<div id="baseModelEmptyState" hidden></div>
<div id="filterPresets" class="filter-presets"></div>
<div id="modelTagsFilter" class="filter-tags"></div> <div id="modelTagsFilter" class="filter-tags"></div>
<button class="clear-filter"></button> <button class="clear-filter"></button>
</div> </div>
<div class="controls"> <div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<button id="excludedViewBackBtn">Back</button>
</div>
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div class="control-group"> <div class="control-group">
@@ -170,6 +179,9 @@ function renderControlsDom(pageKey) {
<i class="fas fa-times-circle clear-filter"></i> <i class="fas fa-times-circle clear-filter"></i>
</div> </div>
</div> </div>
<div id="breadcrumbContainer"></div>
<div id="duplicatesBanner" style="display: none;"></div>
<div class="alphabet-bar-container"></div>
`; `;
} }
@@ -277,6 +289,8 @@ describe('FilterManager tag and base model filters', () => {
const manager = new FilterManager({ page: pageKey }); const manager = new FilterManager({ page: pageKey });
expect(global.fetch).toHaveBeenCalledWith(`/api/lm/${pageKey}/base-models?limit=0`);
await vi.waitFor(() => { await vi.waitFor(() => {
const chip = document.querySelector('[data-base-model="SDXL"]'); const chip = document.querySelector('[data-base-model="SDXL"]');
expect(chip).not.toBeNull(); expect(chip).not.toBeNull();
@@ -302,6 +316,259 @@ describe('FilterManager tag and base model filters', () => {
expect(getCurrentPageState().filters.baseModel).toEqual([]); expect(getCurrentPageState().filters.baseModel).toEqual([]);
expect(baseModelChip.classList.contains('active')).toBe(false); expect(baseModelChip.classList.contains('active')).toBe(false);
}); });
it('filters base model chips locally without changing selected state', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [
{ name: 'SDXL', count: 2 },
{ name: 'LTXV 2.3', count: 1 },
],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
new FilterManager({ page: 'loras' });
await vi.waitFor(() => {
expect(document.querySelector('[data-base-model="LTXV 2.3"]')).not.toBeNull();
});
const searchInput = document.getElementById('baseModelSearchInput');
const ltxvChip = document.querySelector('[data-base-model="LTXV 2.3"]');
ltxvChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']);
loadMoreWithVirtualScrollMock.mockClear();
searchInput.value = 'sdx';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(document.querySelector('[data-base-model="SDXL"]')).not.toBeNull();
expect(document.querySelector('[data-base-model="LTXV 2.3"]')).toBeNull();
expect(document.getElementById('baseModelEmptyState').hidden).toBe(true);
expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']);
searchInput.value = 'zzz';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(document.getElementById('baseModelEmptyState').hidden).toBe(false);
searchInput.value = 'ltx';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
const restoredChip = document.querySelector('[data-base-model="LTXV 2.3"]');
expect(restoredChip).not.toBeNull();
expect(restoredChip.classList.contains('active')).toBe(true);
});
it('disables browser autocomplete helpers for the base model search input', async () => {
renderControlsDom('loras');
const searchInput = document.getElementById('baseModelSearchInput');
searchInput.setAttribute('autocomplete', 'off');
searchInput.setAttribute('autocorrect', 'off');
searchInput.setAttribute('autocapitalize', 'none');
searchInput.setAttribute('spellcheck', 'false');
expect(searchInput.getAttribute('autocomplete')).toBe('off');
expect(searchInput.getAttribute('autocorrect')).toBe('off');
expect(searchInput.getAttribute('autocapitalize')).toBe('none');
expect(searchInput.getAttribute('spellcheck')).toBe('false');
});
it('focuses the base model search input when opening the filter panel', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
const searchInput = document.getElementById('baseModelSearchInput');
expect(document.activeElement).not.toBe(searchInput);
manager.toggleFilterPanel();
expect(document.activeElement).toBe(searchInput);
});
it('does not let base model search trigger bulk shortcuts', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const filterManager = new FilterManager({ page: 'loras' });
const bulkManager = new BulkManager();
const searchInput = document.getElementById('baseModelSearchInput');
window.filterManager = filterManager;
searchInput.focus();
const bulkEvent = new KeyboardEvent('keydown', {
key: 'b',
bubbles: true,
cancelable: true,
});
Object.defineProperty(bulkEvent, 'target', { value: searchInput });
expect(bulkManager.handleGlobalKeyboard(bulkEvent)).toBe(false);
const selectAllEvent = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
Object.defineProperty(selectAllEvent, 'target', { value: searchInput });
expect(bulkManager.handleGlobalKeyboard(selectAllEvent)).toBe(false);
});
it('closes the filter panel on Escape', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
const { initializeEventManagement } = await import('../../../static/js/utils/eventManagementInit.js');
eventManager.cleanup();
initializeEventManagement();
const manager = new FilterManager({ page: 'loras' });
window.filterManager = manager;
manager.toggleFilterPanel();
expect(manager.filterPanel.classList.contains('hidden')).toBe(false);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.filterPanel.classList.contains('hidden')).toBe(true);
eventManager.cleanup();
});
it('applies all base models from a preset using the full base model list', async () => {
global.fetch = vi.fn((url) => {
if (url.includes('/base-models?limit=0')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
base_models: [
{ name: 'SDXL 1.0', count: 5 },
{ name: 'SDXL Lightning', count: 3 },
{ name: 'SDXL Hyper', count: 2 },
],
}),
});
}
if (url.includes('/base-models')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL 1.0', count: 5 }],
}),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
tags: [],
}),
});
}
if (url.includes('/model-types')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
model_types: [],
}),
});
}
return Promise.resolve({
ok: true,
json: async () => ({ success: true }),
});
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
stateModule.state.global.settings.filter_presets = {
loras: [
{
name: 'SDXL Family',
filters: {
baseModel: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'],
tags: {},
license: {},
modelTypes: [],
tagLogic: 'any',
},
},
],
};
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
await vi.waitFor(() => {
expect(document.querySelector('[data-base-model="SDXL Hyper"]')).not.toBeNull();
});
await manager.presetManager.applyPreset('SDXL Family');
expect(manager.activePreset).toBe('SDXL Family');
expect(manager.filters.baseModel).toEqual(['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper']);
expect(getCurrentPageState().filters.baseModel).toEqual(['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper']);
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false);
expect(showToastMock).toHaveBeenCalledWith(
'Preset "SDXL Family" applied',
{},
'success',
);
});
}); });
describe('PageControls favorites, sorting, and duplicates scenarios', () => { describe('PageControls favorites, sorting, and duplicates scenarios', () => {
@@ -574,4 +841,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
duplicateButton.click(); duplicateButton.click();
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1); expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
}); });
it.each([
['loras', 'LorasControls'],
['checkpoints', 'CheckpointsControls'],
['embeddings', 'EmbeddingsControls'],
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const pageState = stateModule.getCurrentPageState();
pageState.filters.search = 'active-search';
pageState.showFavoritesOnly = true;
pageState.showUpdateAvailableOnly = true;
const controlsModule = await import('../../../static/js/components/controls/index.js');
const ControlsClass = controlsModule[exportName];
const controls = new ControlsClass();
await controls.enterExcludedView();
expect(pageState.viewMode).toBe('excluded');
expect(pageState.filters.search).toBe('');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
expect(document.getElementById('filterButton').disabled).toBe(true);
pageState.filters.search = 'excluded-search';
await controls.exitExcludedView();
expect(pageState.viewMode).toBe('active');
expect(pageState.filters.search).toBe('active-search');
expect(pageState.excludedViewState.search).toBe('excluded-search');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
expect(document.getElementById('filterButton').disabled).toBe(false);
});
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const pageState = stateModule.getCurrentPageState();
stateModule.state.bulkMode = true;
pageState.duplicatesMode = true;
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
const toggleBulkMode = vi.fn(() => {
stateModule.state.bulkMode = !stateModule.state.bulkMode;
});
const exitDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = false;
});
const enterDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = true;
});
window.bulkManager = { toggleBulkMode };
window.modelDuplicatesManager = {
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
exitDuplicateMode,
enterDuplicateMode,
};
const controls = new LorasControls();
const indicator = document.getElementById('customFilterIndicator');
expect(indicator.classList.contains('hidden')).toBe(false);
await controls.enterExcludedView();
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(false);
expect(pageState.duplicatesMode).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true);
await controls.exitExcludedView();
expect(indicator.classList.contains('hidden')).toBe(false);
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true);
});
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import json
import logging
from types import SimpleNamespace
import pytest
from py.routes.handlers.model_handlers import ModelQueryHandler
class DummyService:
def __init__(self):
self.received_limit = None
async def get_base_models(self, limit):
self.received_limit = limit
return [{"name": "SDXL", "count": 2}]
@pytest.mark.asyncio
async def test_model_query_handler_accepts_limit_zero_for_base_models():
service = DummyService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"}))
payload = json.loads(response.text)
assert payload["success"] is True
assert service.received_limit == 0
@pytest.mark.asyncio
async def test_model_query_handler_rejects_negative_limit_for_base_models():
service = DummyService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
assert service.received_limit == 20

View File

@@ -0,0 +1,44 @@
import json
import logging
from types import SimpleNamespace
import pytest
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
async def _noop():
return None
@pytest.mark.asyncio
async def test_recipe_query_handler_base_models_limit_zero_returns_all():
cache = SimpleNamespace(
raw_data=[
{"base_model": "SDXL"},
{"base_model": "LTXV 2.3"},
{"base_model": "SDXL"},
]
)
scanner = SimpleNamespace(get_cached_data=lambda: None)
async def get_cached_data():
return cache
scanner.get_cached_data = get_cached_data
handler = RecipeQueryHandler(
ensure_dependencies_ready=_noop,
recipe_scanner_getter=lambda: scanner,
format_recipe_file_url=lambda value: value,
logger=logging.getLogger(__name__),
)
response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"}))
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["base_models"] == [
{"name": "SDXL", "count": 2},
{"name": "LTXV 2.3", "count": 1},
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,10 +30,21 @@ class FakeStream:
class FakeResponse: class FakeResponse:
def __init__(self, status, headers, chunks): def __init__(
self,
status,
headers,
chunks,
*,
url="https://example.com/file",
history=None,
):
self.status = status self.status = status
self.headers = headers self.headers = headers
self.content = FakeStream(chunks) self.content = FakeStream(chunks)
self.url = url
self.history = history or []
self.released = False
async def __aenter__(self): async def __aenter__(self):
return self return self
@@ -41,14 +52,25 @@ class FakeResponse:
async def __aexit__(self, exc_type, exc, tb): async def __aexit__(self, exc_type, exc, tb):
return False return False
def release(self):
self.released = True
class FakeSession: class FakeSession:
def __init__(self, responses): def __init__(self, responses):
self._responses = list(responses) self._responses = list(responses)
self._get_calls = 0 self._get_calls = 0
self.requests = []
def get(self, url, headers=None, allow_redirects=True, proxy=None): # noqa: D401 - signature mirrors aiohttp def get(self, url, headers=None, allow_redirects=True, proxy=None): # noqa: D401 - signature mirrors aiohttp
del url, headers, allow_redirects, proxy self.requests.append(
{
"url": url,
"headers": headers or {},
"allow_redirects": allow_redirects,
"proxy": proxy,
}
)
response_factory = self._responses[self._get_calls] response_factory = self._responses[self._get_calls]
self._get_calls += 1 self._get_calls += 1
return response_factory() return response_factory()
@@ -75,7 +97,7 @@ def _build_downloader(responses, *, max_retries=0):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_file_fails_when_size_mismatch(tmp_path): async def test_download_file_preserves_incomplete_part_when_size_mismatch(tmp_path):
target_path = tmp_path / "model" / "file.bin" target_path = tmp_path / "model" / "file.bin"
target_path.parent.mkdir() target_path.parent.mkdir()
@@ -94,7 +116,7 @@ async def test_download_file_fails_when_size_mismatch(tmp_path):
assert success is False assert success is False
assert "mismatch" in message.lower() assert "mismatch" in message.lower()
assert not target_path.exists() assert not target_path.exists()
assert not Path(str(target_path) + ".part").exists() assert Path(str(target_path) + ".part").read_bytes() == b"abc"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -136,7 +158,9 @@ async def test_download_file_succeeds_when_sizes_match(tmp_path):
downloader = _build_downloader(responses) downloader = _build_downloader(responses)
success, result_path = await downloader.download_file("https://example.com/file", str(target_path)) success, result_path = await downloader.download_file(
"https://example.com/file", str(target_path)
)
assert success is True assert success is True
assert Path(result_path).read_bytes() == payload assert Path(result_path).read_bytes() == payload
@@ -166,9 +190,77 @@ async def test_download_file_recovers_from_stall(tmp_path):
downloader = _build_downloader(responses, max_retries=1) downloader = _build_downloader(responses, max_retries=1)
downloader.stall_timeout = 0.05 downloader.stall_timeout = 0.05
success, result_path = await downloader.download_file("https://example.com/file", str(target_path)) success, result_path = await downloader.download_file(
"https://example.com/file", str(target_path)
)
assert success is True assert success is True
assert Path(result_path).read_bytes() == payload assert Path(result_path).read_bytes() == payload
assert downloader._session._get_calls == 2 assert downloader._session._get_calls == 2
assert not Path(str(target_path) + ".part").exists() assert not Path(str(target_path) + ".part").exists()
@pytest.mark.asyncio
async def test_download_file_resumes_after_incomplete_integrity_check(tmp_path):
target_path = tmp_path / "model" / "file.bin"
target_path.parent.mkdir()
responses = [
lambda: FakeResponse(
status=200,
headers={"content-length": "6"},
chunks=[b"abc"],
),
lambda: FakeResponse(
status=206,
headers={"content-length": "3", "Content-Range": "bytes 3-5/6"},
chunks=[b"def"],
),
]
downloader = _build_downloader(responses, max_retries=1)
success, result_path = await downloader.download_file("https://example.com/file", str(target_path))
assert success is True
assert Path(result_path).read_bytes() == b"abcdef"
assert downloader._session._get_calls == 2
assert downloader._session.requests[1]["headers"]["Range"] == "bytes=3-"
assert not Path(str(target_path) + ".part").exists()
@pytest.mark.asyncio
async def test_download_file_retries_redirected_url_when_range_not_honored(tmp_path):
target_path = tmp_path / "model" / "file.bin"
target_path.parent.mkdir()
Path(str(target_path) + ".part").write_bytes(b"abc")
redirected_url = "https://download.example.com/file.bin"
first_response = FakeResponse(
status=200,
headers={"content-length": "6"},
chunks=[],
url=redirected_url,
history=[object()],
)
responses = [
lambda: first_response,
lambda: FakeResponse(
status=206,
headers={"content-length": "3", "Content-Range": "bytes 3-5/6"},
chunks=[b"def"],
url=redirected_url,
),
]
downloader = _build_downloader(responses, max_retries=0)
success, result_path = await downloader.download_file("https://example.com/file", str(target_path))
assert success is True
assert Path(result_path).read_bytes() == b"abcdef"
assert first_response.released is True
assert downloader._session.requests[0]["headers"]["Range"] == "bytes=3-"
assert downloader._session.requests[1]["url"] == redirected_url
assert downloader._session.requests[1]["headers"]["Range"] == "bytes=3-"

View File

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

View File

@@ -0,0 +1,52 @@
from types import SimpleNamespace
import pytest
from py.services.model_scanner import ModelScanner
class DummyScanner:
def __init__(self, raw_data):
self._cache = SimpleNamespace(raw_data=raw_data)
async def get_cached_data(self):
return self._cache
@pytest.mark.asyncio
async def test_get_base_models_limit_zero_returns_all_sorted():
scanner = DummyScanner(
[
{"base_model": "SDXL"},
{"base_model": "LTXV 2.3"},
{"base_model": "SDXL"},
{"base_model": ""},
{},
]
)
result = await ModelScanner.get_base_models(scanner, limit=0)
assert result == [
{"name": "SDXL", "count": 2},
{"name": "LTXV 2.3", "count": 1},
]
@pytest.mark.asyncio
async def test_get_base_models_positive_limit_still_truncates():
scanner = DummyScanner(
[
{"base_model": "SDXL"},
{"base_model": "LTXV 2.3"},
{"base_model": "Flux.1 D"},
{"base_model": "SDXL"},
]
)
result = await ModelScanner.get_base_models(scanner, limit=2)
assert result == [
{"name": "SDXL", "count": 2},
{"name": "LTXV 2.3", "count": 1},
]

View File

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

View File

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

View File

@@ -1,4 +1,12 @@
from py.utils.civitai_utils import build_license_flags, resolve_license_info, resolve_license_payload from py.utils.civitai_utils import (
build_license_flags,
extract_civitai_image_id,
extract_civitai_model_url_parts,
is_supported_civitai_page_host,
normalize_civitai_download_url,
resolve_license_info,
resolve_license_payload,
)
def test_resolve_license_payload_defaults(): def test_resolve_license_payload_defaults():
@@ -78,3 +86,61 @@ def test_build_license_flags_parses_aggregate_inside_list():
flags = build_license_flags(source) flags = build_license_flags(source)
expected_flags = (1 << 0) | (7 << 1) | (1 << 5) expected_flags = (1 << 0) | (7 << 1) | (1 << 5)
assert flags == expected_flags assert flags == expected_flags
def test_supported_civitai_page_hosts_include_red():
assert is_supported_civitai_page_host("civitai.com") is True
assert is_supported_civitai_page_host("civitai.red") is True
assert is_supported_civitai_page_host("www.civitai.com") is False
assert is_supported_civitai_page_host("www.civitai.red") is False
assert is_supported_civitai_page_host("example.com") is False
def test_extract_civitai_model_url_parts_supports_red():
model_id, version_id = extract_civitai_model_url_parts(
"https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
)
assert model_id == "65423"
assert version_id == "777"
def test_extract_civitai_model_url_parts_rejects_non_civitai_host():
model_id, version_id = extract_civitai_model_url_parts(
"https://example.com/models/65423?modelVersionId=777"
)
assert model_id is None
assert version_id is None
def test_extract_civitai_image_id_supports_red():
assert (
extract_civitai_image_id("https://civitai.red/images/126920345")
== "126920345"
)
def test_extract_civitai_image_id_rejects_non_civitai_host():
assert extract_civitai_image_id("https://example.com/images/126920345") is None
def test_normalize_civitai_download_url_rewrites_red_to_com():
url = "https://civitai.red/api/download/models/2786889?type=Model&format=SafeTensor"
assert (
normalize_civitai_download_url(url)
== "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
)
def test_normalize_civitai_download_url_keeps_non_download_red_urls():
url = "https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
assert normalize_civitai_download_url(url) == url
def test_normalize_civitai_download_url_keeps_existing_com_urls():
url = "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
assert normalize_civitai_download_url(url) == url