feat(backend): gate Other Models behind opt-in management toggles

Other Models management is now opt-in: enable_other_models (default false)
plus the enabled_other_sub_types allow-list replace the unreleased additive
enabled_other_folders key.

- config._get_enabled_other_folder_keys() is the single scan gate; a new
  refresh_other_roots() rebuilds roots and preview roots on toggle.
- ModelScanner gains a _should_keep_cached_entry() hydration hook and
  on_library_changed(reconcile=...) so switching a sub_type off drops its
  entries (and hash/autov3 rows) at load time and switching it on rescans.
- OtherScanner filters location-derived entries accordingly.
- Other routes reject every other type while off (or a disabled sub_type) and
  expose an "other_disabled" page flag; download routing returns a disabled
  marker instead of guessing; the download manager refuses other-type
  downloads and default-path routing for switched-off sub_types.
- Doctor / init-status / refresh-all skip the other scanner while off; the
  scanner stays registered so staged pending-deletes still merge.
- Tests updated with explicit opt-in fixtures plus new gating coverage.
This commit is contained in:
Will Miao
2026-09-13 07:59:28 +08:00
parent f88fe2665c
commit 28fbb86dce
19 changed files with 583 additions and 84 deletions
+20
View File
@@ -1526,6 +1526,15 @@ class DownloadManager:
elif model_type_from_info == "textualinversion":
model_type = "embedding"
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
if not get_settings_manager().is_other_models_enabled():
return {
"success": False,
"error": (
"Other Models management is disabled. Enable it in "
"Settings > Library before downloading VAE, upscaler, "
"text encoder or CLIP files."
),
}
model_type = "other"
else:
return {
@@ -1774,6 +1783,17 @@ class DownloadManager:
default_other_roots = (
settings_manager.get("default_other_roots") or {}
)
if other_sub_type and not settings_manager.is_other_sub_type_enabled(
other_sub_type
):
return {
"success": False,
"error": (
f"Other-model sub-type '{other_sub_type}' is "
f"disabled in settings. Please pick a destination "
f"folder explicitly instead of using default paths."
),
}
default_path = (
default_other_roots.get(other_sub_type)
if other_sub_type
+46 -8
View File
@@ -210,8 +210,14 @@ class ModelScanner:
"""
self._cache_version += 1
def on_library_changed(self) -> None:
"""Reset caches when the active library changes."""
def on_library_changed(self, reconcile: bool = False) -> None:
"""Reset caches when the active library changes.
When ``reconcile`` is True an incremental reconcile runs right after
the cache is re-hydrated, so newly configured roots are scanned and
entries for removed roots are purged. Used when scanner-affecting
settings (e.g. the Other Models toggles) change.
"""
self._persistent_cache = get_persistent_cache()
self._cache = None
self._hash_index = ModelHashIndex()
@@ -229,7 +235,7 @@ class ModelScanner:
if loop and not loop.is_closed():
self._loop = loop
self.loop = loop
loop.create_task(self.initialize_in_background())
loop.create_task(self.initialize_in_background(reconcile=reconcile))
def _resolve_name_display_mode(self) -> str:
"""Return the configured display mode for name sorting."""
@@ -460,8 +466,14 @@ class ModelScanner:
_, license_flags = resolve_license_info(license_source)
entry['license_flags'] = license_flags
async def initialize_in_background(self) -> None:
"""Initialize cache in background using thread pool"""
async def initialize_in_background(self, reconcile: bool = False) -> None:
"""Initialize cache in background using thread pool
Args:
reconcile: When True and a persisted snapshot is hydrated, run an
incremental reconcile afterwards so the cache matches the
current root configuration.
"""
try:
# Set initial empty cache to avoid None reference errors
if self._cache is None:
@@ -501,6 +513,11 @@ class ModelScanner:
logger.info(
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
)
if reconcile:
# Root configuration changed (e.g. Other Models toggles):
# pick up newly enabled folders and drop rows for folders
# that are no longer managed.
await self.get_cached_data(force_refresh=True)
return
# Persistent load failed; fall back to a full scan
@@ -663,21 +680,33 @@ class ModelScanner:
if not persisted or not persisted.raw_data:
return None
# Drop entries the scanner no longer manages (e.g. an other-model
# sub_type the user just disabled) before rebuilding the indexes, so
# hash/autov3 lookups cannot resolve to unmanaged files either.
kept_items = [
item
for item in persisted.raw_data
if self._should_keep_cached_entry(item)
]
kept_paths = {
item.get("file_path") for item in kept_items if item.get("file_path")
}
hash_index = ModelHashIndex()
for sha_value, path in persisted.hash_rows:
if sha_value and path:
if sha_value and path and path in kept_paths:
hash_index.add_entry(sha_value.lower(), path)
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
# cover every known autov3 -> path mapping regardless of whether a
# sha256 row also exists for the same file.
for autov3_value, path in persisted.autov3_hash_rows:
if autov3_value and path:
if autov3_value and path and path in kept_paths:
hash_index.add_autov3(autov3_value.lower(), path)
tags_count: Dict[str, int] = {}
adjusted_raw_data: List[Dict[str, Any]] = []
for item in persisted.raw_data:
for item in kept_items:
# load_cache builds a fresh dict per row, and validate_batch below
# works on its own per-entry copy when auto_repair=True, so no
# additional dict copy is needed here.
@@ -1435,6 +1464,15 @@ class ModelScanner:
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
return entry
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
"""Hook for subclasses: decide whether a persisted entry is still managed.
Entries rejected here are dropped (with their hash/autov3 index rows)
while hydrating the persisted cache, so a scanner whose configured
roots shrank does not surface stale models before the next reconcile.
"""
return True
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Hook for subclasses: resolve the location-derived sub_type for a file.
+10
View File
@@ -454,6 +454,16 @@ class OtherScanner(ModelScanner):
entry["sub_type"] = sub_type
return entry
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
"""Drop persisted entries whose folder is no longer a managed root.
sub_type is location-derived and config only maps enabled roots, so a
file under a disabled sub_type - or under any other root while the
feature is off - resolves to None here and is filtered out while the
persisted cache is hydrated.
"""
return self.resolve_sub_type_for_path(entry.get("file_path")) is not None
def get_model_roots(self) -> List[str]:
"""Get other-model root directories"""
roots: List[str] = []
+84 -36
View File
@@ -25,11 +25,13 @@ from typing import (
from platformdirs import user_config_dir
from ..utils.constants import (
DEFAULT_ENABLED_OTHER_SUB_TYPES,
DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
OTHER_MODEL_FOLDER_SUBTYPES,
OTHER_SUB_TYPE_FOLDER_KEYS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_OTHER_SUB_TYPES,
normalize_other_sub_types,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import (
@@ -86,6 +88,10 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"default_unet_root": "",
"default_embedding_root": "",
"default_other_roots": {},
# Other Models management is opt-in: nothing is scanned, shown or offered
# for download until the user turns the feature on.
"enable_other_models": False,
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
"recipes_path": "",
"base_model_path_mappings": {},
"download_path_templates": {},
@@ -680,6 +686,42 @@ class SettingsManager:
normalized[sub_type] = stripped
return normalized
def is_other_models_enabled(self) -> bool:
"""Return True when the opt-in Other Models management is enabled."""
return bool(self.settings.get("enable_other_models", False))
def get_enabled_other_sub_types(self) -> List[str]:
"""Return the enabled other-model sub_types (empty when the feature is off)."""
if not self.is_other_models_enabled():
return []
return normalize_other_sub_types(self.settings.get("enabled_other_sub_types"))
def is_other_sub_type_enabled(self, sub_type: Optional[str]) -> bool:
"""Return True when ``sub_type`` is currently managed."""
if not sub_type:
return False
return sub_type in self.get_enabled_other_sub_types()
def _apply_other_model_settings_change(self) -> None:
"""Rebuild other-model roots and refresh the other scanner after a toggle."""
try:
from ..config import config # Local import to avoid circular dependency
config.refresh_other_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other-model roots: %s", exc)
try:
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
scanner = ServiceRegistry.get_service_sync("other_scanner")
if scanner is not None and hasattr(scanner, "on_library_changed"):
# reconcile=True lets the scanner pick up newly enabled roots and
# purge rows for folders that are no longer managed.
scanner.on_library_changed(reconcile=True)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
def _has_configured_paths(self, folder_paths: Any) -> bool:
if not isinstance(folder_paths, Mapping):
return False
@@ -951,44 +993,44 @@ class SettingsManager:
updated = _check_and_auto_set("unet", "default_unet_root") or updated
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
# Other-model default roots: one entry per sub_type; candidates are the
# union of that sub_type's folder_paths keys (text_encoder merges the
# legacy 'clip' key with 'text_encoders').
sub_type_folder_keys: Dict[str, List[str]] = {}
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
sub_type_folder_keys.setdefault(sub_type, []).append(folder_key)
# Other-model default roots: one entry per enabled sub_type; candidates
# are the union of that sub_type's folder_paths keys (text_encoder
# merges the legacy 'clip' key with 'text_encoders'). When the opt-in
# feature is off the existing mapping is left untouched.
other_roots = self._normalize_default_other_roots(
self.settings.get("default_other_roots")
)
for sub_type in VALID_OTHER_SUB_TYPES:
candidates: List[str] = []
candidate_identities: set[str] = set()
for folder_key in sub_type_folder_keys.get(sub_type, []):
for candidate in self._get_valid_root_candidates(folder_key):
identity = _normalize_root_identity(candidate)
if identity in candidate_identities:
continue
candidate_identities.add(identity)
candidates.append(candidate)
if not candidates:
continue
current = other_roots.get(sub_type, "")
if current and _normalize_root_identity(current) in candidate_identities:
continue
other_roots[sub_type] = candidates[0]
if current:
logger.info(
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
sub_type,
current,
candidates[0],
)
else:
logger.info(
"Auto-set default_other_roots[%s] to '%s'", sub_type, candidates[0]
)
updated = True
if self.is_other_models_enabled():
for sub_type in self.get_enabled_other_sub_types():
candidates: List[str] = []
candidate_identities: set[str] = set()
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
for candidate in self._get_valid_root_candidates(folder_key):
identity = _normalize_root_identity(candidate)
if identity in candidate_identities:
continue
candidate_identities.add(identity)
candidates.append(candidate)
if not candidates:
continue
current = other_roots.get(sub_type, "")
if current and _normalize_root_identity(current) in candidate_identities:
continue
other_roots[sub_type] = candidates[0]
if current:
logger.info(
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
sub_type,
current,
candidates[0],
)
else:
logger.info(
"Auto-set default_other_roots[%s] to '%s'",
sub_type,
candidates[0],
)
updated = True
if updated:
self.settings["default_other_roots"] = other_roots
@@ -1699,6 +1741,10 @@ class SettingsManager:
value = self.normalize_mature_blur_level(value)
elif key == "default_other_roots":
value = self._normalize_default_other_roots(value, strict=True)
elif key == "enabled_other_sub_types":
value = normalize_other_sub_types(value)
elif key == "enable_other_models":
value = bool(value)
elif key == "recipes_path":
current_recipes_dir = self._get_effective_recipes_dir()
value = self._normalize_recipes_path_value(value)
@@ -1735,6 +1781,8 @@ class SettingsManager:
self._save_settings()
if key == "recipes_path":
self._notify_library_change(self.get_active_library_name())
if key in ("enable_other_models", "enabled_other_sub_types"):
self._apply_other_model_settings_change()
if portable_switch_pending:
self._finalize_portable_switch()