diff --git a/locales/de.json b/locales/de.json index fd34ccec..297f3a6a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -495,6 +495,25 @@ "saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Bereits heruntergeladene Modellversionen überspringen", "help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe." diff --git a/locales/en.json b/locales/en.json index 0db5e294..92ed45ef 100644 --- a/locales/en.json +++ b/locales/en.json @@ -495,6 +495,25 @@ "saveFailed": "Unable to save excluded base models: {message}" } }, + "downloadRootRules": { + "title": "Download Root Rules", + "help": "Route downloads to a different root folder based on the model's base model.", + "description": "Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "Add rule", + "removeRule": "Remove rule", + "moveUp": "Move up", + "moveDown": "Move down", + "noRoots": "No roots configured for this model type.", + "types": { + "loras": "LoRA", + "checkpoints": "Checkpoint", + "unet": "Diffusion Model", + "embeddings": "Embedding" + }, + "validation": { + "saveFailed": "Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Skip previously downloaded model versions", "help": "When enabled, versions downloaded before will be skipped." diff --git a/locales/es.json b/locales/es.json index b76ff5d9..ea062cdb 100644 --- a/locales/es.json +++ b/locales/es.json @@ -495,6 +495,25 @@ "saveFailed": "No se pudieron guardar los modelos base excluidos: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Omitir versiones de modelos previamente descargadas", "help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga." diff --git a/locales/fr.json b/locales/fr.json index 3842415d..dcdbb5f4 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -495,6 +495,25 @@ "saveFailed": "Impossible d’enregistrer les modèles de base exclus : {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Ignorer les versions de modèles précédemment téléchargées", "help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement." diff --git a/locales/he.json b/locales/he.json index aa752516..90e9faba 100644 --- a/locales/he.json +++ b/locales/he.json @@ -495,6 +495,25 @@ "saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "דלג על גרסאות מודלים שהורדו בעבר", "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." diff --git a/locales/ja.json b/locales/ja.json index 806345e9..342bfff9 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -495,6 +495,25 @@ "saveFailed": "除外するベースモデルを保存できませんでした: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "以前にダウンロードしたモデルバージョンをスキップ", "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" diff --git a/locales/ko.json b/locales/ko.json index 944faf2a..a6d5a691 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -495,6 +495,25 @@ "saveFailed": "제외된 베이스 모델을 저장할 수 없습니다: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "이전에 다운로드한 모델 버전 건너뛰기", "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." diff --git a/locales/ru.json b/locales/ru.json index 8eaf2477..b867796b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -495,6 +495,25 @@ "saveFailed": "Не удалось сохранить исключённые базовые модели: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Пропускать ранее загруженные версии моделей", "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." diff --git a/locales/zh-CN.json b/locales/zh-CN.json index dbea1cf1..05ee6285 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -495,6 +495,25 @@ "saveFailed": "无法保存已排除的基础模型:{message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "跳过已下载的模型版本", "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 043c2cf7..2269bf51 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -495,6 +495,25 @@ "saveFailed": "無法儲存已排除的基礎模型:{message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "跳過已下載的模型版本", "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" diff --git a/py/routes/handlers/download_routing_handlers.py b/py/routes/handlers/download_routing_handlers.py index 8107afc5..20fa2086 100644 --- a/py/routes/handlers/download_routing_handlers.py +++ b/py/routes/handlers/download_routing_handlers.py @@ -8,7 +8,10 @@ import logging from aiohttp import web from ...services.download_routing import ( + MODEL_TYPE_TO_DEFAULT_ROOT_SETTING, + MODEL_TYPE_TO_ROOT_RULE_KEY, is_diffusion_model_download, + resolve_download_root, resolve_other_download_sub_type, ) from ...utils.constants import VALID_OTHER_CIVITAI_TYPES @@ -103,18 +106,35 @@ class DownloadRoutingHandler: from ...services.settings_manager import get_settings_manager + settings = get_settings_manager() is_diffusion = is_diffusion_model_download( model_type, file_types=(str(t) for t in file_types), base_model=base_model, - unknown_base_model_default=get_settings_manager().get( + unknown_base_model_default=settings.get( "unknown_base_model_routing", "diffusion_model" ), ) + root_kind = "unet" if is_diffusion else model_type + + # Apply download_root_rules against the final root kind so the + # location-step preview matches where the download will land. + resolved_root = "" + rule_key = MODEL_TYPE_TO_ROOT_RULE_KEY.get(root_kind.lower()) + default_root_setting = MODEL_TYPE_TO_DEFAULT_ROOT_SETTING.get(root_kind.lower()) + if rule_key and default_root_setting: + default_root = settings.get(default_root_setting) or "" + if isinstance(default_root, str) and default_root: + resolved_root = resolve_download_root( + settings.get_download_root_rules().get(rule_key), + base_model, + default_root, + ) return web.json_response( { "success": True, "is_diffusion_model": is_diffusion, - "root_kind": "unet" if is_diffusion else model_type, + "root_kind": root_kind, + "resolved_root": resolved_root, } ) diff --git a/py/services/download_manager.py b/py/services/download_manager.py index dc3d1a5a..d06d64af 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -47,7 +47,11 @@ from ..utils.exif_utils import ExifUtils from ..utils.metadata_manager import MetadataManager from ..utils.sidecar_paths import get_metadata_path, get_preview_dir from .service_registry import ServiceRegistry -from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type +from .download_routing import ( + is_diffusion_model_download, + resolve_download_root, + resolve_other_download_sub_type, +) from .settings_manager import get_settings_manager from .metadata_service import get_default_metadata_provider, get_metadata_provider from .downloader import get_downloader, DownloadProgress, DownloadStreamControl @@ -1494,6 +1498,23 @@ class DownloadManager: self._restored_persisted_downloads = True + @staticmethod + def _resolve_rule_routed_root( + rule_key: str, + base_model: str, + default_root: str, + ) -> str: + """Apply download_root_rules for the final model type's folder key. + + ``rule_key`` is a folder_paths key ("loras"/"checkpoints"/"unet"/ + "embeddings") — a checkpoint download routed to the unet roots + consults the "unet" rules. + """ + if not isinstance(base_model, str) or not base_model: + return default_root + rules = get_settings_manager().get_download_root_rules().get(rule_key) + return resolve_download_root(rules, base_model, default_root) + async def _resolve_download_target_path( self, save_dir: str, @@ -1904,15 +1925,19 @@ class DownloadManager: if is_diffusion_model: default_path = settings_manager.get("default_unet_root") error_msg = "Default unet root path not set in settings" + rule_key = "unet" else: default_path = settings_manager.get("default_checkpoint_root") error_msg = "Default checkpoint root path not set in settings" + rule_key = "checkpoints" if not default_path: return { "success": False, "error": error_msg, } - save_dir = default_path + save_dir = self._resolve_rule_routed_root( + rule_key, base_model_value, default_path + ) elif model_type == "lora": default_path = settings_manager.get("default_lora_root") if not default_path: @@ -1920,7 +1945,9 @@ class DownloadManager: "success": False, "error": "Default lora root path not set in settings", } - save_dir = default_path + save_dir = self._resolve_rule_routed_root( + "loras", base_model_value, default_path + ) elif model_type == "embedding": default_path = settings_manager.get("default_embedding_root") if not default_path: @@ -1928,7 +1955,9 @@ class DownloadManager: "success": False, "error": "Default embedding root path not set in settings", } - save_dir = default_path + save_dir = self._resolve_rule_routed_root( + "embeddings", base_model_value, default_path + ) elif model_type == "other": other_sub_type = resolve_other_download_sub_type( model_type_from_info, diff --git a/py/services/download_routing.py b/py/services/download_routing.py index 175ca9a4..84fbac1f 100644 --- a/py/services/download_routing.py +++ b/py/services/download_routing.py @@ -10,7 +10,7 @@ two can never disagree. from __future__ import annotations import logging -from typing import Iterable, Optional +from typing import Iterable, Mapping, Optional, Sequence from ..utils.constants import ( CHECKPOINT_BASE_MODELS, @@ -30,6 +30,60 @@ DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"}) ROUTING_DIFFUSION_MODEL = "diffusion_model" ROUTING_CHECKPOINT = "checkpoint" +# Download-time model type -> download_root_rules folder key. The rule set is +# keyed by folder_paths keys, so a checkpoint download that resolves to the +# unet roots consults the "unet" rules, not the "checkpoints" ones. +MODEL_TYPE_TO_ROOT_RULE_KEY = { + "lora": "loras", + "checkpoint": "checkpoints", + "unet": "unet", + "embedding": "embeddings", +} + +# Download-time model type -> the setting holding its default download root. +MODEL_TYPE_TO_DEFAULT_ROOT_SETTING = { + "lora": "default_lora_root", + "checkpoint": "default_checkpoint_root", + "unet": "default_unet_root", + "embedding": "default_embedding_root", +} + + +def resolve_download_root( + rules: Optional[Sequence[Mapping[str, object]]], + base_model: str, + default_root: str, +) -> str: + """Return the root from the first matching rule, else ``default_root``. + + Rules are ordered; the first rule whose ``match`` list contains + ``base_model`` wins. Only ``match_type == "base_model"`` rules are + supported (tag matching is a P1 concern). + """ + if not rules or not base_model: + return default_root + + for rule in rules: + if not isinstance(rule, Mapping): + continue + if rule.get("match_type") != "base_model": + continue + match = rule.get("match") + if not isinstance(match, Sequence) or isinstance(match, (str, bytes)): + continue + if base_model not in match: + continue + root = rule.get("root") + if isinstance(root, str) and root.strip(): + logger.info( + "Download root rule matched base model '%s', routing to %s", + base_model, + root, + ) + return root + + return default_root + def is_diffusion_model_download( model_type: str, diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 21b71a12..049a0b03 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -30,6 +30,7 @@ from ..utils.constants import ( DEFAULT_ENABLED_OTHER_SUB_TYPES, DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG, + DOWNLOAD_ROOT_RULE_FOLDER_KEYS, OTHER_SUB_TYPE_FOLDER_KEYS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, VALID_OTHER_SUB_TYPES, @@ -140,6 +141,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "metadata_refresh_skip_paths": [], "skip_previously_downloaded_model_versions": False, "download_skip_base_models": [], + # Ordered per-type rules routing downloads to a non-default root by base + # model; the first matching rule wins, unmatched downloads use default_*_root. + "download_root_rules": {key: [] for key in DOWNLOAD_ROOT_RULE_FOLDER_KEYS}, # Routing target for checkpoint downloads whose baseModel is neither a # known diffusion model nor a known full checkpoint (CHECKPOINT_BASE_MODELS). "unknown_base_model_routing": "diffusion_model", @@ -436,6 +440,19 @@ class SettingsManager: self.settings["download_skip_base_models"] = [] inserted_defaults = True + if "download_root_rules" in self.settings: + normalized_root_rules = self.normalize_download_root_rules( + self.settings.get("download_root_rules") + ) + if normalized_root_rules != self.settings.get("download_root_rules"): + self.settings["download_root_rules"] = normalized_root_rules + updated_existing = True + else: + self.settings["download_root_rules"] = { + key: [] for key in DOWNLOAD_ROOT_RULE_FOLDER_KEYS + } + inserted_defaults = True + if "skip_previously_downloaded_model_versions" not in self.settings: self.settings["skip_previously_downloaded_model_versions"] = False inserted_defaults = True @@ -1505,6 +1522,96 @@ class SettingsManager: self._save_settings() return base_models + def normalize_download_root_rules( + self, value: Any + ) -> Dict[str, List[Dict[str, Any]]]: + """Normalize a ``download_root_rules`` payload. + + Fault-tolerant like the other settings normalizers: unknown type keys, + malformed entries, unknown match types and out-of-whitelist base model + values are dropped silently; a rule whose root is not one of the + configured roots for its type (folder_paths + extra_folder_paths) is + dropped with a warning. + """ + normalized: Dict[str, List[Dict[str, Any]]] = { + key: [] for key in DOWNLOAD_ROOT_RULE_FOLDER_KEYS + } + if not isinstance(value, Mapping): + return normalized + + for key, raw_rules in value.items(): + if key not in DOWNLOAD_ROOT_RULE_FOLDER_KEYS: + continue + if not isinstance(raw_rules, Sequence) or isinstance( + raw_rules, (str, bytes, bytearray) + ): + continue + allowed_roots = self._get_allowed_roots(key) + for raw_rule in raw_rules: + if not isinstance(raw_rule, Mapping): + continue + match_type = raw_rule.get("match_type") + if match_type != "base_model": + logger.warning( + "Dropping download root rule for %s: unsupported match_type %r", + key, + match_type, + ) + continue + match = raw_rule.get("match") + if not isinstance(match, Sequence) or isinstance( + match, (str, bytes, bytearray) + ): + continue + matched_models: List[str] = [] + seen = set() + for candidate in match: + if not isinstance(candidate, str): + continue + token = candidate.strip() + if not token or token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS: + continue + if token in seen: + continue + seen.add(token) + matched_models.append(token) + if not matched_models: + logger.warning( + "Dropping download root rule for %s: no valid base models", + key, + ) + continue + root = raw_rule.get("root") + if not isinstance(root, str) or not root.strip(): + continue + root = root.strip() + if _normalize_root_identity(root) not in allowed_roots: + logger.warning( + "Dropping download root rule for %s: root %s is not a " + "configured root for this model type", + key, + root, + ) + continue + normalized[key].append( + { + "match_type": "base_model", + "match": matched_models, + "root": root, + } + ) + + return normalized + + def get_download_root_rules(self) -> Dict[str, List[Dict[str, Any]]]: + rules = self.normalize_download_root_rules( + self.settings.get("download_root_rules") + ) + if rules != self.settings.get("download_root_rules"): + self.settings["download_root_rules"] = rules + self._save_settings() + return rules + def get_skip_previously_downloaded_model_versions(self) -> bool: value = self.settings.get("skip_previously_downloaded_model_versions", False) if isinstance(value, bool): @@ -1880,6 +1987,8 @@ class SettingsManager: value = self.normalize_metadata_refresh_skip_paths(value) elif key == "download_skip_base_models": value = self.normalize_download_skip_base_models(value) + elif key == "download_root_rules": + value = self.normalize_download_root_rules(value) elif key == "mature_blur_level": value = self.normalize_mature_blur_level(value) elif key == "default_other_roots": diff --git a/py/utils/constants.py b/py/utils/constants.py index 9a9341ca..1cb0f4c7 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -516,3 +516,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset( "Wan Video 2.7", ] ) + +# Model-type keys that download_root_rules understands. These mirror the +# folder_paths keys ("loras"/"checkpoints"/"unet"/"embeddings") so a rule's +# root can be validated against the roots configured for that type. +DOWNLOAD_ROOT_RULE_FOLDER_KEYS = ("loras", "checkpoints", "unet", "embeddings") diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 5b979d43..15b27f11 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -620,6 +620,76 @@ opacity: 0.75; } +.download-root-rule-group { + margin-bottom: var(--space-3); +} + +.download-root-rule-group:last-child { + margin-bottom: 0; +} + +.download-root-rule-group-header { + font-weight: 600; + font-size: 0.95em; + margin-bottom: var(--space-1); +} + +.download-root-rule-toggle { + width: 100%; + min-width: 0; +} + +.download-root-rule-summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-root-rule-root { + width: 100%; + min-width: 0; +} + +.download-root-rule-actions { + display: flex; + gap: 4px; + align-items: center; +} + +.rule-move-btn { + width: 32px; + height: 32px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-base); +} + +.rule-move-btn:hover:not(:disabled) { + border-color: var(--lora-accent); + color: var(--lora-accent); +} + +.rule-move-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.download-root-rules-hint { + font-size: 0.9em; + opacity: 0.6; + padding: 4px 0; +} + +.download-root-rule-panel { + margin-top: var(--space-1); +} + .priority-tags-input:focus { border-color: var(--lora-accent); outline: none; diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 08c0b31d..15c710c6 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -37,6 +37,9 @@ export class DownloadManager { // Manual checkpoint/diffusion root override on the location step: // null follows the auto classification, true/false is user-picked. this._routingOverride = null; + // Root resolved by download_root_rules for the current version + // (null = no rule matched; the default root is preselected). + this._resolvedRoot = null; // Multi-file selection state: selectedFile stays the first selected // file for backward compatibility with single-file flows (#1058). @@ -981,8 +984,10 @@ export class DownloadManager { async proceedToLocationContent() { try { + this._resolvedRoot = null; this._isDiffusionModel = await this._resolveIsDiffusionModel(); this._otherSubType = await this._resolveOtherSubType(); + this._resolvedRoot = await this._resolveRuleRoot(); // Each location-step entry starts from the auto classification; // the user can override it via the checkpoint/diffusion toggle. this._routingOverride = null; @@ -1073,9 +1078,14 @@ export class DownloadManager { } console.log('Default root:', defaultRoot); console.log('Available roots:', rootsData.roots); - if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - console.log(`Setting default root: ${defaultRoot}`); - modelRoot.value = defaultRoot; + // A download_root_rules hit preselects the rule's root so the + // location-step preview matches where the download will land. + const preferredRoot = (this._resolvedRoot && rootsData.roots.includes(this._resolvedRoot)) + ? this._resolvedRoot + : defaultRoot; + if (preferredRoot && rootsData.roots.includes(preferredRoot)) { + console.log(`Setting default root: ${preferredRoot}`); + modelRoot.value = preferredRoot; } document.getElementById('modelRootLabel').textContent = @@ -1172,6 +1182,10 @@ export class DownloadManager { throw new Error(`routing endpoint returned ${response.status}`); } const data = await response.json(); + if (typeof data.resolved_root === 'string' && data.resolved_root) { + // Root a download_root_rules rule would send this download to. + this._resolvedRoot = data.resolved_root; + } if (typeof data.is_diffusion_model === 'boolean') { return data.is_diffusion_model; } @@ -1182,6 +1196,50 @@ export class DownloadManager { return localFileTypeCheck; } + /** + * Resolve the root a download_root_rules rule routes this download to, + * so the location step preselects the same root the backend will use. + * Returns null when no rule matches or the endpoint is unavailable. + */ + async _resolveRuleRoot() { + // The checkpoints page already fetched the routing decision + // (including the rule-resolved root) in _resolveIsDiffusionModel. + if (this.apiClient.modelType === 'checkpoints') { + return this._resolvedRoot ?? null; + } + + // Other-page downloads route by sub_type, not by base model rules; + // without version metadata (e.g. Hugging Face downloads) there is + // no base model to match a rule against. + if (this.apiClient.modelType === 'other' + || (!this.selectedFile && !this.currentVersion)) { + return null; + } + + try { + const fileTypes = this.selectedFile + ? [this.selectedFile.type] + : (this.currentVersion?.files || []).map(f => f.type); + const response = await fetch(DOWNLOAD_ENDPOINTS.routing, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: this.apiClient.modelType.replace(/s$/, ''), + base_model: this.currentVersion?.baseModel || '', + file_types: fileTypes, + }), + }); + if (!response.ok) { + throw new Error(`routing endpoint returned ${response.status}`); + } + const data = await response.json(); + return (typeof data.resolved_root === 'string' && data.resolved_root) || null; + } catch (error) { + console.warn('[download] root rule resolution unavailable:', error); + return null; + } + } + /** * Resolve which other-page sub_type (vae/upscaler/text_encoder/ * clip_vision/controlnet) this download routes to. The backend owns the diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index a0c9baa1..c58a1a41 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -46,6 +46,19 @@ const FILENAME_TEMPLATE_MODEL_TYPES = { embedding: 'embeddings', }; +// Model-type keys understood by the download_root_rules setting; mirrors +// DOWNLOAD_ROOT_RULE_FOLDER_KEYS in py/utils/constants.py. +const DOWNLOAD_ROOT_RULE_TYPES = ['loras', 'checkpoints', 'unet', 'embeddings']; + +// Root-list endpoints per rule type; same data source as the default root +// dropdowns in the Library settings section. +const DOWNLOAD_ROOT_RULE_ROOT_ENDPOINTS = { + loras: '/api/lm/loras/roots', + checkpoints: '/api/lm/checkpoints/checkpoints_roots', + unet: '/api/lm/checkpoints/unet_roots', + embeddings: '/api/lm/embeddings/roots', +}; + export class SettingsManager { constructor() { this.initialized = false; @@ -56,6 +69,10 @@ export class SettingsManager { this.registeredStartupBannerIds = new Set(); this.modelPathsSectionInitialized = false; this.modelPathsDirty = false; + this.downloadRootRuleRoots = null; + this.downloadRootRuleDrafts = {}; + this.downloadRootRuleOpenPanels = new Set(); + this.downloadRootRuleSearches = {}; // Add initialization to sync with modal state this.currentPage = document.body.dataset.page || 'loras'; @@ -203,6 +220,10 @@ export class SettingsManager { backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models ); + merged.download_root_rules = this.normalizeDownloadRootRules( + backendSettings?.download_root_rules ?? defaults.download_root_rules + ); + merged.mature_blur_level = this.normalizeMatureBlurLevel( backendSettings?.mature_blur_level ?? defaults.mature_blur_level ); @@ -254,6 +275,41 @@ export class SettingsManager { return this.normalizePatternList(value).filter(model => allowed.has(model)); } + normalizeDownloadRootRules(value) { + const normalized = { loras: [], checkpoints: [], unet: [], embeddings: [] }; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return normalized; + } + DOWNLOAD_ROOT_RULE_TYPES.forEach((typeKey) => { + const rawRules = value[typeKey]; + if (!Array.isArray(rawRules)) { + return; + } + rawRules.forEach((rule) => { + if (!rule || typeof rule !== 'object') { + return; + } + const match = Array.isArray(rule.match) + ? [...new Set(rule.match + .filter((model) => typeof model === 'string' && model.trim()) + .map((model) => model.trim()))] + : []; + const root = typeof rule.root === 'string' ? rule.root.trim() : ''; + if (!match.length || !root) { + return; + } + normalized[typeKey].push({ match_type: 'base_model', match, root }); + }); + }); + return normalized; + } + + getDownloadRootRules() { + const normalized = this.normalizeDownloadRootRules(state.global.settings.download_root_rules); + state.global.settings.download_root_rules = normalized; + return normalized; + } + registerStartupMessages(messages = []) { if (!Array.isArray(messages) || messages.length === 0) { return; @@ -1248,6 +1304,13 @@ export class SettingsManager { // Load base model path mappings this.loadBaseModelMappings(); + // Load download root rules (async: fetches per-type root lists) + await this.loadDownloadRootRules(); + const downloadRootRulesError = document.getElementById('downloadRootRulesError'); + if (downloadRootRulesError) { + downloadRootRulesError.textContent = ''; + } + // Load library options await this.loadLibraries(); @@ -4369,6 +4432,402 @@ export class SettingsManager { } } + // ── Download Root Rules ───────────────────────────────────────────── + // Ordered per-type rules routing downloads to a non-default root by base + // model. Edits mutate state.global.settings.download_root_rules and are + // persisted immediately via saveSetting; the backend normalizes on save. + + async loadDownloadRootRules() { + const container = document.getElementById('downloadRootRulesContainer'); + if (!container) { + return; + } + await this.fetchDownloadRootRuleRoots(); + this.renderDownloadRootRules(); + } + + async fetchDownloadRootRuleRoots() { + const roots = { loras: [], checkpoints: [], unet: [], embeddings: [] }; + await Promise.all( + Object.entries(DOWNLOAD_ROOT_RULE_ROOT_ENDPOINTS).map(async ([typeKey, url]) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + if (Array.isArray(data.roots)) { + roots[typeKey] = data.roots; + } + } catch (error) { + console.warn(`Failed to load ${typeKey} roots for download root rules:`, error); + } + }) + ); + this.downloadRootRuleRoots = roots; + } + + renderDownloadRootRules() { + const container = document.getElementById('downloadRootRulesContainer'); + if (!container) { + return; + } + container.innerHTML = ''; + DOWNLOAD_ROOT_RULE_TYPES.forEach((typeKey) => { + container.appendChild(this.buildDownloadRootRuleGroup(typeKey)); + }); + } + + buildDownloadRootRuleGroup(typeKey) { + const group = document.createElement('div'); + group.className = 'download-root-rule-group'; + + const header = document.createElement('div'); + header.className = 'download-root-rule-group-header'; + header.textContent = translate(`settings.downloadRootRules.types.${typeKey}`, {}, typeKey); + group.appendChild(header); + + const roots = this.downloadRootRuleRoots?.[typeKey] || []; + if (!roots.length) { + const hint = document.createElement('div'); + hint.className = 'download-root-rules-hint'; + hint.textContent = translate('settings.downloadRootRules.noRoots', {}, 'No roots configured for this model type.'); + group.appendChild(hint); + return group; + } + + const rules = this.getDownloadRootRules()[typeKey]; + rules.forEach((rule, index) => { + group.appendChild(this.buildDownloadRootRuleRow(typeKey, rule, index, false, roots)); + }); + + const draft = this.downloadRootRuleDrafts[typeKey]; + if (draft) { + group.appendChild(this.buildDownloadRootRuleRow(typeKey, draft, rules.length, true, roots)); + } + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'add-mapping-btn'; + addBtn.disabled = !!draft; + addBtn.innerHTML = `${translate('settings.downloadRootRules.addRule', {}, 'Add rule')}`; + addBtn.addEventListener('click', () => this.addDownloadRootRule(typeKey)); + group.appendChild(addBtn); + + return group; + } + + buildDownloadRootRuleRow(typeKey, rule, index, isDraft, roots) { + const row = document.createElement('div'); + row.className = 'mapping-row download-root-rule-row'; + row.dataset.ruleType = typeKey; + row.dataset.ruleIndex = String(index); + + const panelKey = `${typeKey}:${index}`; + const isOpen = this.downloadRootRuleOpenPanels.has(panelKey); + + const controls = document.createElement('div'); + controls.className = 'mapping-controls download-root-rule-controls'; + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'secondary-btn base-model-skip-toggle download-root-rule-toggle'; + toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + const summary = document.createElement('span'); + summary.className = 'download-root-rule-summary'; + this.renderDownloadRootRuleSummary(summary, rule.match); + const toggleLabel = document.createElement('span'); + toggleLabel.className = 'base-model-skip-toggle-label'; + toggleLabel.textContent = isOpen + ? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse') + : translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit'); + toggle.appendChild(summary); + toggle.appendChild(toggleLabel); + toggle.addEventListener('click', () => this.toggleDownloadRootRulePanel(typeKey, index)); + controls.appendChild(toggle); + + const rootSelect = document.createElement('select'); + rootSelect.className = 'base-model-select download-root-rule-root'; + roots.forEach((root) => { + const option = document.createElement('option'); + option.value = root; + option.textContent = root; + rootSelect.appendChild(option); + }); + if (roots.includes(rule.root)) { + rootSelect.value = rule.root; + } + rootSelect.addEventListener('change', () => { + this.updateDownloadRootRuleRoot(typeKey, index, rootSelect.value, isDraft); + }); + controls.appendChild(rootSelect); + + const actions = document.createElement('div'); + actions.className = 'download-root-rule-actions'; + if (!isDraft) { + const ruleCount = this.getDownloadRootRules()[typeKey].length; + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'rule-move-btn'; + upBtn.disabled = index === 0; + upBtn.title = translate('settings.downloadRootRules.moveUp', {}, 'Move up'); + upBtn.innerHTML = ''; + upBtn.addEventListener('click', () => this.moveDownloadRootRule(typeKey, index, -1)); + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'rule-move-btn'; + downBtn.disabled = index === ruleCount - 1; + downBtn.title = translate('settings.downloadRootRules.moveDown', {}, 'Move down'); + downBtn.innerHTML = ''; + downBtn.addEventListener('click', () => this.moveDownloadRootRule(typeKey, index, 1)); + actions.appendChild(upBtn); + actions.appendChild(downBtn); + } + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-mapping-btn'; + removeBtn.title = translate('settings.downloadRootRules.removeRule', {}, 'Remove rule'); + removeBtn.innerHTML = ''; + removeBtn.addEventListener('click', () => this.removeDownloadRootRule(typeKey, index, isDraft)); + actions.appendChild(removeBtn); + controls.appendChild(actions); + row.appendChild(controls); + + const panel = document.createElement('div'); + panel.className = 'base-model-skip-panel download-root-rule-panel'; + panel.hidden = !isOpen; + + const toolbar = document.createElement('div'); + toolbar.className = 'base-model-skip-toolbar'; + const search = document.createElement('input'); + search.type = 'text'; + search.className = 'base-model-skip-search'; + search.placeholder = translate('settings.downloadSkipBaseModels.searchPlaceholder', {}, 'Filter base models...'); + search.value = this.downloadRootRuleSearches[panelKey] || ''; + toolbar.appendChild(search); + panel.appendChild(toolbar); + + const list = document.createElement('div'); + list.className = 'base-model-skip-list'; + panel.appendChild(list); + search.addEventListener('input', () => { + this.downloadRootRuleSearches[panelKey] = search.value; + this.renderDownloadRootRuleBaseModelList(list, typeKey, index, isDraft, summary); + }); + this.renderDownloadRootRuleBaseModelList(list, typeKey, index, isDraft, summary); + + row.appendChild(panel); + return row; + } + + renderDownloadRootRuleSummary(summaryEl, match) { + if (!match.length) { + summaryEl.textContent = translate('settings.downloadSkipBaseModels.summary.none', {}, 'None selected'); + return; + } + if (match.length <= 2) { + summaryEl.textContent = match.join(', '); + return; + } + summaryEl.textContent = translate( + 'settings.downloadSkipBaseModels.summary.count', + { count: match.length }, + `${match.length} selected` + ); + } + + renderDownloadRootRuleBaseModelList(listEl, typeKey, index, isDraft, summaryEl) { + const rule = isDraft + ? this.downloadRootRuleDrafts[typeKey] + : this.getDownloadRootRules()[typeKey][index]; + if (!rule) { + return; + } + const selected = new Set(rule.match); + const query = (this.downloadRootRuleSearches[`${typeKey}:${index}`] || '').trim().toLowerCase(); + const options = this.getAvailableDownloadSkipBaseModels(); + const filtered = query + ? options.filter((baseModel) => baseModel.toLowerCase().includes(query)) + : options; + + listEl.innerHTML = ''; + filtered.forEach((baseModel) => { + const label = document.createElement('label'); + label.className = 'base-model-skip-option'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.value = baseModel; + input.checked = selected.has(baseModel); + input.addEventListener('change', () => { + this.toggleDownloadRootRuleBaseModel(typeKey, index, baseModel, input.checked, isDraft, summaryEl); + }); + const text = document.createElement('span'); + text.textContent = baseModel; + label.appendChild(input); + label.appendChild(text); + listEl.appendChild(label); + }); + } + + toggleDownloadRootRulePanel(typeKey, index) { + const panelKey = `${typeKey}:${index}`; + const opening = !this.downloadRootRuleOpenPanels.has(panelKey); + if (opening) { + this.downloadRootRuleOpenPanels.add(panelKey); + } else { + this.downloadRootRuleOpenPanels.delete(panelKey); + } + this.renderDownloadRootRules(); + if (opening) { + document + .querySelector(`#downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"][data-rule-index="${index}"] .base-model-skip-search`) + ?.focus(); + } + } + + addDownloadRootRule(typeKey) { + if (this.downloadRootRuleDrafts[typeKey]) { + return; + } + const roots = this.downloadRootRuleRoots?.[typeKey] || []; + this.downloadRootRuleDrafts[typeKey] = { + match_type: 'base_model', + match: [], + root: roots[0] || '', + }; + const index = this.getDownloadRootRules()[typeKey].length; + this.downloadRootRuleOpenPanels.add(`${typeKey}:${index}`); + this.renderDownloadRootRules(); + } + + removeDownloadRootRule(typeKey, index, isDraft) { + if (isDraft) { + delete this.downloadRootRuleDrafts[typeKey]; + } else { + const rules = this.getDownloadRootRules(); + rules[typeKey].splice(index, 1); + } + // Row indices shift after removal; open panels track positions, so + // close them all rather than reopening the wrong rule. + this.downloadRootRuleOpenPanels.clear(); + this.downloadRootRuleSearches = {}; + this.renderDownloadRootRules(); + if (!isDraft) { + this.persistDownloadRootRules(); + } + } + + moveDownloadRootRule(typeKey, index, direction) { + const rules = this.getDownloadRootRules(); + const list = rules[typeKey]; + const target = index + direction; + if (target < 0 || target >= list.length) { + return; + } + [list[index], list[target]] = [list[target], list[index]]; + this.downloadRootRuleOpenPanels.clear(); + this.downloadRootRuleSearches = {}; + this.renderDownloadRootRules(); + this.persistDownloadRootRules(); + } + + updateDownloadRootRuleRoot(typeKey, index, root, isDraft) { + if (isDraft) { + const draft = this.downloadRootRuleDrafts[typeKey]; + if (draft) { + draft.root = root; + } + return; + } + const rules = this.getDownloadRootRules(); + const rule = rules[typeKey][index]; + if (!rule || rule.root === root) { + return; + } + rule.root = root; + this.persistDownloadRootRules(); + } + + toggleDownloadRootRuleBaseModel(typeKey, index, baseModel, checked, isDraft, summaryEl) { + if (isDraft) { + const draft = this.downloadRootRuleDrafts[typeKey]; + if (!draft) { + return; + } + draft.match = checked + ? [...draft.match, baseModel] + : draft.match.filter((model) => model !== baseModel); + if (!draft.match.length) { + if (summaryEl) { + this.renderDownloadRootRuleSummary(summaryEl, draft.match); + } + return; + } + // First base model on a draft promotes it to a persisted rule. + const rules = this.getDownloadRootRules(); + rules[typeKey].push({ + match_type: 'base_model', + match: [...draft.match], + root: draft.root, + }); + delete this.downloadRootRuleDrafts[typeKey]; + this.downloadRootRuleOpenPanels.delete(`${typeKey}:${index}`); + this.downloadRootRuleOpenPanels.add(`${typeKey}:${rules[typeKey].length - 1}`); + this.renderDownloadRootRules(); + this.persistDownloadRootRules(); + return; + } + + const rules = this.getDownloadRootRules(); + const rule = rules[typeKey][index]; + if (!rule) { + return; + } + rule.match = checked + ? [...rule.match, baseModel] + : rule.match.filter((model) => model !== baseModel); + if (!rule.match.length) { + // A rule without base models can never match; drop it (the + // backend normalization would discard it anyway). + rules[typeKey].splice(index, 1); + this.downloadRootRuleOpenPanels.clear(); + this.downloadRootRuleSearches = {}; + this.renderDownloadRootRules(); + this.persistDownloadRootRules(); + return; + } + if (summaryEl) { + this.renderDownloadRootRuleSummary(summaryEl, rule.match); + } + this.persistDownloadRootRules(); + } + + async persistDownloadRootRules() { + const payload = this.getDownloadRootRules(); + const errorElement = document.getElementById('downloadRootRulesError'); + try { + if (errorElement) { + errorElement.textContent = ''; + } + await this.saveSetting('download_root_rules', payload); + showToast( + 'toast.settings.settingsUpdated', + { setting: translate('settings.downloadRootRules.title', {}, 'Download Root Rules') }, + 'success' + ); + } catch (error) { + console.error('Failed to save download root rules:', error); + if (errorElement) { + errorElement.textContent = translate( + 'settings.downloadRootRules.validation.saveFailed', + { message: error.message }, + `Unable to save download root rules: ${error.message}` + ); + } + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + } + } + async saveMetadataRefreshSkipPaths() { const input = document.getElementById('metadataRefreshSkipPaths'); const errorElement = document.getElementById('metadataRefreshSkipPathsError'); diff --git a/static/js/state/index.js b/static/js/state/index.js index d7e4f593..a9c8cede 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -64,6 +64,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ metadata_refresh_skip_paths: [], skip_previously_downloaded_model_versions: false, download_skip_base_models: [], + download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] }, unknown_base_model_routing: 'diffusion_model', backup_auto_enabled: true, backup_retention_count: 5, @@ -87,6 +88,7 @@ export function createDefaultSettings() { download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, default_other_roots: {}, + download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] }, enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'], // Standalone-only fields populated by GET /api/lm/settings; in plugin // mode the backend omits folder_paths/folder_path_schema and these diff --git a/templates/components/modals/settings/organization.html b/templates/components/modals/settings/organization.html index 41d74bc0..7edeb8d1 100644 --- a/templates/components/modals/settings/organization.html +++ b/templates/components/modals/settings/organization.html @@ -114,6 +114,22 @@ +
+
+
+ +
+
+
{{ t('settings.downloadRootRules.description') }}
+
+
+
+
+
+ {{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
diff --git a/tests/frontend/managers/settingsManager.downloadRootRules.test.js b/tests/frontend/managers/settingsManager.downloadRootRules.test.js new file mode 100644 index 00000000..690c88e8 --- /dev/null +++ b/tests/frontend/managers/settingsManager.downloadRootRules.test.js @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { + closeModal: vi.fn(), + }, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../../../static/js/state/index.js', () => { + const settings = {}; + return { + state: { + global: { + settings, + }, + }, + createDefaultSettings: () => ({ + language: 'en', + download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] }, + }), + }; +}); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + resetAndReload: vi.fn(), +})); + +vi.mock('../../../static/js/utils/constants.js', () => ({ + DOWNLOAD_PATH_TEMPLATES: {}, + DEFAULT_PATH_TEMPLATES: {}, + MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'], + PATH_TEMPLATE_PLACEHOLDERS: {}, + FILENAME_TEMPLATE_PLACEHOLDERS: [], + DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' }, + DEFAULT_PRIORITY_TAG_CONFIG: { + lora: 'character, style', + checkpoint: 'base, guide', + embedding: 'hint', + }, + getMappableBaseModelsDynamic: () => ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'], +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: (key, params, fallback) => { + if (key === 'settings.downloadSkipBaseModels.summary.none') { + return 'None selected'; + } + if (key === 'settings.downloadSkipBaseModels.summary.count') { + return `${params?.count ?? 0} selected`; + } + return fallback ?? ''; + }, +})); + +vi.mock('../../../static/js/i18n/index.js', () => ({ + i18n: { + getCurrentLocale: () => 'en', + setLanguage: vi.fn().mockResolvedValue(), + }, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock('../../../static/js/managers/BannerService.js', () => ({ + bannerService: { + registerBanner: vi.fn(), + }, +})); + +import { SettingsManager } from '../../../static/js/managers/SettingsManager.js'; +import { state } from '../../../static/js/state/index.js'; + +const ROOTS = { + loras: ['/models/loras-main', '/models/lora-archive'], + checkpoints: ['/models/checkpoints'], + unet: ['/models/unet-main', '/models/unet-archive'], + embeddings: [], +}; + +const createManager = () => { + const initSettingsSpy = vi + .spyOn(SettingsManager.prototype, 'initializeSettings') + .mockResolvedValue(); + const initializeSpy = vi + .spyOn(SettingsManager.prototype, 'initialize') + .mockImplementation(() => {}); + + const manager = new SettingsManager(); + + initSettingsSpy.mockRestore(); + initializeSpy.mockRestore(); + + manager.saveSetting = vi.fn().mockResolvedValue(); + manager.downloadRootRuleRoots = ROOTS; + + return manager; +}; + +const appendDownloadRootRulesUi = () => { + document.body.innerHTML = ` +
+
+ `; +}; + +const groupRows = (typeKey) => + document.querySelectorAll( + `#downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"]` + ); + +describe('SettingsManager download root rules', () => { + beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + state.global.settings = { + download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] }, + }; + }); + + it('normalizes malformed rules out of the setting', () => { + const manager = createManager(); + + const normalized = manager.normalizeDownloadRootRules({ + loras: [ + { match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }, + { match_type: 'base_model', match: [], root: '/models/lora-archive' }, + { match_type: 'base_model', match: ['Pony'], root: '' }, + null, + 'not-a-rule', + ], + embeddings: 'oops', + unknownType: [{ match_type: 'base_model', match: ['Pony'], root: '/x' }], + }); + + expect(normalized).toEqual({ + loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }], + checkpoints: [], + unet: [], + embeddings: [], + }); + }); + + it('renders one group per type and a hint when no roots are configured', () => { + appendDownloadRootRulesUi(); + state.global.settings.download_root_rules = { + loras: [{ match_type: 'base_model', match: ['Flux.1 D', 'Pony'], root: '/models/lora-archive' }], + checkpoints: [], + unet: [], + embeddings: [], + }; + const manager = createManager(); + + manager.renderDownloadRootRules(); + + expect(document.querySelectorAll('.download-root-rule-group')).toHaveLength(4); + expect(groupRows('loras')).toHaveLength(1); + expect(groupRows('loras')[0].querySelector('.download-root-rule-summary').textContent) + .toBe('Flux.1 D, Pony'); + expect(groupRows('loras')[0].querySelector('select.download-root-rule-root').value) + .toBe('/models/lora-archive'); + // embeddings has no roots configured + const embeddingsGroup = document.querySelectorAll('.download-root-rule-group')[3]; + expect(embeddingsGroup.querySelector('.download-root-rules-hint')).not.toBeNull(); + expect(embeddingsGroup.querySelector('.add-mapping-btn')).toBeNull(); + }); + + it('add rule creates a draft and the first base model selection promotes and persists it', async () => { + appendDownloadRootRulesUi(); + const manager = createManager(); + manager.renderDownloadRootRules(); + + manager.addDownloadRootRule('loras'); + + expect(manager.downloadRootRuleDrafts.loras).toEqual({ + match_type: 'base_model', + match: [], + root: '/models/loras-main', + }); + expect(groupRows('loras')).toHaveLength(1); + // A second add while a draft exists is a no-op + manager.addDownloadRootRule('loras'); + expect(groupRows('loras')).toHaveLength(1); + + // The draft row panel is open; tick a base model checkbox + const checkbox = groupRows('loras')[0].querySelector( + '.download-root-rule-panel input[value="Flux.1 D"]' + ); + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(manager.saveSetting).toHaveBeenCalled(); + }); + + expect(manager.downloadRootRuleDrafts.loras).toBeUndefined(); + expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', { + loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/loras-main' }], + checkpoints: [], + unet: [], + embeddings: [], + }); + expect(state.global.settings.download_root_rules.loras).toHaveLength(1); + }); + + it('removing a rule persists the updated list', async () => { + appendDownloadRootRulesUi(); + state.global.settings.download_root_rules = { + loras: [ + { match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }, + { match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' }, + ], + checkpoints: [], + unet: [], + embeddings: [], + }; + const manager = createManager(); + manager.renderDownloadRootRules(); + + manager.removeDownloadRootRule('loras', 0, false); + await vi.waitFor(() => { + expect(manager.saveSetting).toHaveBeenCalled(); + }); + + expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', { + loras: [{ match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' }], + checkpoints: [], + unet: [], + embeddings: [], + }); + }); + + it('move down swaps rule order and persists', async () => { + appendDownloadRootRulesUi(); + state.global.settings.download_root_rules = { + loras: [ + { match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }, + { match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' }, + ], + checkpoints: [], + unet: [], + embeddings: [], + }; + const manager = createManager(); + manager.renderDownloadRootRules(); + + manager.moveDownloadRootRule('loras', 0, 1); + await vi.waitFor(() => { + expect(manager.saveSetting).toHaveBeenCalled(); + }); + + expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', { + loras: [ + { match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' }, + { match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }, + ], + checkpoints: [], + unet: [], + embeddings: [], + }); + + // Out-of-range moves are no-ops + manager.saveSetting.mockClear(); + manager.moveDownloadRootRule('loras', 1, 1); + expect(manager.saveSetting).not.toHaveBeenCalled(); + }); + + it('unchecking the last base model of a persisted rule removes the rule', async () => { + appendDownloadRootRulesUi(); + state.global.settings.download_root_rules = { + loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }], + checkpoints: [], + unet: [], + embeddings: [], + }; + const manager = createManager(); + manager.renderDownloadRootRules(); + + const row = groupRows('loras')[0]; + const checkbox = row.querySelector('.download-root-rule-panel input[value="Flux.1 D"]'); + expect(checkbox.checked).toBe(true); + checkbox.checked = false; + checkbox.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(manager.saveSetting).toHaveBeenCalled(); + }); + + expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', { + loras: [], + checkpoints: [], + unet: [], + embeddings: [], + }); + expect(groupRows('loras')).toHaveLength(0); + }); + + it('loadDownloadRootRules fetches roots for every type and renders', async () => { + appendDownloadRootRulesUi(); + const fetchMock = vi.fn().mockImplementation(async (url) => ({ + ok: true, + json: async () => { + if (url.includes('loras/roots')) return { roots: ROOTS.loras }; + if (url.includes('checkpoints_roots')) return { roots: ROOTS.checkpoints }; + if (url.includes('unet_roots')) return { roots: ROOTS.unet }; + if (url.includes('embeddings/roots')) return { roots: [] }; + return {}; + }, + })); + vi.stubGlobal('fetch', fetchMock); + try { + const manager = createManager(); + manager.downloadRootRuleRoots = null; + + await manager.loadDownloadRootRules(); + + expect(manager.downloadRootRuleRoots).toEqual(ROOTS); + expect(document.querySelectorAll('.download-root-rule-group')).toHaveLength(4); + expect(document.getElementById('downloadRootRulesError').textContent).toBe(''); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/tests/routes/test_download_routing_handlers.py b/tests/routes/test_download_routing_handlers.py index 05695dfa..d26b7ced 100644 --- a/tests/routes/test_download_routing_handlers.py +++ b/tests/routes/test_download_routing_handlers.py @@ -44,7 +44,14 @@ async def test_diffusion_base_model_routes_to_unet(): ) payload = json.loads(response.text) assert response.status == 200 - assert payload == {"success": True, "is_diffusion_model": True, "root_kind": "unet"} + assert payload == { + "success": True, + "is_diffusion_model": True, + "root_kind": "unet", + # No rule matches "Anima", so the resolved root falls back to the + # auto-configured default unet root. + "resolved_root": get_settings_manager().get("default_unet_root"), + } @pytest.mark.asyncio @@ -245,3 +252,57 @@ async def test_other_routing_disabled_for_switched_off_sub_type(): assert payload["disabled"] is True assert payload["reason"] == "other_sub_type_disabled" assert payload["requested_sub_type"] == "upscaler" + + +@pytest.mark.asyncio +async def test_resolved_root_falls_back_to_default_root(): + manager = get_settings_manager() + manager.settings["default_lora_root"] = "/loras-main" + + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest({"model_type": "lora", "base_model": "Pony", "file_types": []}) + ) + payload = json.loads(response.text) + assert payload["resolved_root"] == "/loras-main" + + +@pytest.mark.asyncio +async def test_resolved_root_applies_download_root_rules(): + manager = get_settings_manager() + manager.settings["folder_paths"] = {"loras": ["/loras-main", "/lora-archive"]} + manager.settings["default_lora_root"] = "/loras-main" + manager.settings["download_root_rules"] = { + "loras": [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/lora-archive"} + ] + } + + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest({"model_type": "lora", "base_model": "Flux.1 D", "file_types": []}) + ) + payload = json.loads(response.text) + assert payload["resolved_root"] == "/lora-archive" + + +@pytest.mark.asyncio +async def test_resolved_root_applies_unet_rules_after_diffusion_routing(): + manager = get_settings_manager() + manager.settings["folder_paths"] = {"unet": ["/unet-main", "/unet-archive"]} + manager.settings["default_unet_root"] = "/unet-main" + manager.settings["download_root_rules"] = { + "unet": [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/unet-archive"} + ] + } + + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest( + {"model_type": "checkpoint", "base_model": "Flux.1 D", "file_types": ["Model"]} + ) + ) + payload = json.loads(response.text) + assert payload["root_kind"] == "unet" + assert payload["resolved_root"] == "/unet-archive" diff --git a/tests/services/test_download_manager_basic.py b/tests/services/test_download_manager_basic.py index 39066cb6..80c8c3fc 100644 --- a/tests/services/test_download_manager_basic.py +++ b/tests/services/test_download_manager_basic.py @@ -2362,3 +2362,182 @@ async def test_fetch_raw_file_name_edge_cases(): # Provider exceptions degrade to None. provider.get_version_file_mini = AsyncMock(side_effect=RuntimeError("boom")) assert await manager._fetch_raw_file_name(provider, 1, 2) is None + + +def _capture_execute_download(monkeypatch, captured): + async def fake_execute_download(self, **kwargs): + captured.update( + { + "save_dir": kwargs["save_dir"], + "relative_path": kwargs["relative_path"], + "model_type": kwargs["model_type"], + } + ) + return {"success": True} + + monkeypatch.setattr( + DownloadManager, "_execute_download", fake_execute_download, raising=False + ) + + +def _lora_payload(base_model): + return { + "id": 1234, + "model": {"type": "LORA", "tags": ["fantasy"]}, + "baseModel": base_model, + "creator": {"username": "Author"}, + "files": [ + { + "type": "Model", + "primary": True, + "downloadUrl": "https://example.invalid/file.safetensors", + "name": "file.safetensors", + } + ], + } + + +@pytest.mark.asyncio +async def test_download_root_rule_routes_lora_to_rule_root( + monkeypatch, scanners, metadata_provider, tmp_path +): + """A matching download_root_rules rule replaces the default lora root.""" + manager = DownloadManager() + + default_root = tmp_path / "loras-main" + archive_root = tmp_path / "lora-archive" + settings = get_settings_manager() + settings.settings["folder_paths"] = { + "loras": [str(default_root), str(archive_root)] + } + settings.settings["default_lora_root"] = str(default_root) + settings.settings["download_root_rules"] = { + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D"], + "root": str(archive_root), + } + ] + } + metadata_provider.payload = _lora_payload("Flux.1 D") + + captured = {} + _capture_execute_download(monkeypatch, captured) + + result = await manager.download_from_civitai( + model_version_id=99, + save_dir=str(tmp_path), + use_default_paths=True, + progress_callback=None, + source=None, + ) + + assert result["success"] is True + assert captured["model_type"] == "lora" + assert captured["save_dir"] == str(archive_root / "Flux.1 D" / "fantasy") + + +@pytest.mark.asyncio +async def test_download_root_rule_falls_back_to_default_root( + monkeypatch, scanners, metadata_provider, tmp_path +): + """A non-matching base model keeps the default root.""" + manager = DownloadManager() + + default_root = tmp_path / "loras-main" + archive_root = tmp_path / "lora-archive" + settings = get_settings_manager() + settings.settings["folder_paths"] = { + "loras": [str(default_root), str(archive_root)] + } + settings.settings["default_lora_root"] = str(default_root) + settings.settings["download_root_rules"] = { + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D"], + "root": str(archive_root), + } + ] + } + metadata_provider.payload = _lora_payload("Pony") + + captured = {} + _capture_execute_download(monkeypatch, captured) + + result = await manager.download_from_civitai( + model_version_id=99, + save_dir=str(tmp_path), + use_default_paths=True, + progress_callback=None, + source=None, + ) + + assert result["success"] is True + assert str(captured["save_dir"]).startswith(str(default_root)) + + +@pytest.mark.asyncio +async def test_download_root_rule_applies_to_final_unet_type( + monkeypatch, scanners, metadata_provider, tmp_path +): + """A checkpoint download routed to unet consults the unet rules, not + the checkpoints rules.""" + manager = DownloadManager() + + unet_default = tmp_path / "unet-main" + unet_archive = tmp_path / "unet-archive" + checkpoint_root = tmp_path / "checkpoints" + settings = get_settings_manager() + settings.settings["folder_paths"] = { + "checkpoints": [str(checkpoint_root)], + "unet": [str(unet_default), str(unet_archive)], + } + settings.settings["default_checkpoint_root"] = str(checkpoint_root) + settings.settings["default_unet_root"] = str(unet_default) + settings.settings["download_root_rules"] = { + "checkpoints": [ + { + "match_type": "base_model", + "match": ["Flux.1 D"], + "root": str(checkpoint_root), + } + ], + "unet": [ + { + "match_type": "base_model", + "match": ["Flux.1 D"], + "root": str(unet_archive), + } + ], + } + metadata_provider.payload = { + "id": 1234, + "model": {"type": "Checkpoint", "tags": ["fantasy"]}, + "baseModel": "Flux.1 D", + "creator": {"username": "Author"}, + "files": [ + { + "type": "Model", + "primary": True, + "downloadUrl": "https://example.invalid/file.safetensors", + "name": "file.safetensors", + } + ], + } + + captured = {} + _capture_execute_download(monkeypatch, captured) + + result = await manager.download_from_civitai( + model_version_id=99, + save_dir=str(tmp_path), + use_default_paths=True, + progress_callback=None, + source=None, + ) + + assert result["success"] is True + assert captured["model_type"] == "checkpoint" + assert captured["save_dir"] == str(unet_archive / "Flux.1 D" / "fantasy") diff --git a/tests/services/test_download_routing.py b/tests/services/test_download_routing.py index b118b0a6..d363b5fd 100644 --- a/tests/services/test_download_routing.py +++ b/tests/services/test_download_routing.py @@ -205,3 +205,69 @@ class TestResolveOtherDownloadSubType: def test_model_type_matching_is_case_insensitive(self): assert resolve_other_download_sub_type("vAe") == "vae" + + +from py.services.download_routing import resolve_download_root + + +def test_resolve_download_root_returns_default_without_rules(): + assert resolve_download_root(None, "Flux.1 D", "/default") == "/default" + assert resolve_download_root([], "Flux.1 D", "/default") == "/default" + + +def test_resolve_download_root_returns_default_without_base_model(): + rules = [{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/nas"}] + assert resolve_download_root(rules, "", "/default") == "/default" + + +def test_resolve_download_root_first_match_wins(): + rules = [ + {"match_type": "base_model", "match": ["Flux.1 D", "Wan Video"], "root": "/nas"}, + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/other"}, + ] + assert resolve_download_root(rules, "Flux.1 D", "/default") == "/nas" + assert resolve_download_root(rules, "Wan Video", "/default") == "/nas" + + +def test_resolve_download_root_falls_back_when_no_rule_matches(): + rules = [{"match_type": "base_model", "match": ["Pony"], "root": "/nas"}] + assert resolve_download_root(rules, "Flux.1 D", "/default") == "/default" + + +def test_resolve_download_root_skips_malformed_rules(): + rules = [ + {"match_type": "tags", "match": ["Flux.1 D"], "root": "/nas"}, + {"match_type": "base_model", "match": "Flux.1 D", "root": "/nas"}, + "not-a-dict", + {"match_type": "base_model", "match": ["Flux.1 D"], "root": ""}, + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/nas"}, + ] + assert resolve_download_root(rules, "Flux.1 D", "/default") == "/nas" + + +def test_resolve_download_root_rule_lists_are_per_type(): + """Checkpoint and unet rule sets are consulted independently.""" + rules_by_type = { + "checkpoints": [ + {"match_type": "base_model", "match": ["SDXL 1.0"], "root": "/ckpt-nas"} + ], + "unet": [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/unet-nas"} + ], + } + assert ( + resolve_download_root(rules_by_type["checkpoints"], "SDXL 1.0", "/default") + == "/ckpt-nas" + ) + assert ( + resolve_download_root(rules_by_type["unet"], "SDXL 1.0", "/default") + == "/default" + ) + assert ( + resolve_download_root(rules_by_type["unet"], "Flux.1 D", "/default") + == "/unet-nas" + ) + assert ( + resolve_download_root(rules_by_type["checkpoints"], "Flux.1 D", "/default") + == "/default" + ) diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index afa16004..873e12cd 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -1566,3 +1566,196 @@ def test_migrate_sanitizes_legacy_libraries_includes_other_roots(tmp_path, monke payload = manager.get_libraries()["legacy"] assert payload["default_other_roots"] == {} + + +def _configure_lora_roots(manager, *roots): + manager.settings["folder_paths"] = {"loras": [str(root) for root in roots]} + + +def test_download_root_rules_defaults_to_empty_per_type(manager): + assert manager.get_download_root_rules() == { + "loras": [], + "checkpoints": [], + "unet": [], + "embeddings": [], + } + + +def test_download_root_rules_normalize_keeps_valid_rules_in_order(manager): + _configure_lora_roots(manager, "/loras-main", "/loras-archive") + manager.settings["download_root_rules"] = { + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D", "Wan Video"], + "root": "/loras-archive", + }, + {"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"}, + ] + } + + result = manager.get_download_root_rules() + + assert result["loras"] == [ + { + "match_type": "base_model", + "match": ["Flux.1 D", "Wan Video"], + "root": "/loras-archive", + }, + {"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"}, + ] + assert manager.settings["download_root_rules"]["loras"] == result["loras"] + + +def test_download_root_rules_normalize_drops_invalid_match_values(manager): + _configure_lora_roots(manager, "/loras-archive") + manager.settings["download_root_rules"] = { + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D", "Not A Model", "Flux.1 D", 42, ""], + "root": "/loras-archive", + } + ] + } + + result = manager.get_download_root_rules() + + assert result["loras"] == [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"} + ] + + +def test_download_root_rules_normalize_drops_rule_with_unknown_root( + manager, caplog +): + _configure_lora_roots(manager, "/loras-main") + manager.settings["download_root_rules"] = { + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D"], + "root": "/not-configured", + }, + {"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"}, + ] + } + + with caplog.at_level(logging.WARNING): + result = manager.get_download_root_rules() + + assert result["loras"] == [ + {"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"} + ] + assert "/not-configured" in caplog.text + + +def test_download_root_rules_root_matches_with_slash_normalization(manager): + _configure_lora_roots(manager, "/loras-archive/") + manager.settings["download_root_rules"] = { + "loras": [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"} + ] + } + + result = manager.get_download_root_rules() + + assert result["loras"][0]["root"] == "/loras-archive" + + +def test_download_root_rules_accepts_extra_folder_paths_roots(manager): + _configure_lora_roots(manager, "/loras-main") + manager.settings["extra_folder_paths"] = {"loras": ["/loras-extra"]} + manager.settings["download_root_rules"] = { + "loras": [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-extra"} + ] + } + + result = manager.get_download_root_rules() + + assert result["loras"][0]["root"] == "/loras-extra" + + +def test_download_root_rules_normalize_drops_unknown_match_type(manager, caplog): + _configure_lora_roots(manager, "/loras-archive") + manager.settings["download_root_rules"] = { + "loras": [ + {"match_type": "tags", "match": ["Flux.1 D"], "root": "/loras-archive"} + ] + } + + with caplog.at_level(logging.WARNING): + result = manager.get_download_root_rules() + + assert result["loras"] == [] + assert "tags" in caplog.text + + +def test_download_root_rules_normalize_drops_malformed_input(manager): + _configure_lora_roots(manager, "/loras-archive") + + assert manager.normalize_download_root_rules(None) == { + "loras": [], + "checkpoints": [], + "unet": [], + "embeddings": [], + } + assert manager.normalize_download_root_rules("not-a-dict")["loras"] == [] + assert manager.normalize_download_root_rules({"loras": "not-a-list"})[ + "loras" + ] == [] + assert manager.normalize_download_root_rules({"loras": ["not-a-dict", 42]})[ + "loras" + ] == [] + # Unknown type keys are dropped; known keys survive. + result = manager.normalize_download_root_rules( + { + "videos": [ + {"match_type": "base_model", "match": ["Pony"], "root": "/x"} + ], + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D"], + "root": "/loras-archive", + } + ], + } + ) + assert "videos" not in result + assert len(result["loras"]) == 1 + + +def test_download_root_rules_normalize_drops_rule_with_empty_match(manager): + _configure_lora_roots(manager, "/loras-archive") + manager.settings["download_root_rules"] = { + "loras": [ + {"match_type": "base_model", "match": ["Not A Model"], "root": "/loras-archive"} + ] + } + + result = manager.get_download_root_rules() + + assert result["loras"] == [] + + +def test_set_download_root_rules_normalizes(manager): + _configure_lora_roots(manager, "/loras-main", "/loras-archive") + + manager.set( + "download_root_rules", + { + "loras": [ + { + "match_type": "base_model", + "match": ["Flux.1 D", "Invalid"], + "root": "/loras-archive", + } + ] + }, + ) + + assert manager.get("download_root_rules")["loras"] == [ + {"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"} + ]