feat(download): add download root rules routing downloads by base model

Add a download_root_rules setting: ordered per-type rules
({match_type: base_model, match: [...], root: path}) that replace the
default download root when 'use default paths' is on. The first matching
rule wins; unmatched downloads fall back to default_*_root. Rules are
consulted under the final download type, so a checkpoint routed to the
unet roots uses the unet rule set.

- settings_manager: normalize/get/set with root validation against the
  configured roots (folder_paths + extra_folder_paths); invalid rules
  are dropped with a warning
- download_manager: apply the resolved root in the use_default_paths
  branches for lora/checkpoint(unet)/embedding downloads
- download routing handler: expose resolved_root so the download
  confirmation modal preselects the rule-routed root
- settings UI: per-type rule editor in the Organization section with
  base-model multi-select panels, root dropdowns, reorder and remove
This commit is contained in:
Will Miao
2026-10-09 11:50:26 +08:00
parent 1a6b0f78a3
commit 5dc2109d7c
25 changed files with 1849 additions and 11 deletions
+19
View File
@@ -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."
+19
View File
@@ -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."
+19
View File
@@ -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."
+19
View File
@@ -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."
+19
View File
@@ -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 ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
+19
View File
@@ -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はそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
+19
View File
@@ -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는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
+19
View File
@@ -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 будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
+19
View File
@@ -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 将跳过下载该模型版本。适用于所有下载流程。"
+19
View File
@@ -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 將跳過下載該模型版本。適用於所有下載流程。"
@@ -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,
}
)
+33 -4
View File
@@ -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,
+55 -1
View File
@@ -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,
+109
View File
@@ -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":
+5
View File
@@ -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")
@@ -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;
+61 -3
View File
@@ -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
+459
View File
@@ -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 = `<i class="fas fa-plus"></i><span>${translate('settings.downloadRootRules.addRule', {}, 'Add rule')}</span>`;
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 = '<i class="fas fa-arrow-up"></i>';
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 = '<i class="fas fa-arrow-down"></i>';
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 = '<i class="fas fa-times"></i>';
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');
+2
View File
@@ -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
@@ -114,6 +114,22 @@
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.downloadRootRules.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadRootRules.help') }}"></i>
</label>
</div>
</div>
<div class="input-help">{{ t('settings.downloadRootRules.description') }}</div>
<div class="mappings-container">
<div id="downloadRootRulesContainer"></div>
</div>
<div class="settings-input-error-message" id="downloadRootRulesError"></div>
</div>
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
<div class="setting-item">
@@ -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 = `
<div id="downloadRootRulesContainer"></div>
<div id="downloadRootRulesError"></div>
`;
};
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();
}
});
});
+62 -1
View File
@@ -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"
@@ -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")
+66
View File
@@ -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"
)
+193
View File
@@ -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"}
]