mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-08 10:32:13 -03:00
fix(sidebar): keep the recipes storage dir out of folder management
The default recipes dir (<first lora root>/recipes) is auto-created by the recipe scanner and recorded by the model-root walks as an empty folder, so it showed up in the folder sidebar — and since recipe files are not model weight files, the folder delete endpoint's model_count check let a right-click delete wipe the whole recipe library via shutil.rmtree. - scan walks (full scan, reconcile, all-folders backfill) no longer descend into the effective recipes dir, and get_all_folders() filters it out of snapshots persisted before the exclusion existed - delete_folder/rename_folder refuse the recipes dir and any ancestor of it with code "protected" - the delete modal renders a protected state (confirm hidden, per-row checkbox disabled) instead of a deletable 'no models' prediction - RecipeScanner.recipes_dir now shares the side-effect-free resolver in py/utils/recipes_paths.py
This commit is contained in:
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
||||
from ..utils.recipes_paths import path_is_or_contains_recipes_dir
|
||||
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||
@@ -697,6 +698,19 @@ class ModelMoveService:
|
||||
"error": "The library root itself cannot be deleted",
|
||||
}
|
||||
|
||||
if path_is_or_contains_recipes_dir(absolute_path):
|
||||
# The recipes dir holds no model weight files, so the
|
||||
# model_count check below cannot protect it — but removing it
|
||||
# wipes the user's recipe library.
|
||||
return {
|
||||
"success": False,
|
||||
"code": "protected",
|
||||
"error": (
|
||||
"This folder holds the recipe library and cannot be "
|
||||
"deleted from here"
|
||||
),
|
||||
}
|
||||
|
||||
manifest = self._collect_folder_manifest(absolute_path)
|
||||
|
||||
if manifest["pending_delete_job"]:
|
||||
@@ -948,6 +962,18 @@ class ModelMoveService:
|
||||
"error": "The library root itself cannot be renamed",
|
||||
}
|
||||
|
||||
if path_is_or_contains_recipes_dir(absolute_path):
|
||||
# Renaming the recipes dir (or an ancestor) strands the recipe
|
||||
# library: the scanner keeps looking at the configured path.
|
||||
return {
|
||||
"success": False,
|
||||
"code": "protected",
|
||||
"error": (
|
||||
"This folder holds the recipe library and cannot be "
|
||||
"renamed from here"
|
||||
),
|
||||
}
|
||||
|
||||
previous_relative = self._calculate_relative_folder(absolute_path)
|
||||
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ from ..utils.sidecar_paths import (
|
||||
resolve_centralized_dir_for_dir,
|
||||
)
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from ..utils.recipes_paths import (
|
||||
get_effective_recipes_dir,
|
||||
normalized_recipes_dir_key,
|
||||
)
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
||||
@@ -72,6 +76,11 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||
|
||||
|
||||
def _dir_entry_key(parent: str, name: str) -> str:
|
||||
"""normcase+abspath key for a directory entry, for reserved-dir checks."""
|
||||
return os.path.normcase(os.path.abspath(os.path.join(parent, name)))
|
||||
|
||||
|
||||
def _file_name_stem(file_path: str) -> str:
|
||||
"""Return the extension-free file name of a normalized model path.
|
||||
|
||||
@@ -577,6 +586,9 @@ def _walk_root_for_reconcile(
|
||||
stale_seen: Set[str] = set()
|
||||
files_since_report = 0
|
||||
walk_start = walk_path or root_path
|
||||
# The recipe library sits inside a lora root by default; it is a reserved
|
||||
# directory, never a model folder, so the walk must not descend into it.
|
||||
recipes_dir_key = normalized_recipes_dir_key()
|
||||
|
||||
def _on_walk_error(error: OSError) -> None:
|
||||
"""Record a directory the walk could not enter (offline/denied)."""
|
||||
@@ -599,7 +611,11 @@ def _walk_root_for_reconcile(
|
||||
for root, dirnames, files in os.walk(
|
||||
walk_start, followlinks=True, onerror=_on_walk_error
|
||||
):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
dirnames[:] = [
|
||||
d
|
||||
for d in dirnames
|
||||
if not _is_excluded_dir(d) and _dir_entry_key(root, d) != recipes_dir_key
|
||||
]
|
||||
|
||||
real_root = os.path.realpath(root)
|
||||
if not dir_claims.claim(real_root):
|
||||
@@ -1451,17 +1467,20 @@ class ModelScanner:
|
||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||
def _count_model_files(self) -> int:
|
||||
"""Count all model files with supported extensions in all roots
|
||||
|
||||
|
||||
Returns:
|
||||
int: Total number of model files found
|
||||
"""
|
||||
total_files = 0
|
||||
visited_real_paths = set()
|
||||
|
||||
# The recipe library holds no model files; skip it so the progress
|
||||
# estimate matches what the scan itself will walk.
|
||||
recipes_dir_key = normalized_recipes_dir_key()
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
|
||||
def count_recursive(path):
|
||||
nonlocal total_files
|
||||
try:
|
||||
@@ -1469,7 +1488,7 @@ class ModelScanner:
|
||||
if real_path in visited_real_paths:
|
||||
return
|
||||
visited_real_paths.add(real_path)
|
||||
|
||||
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
@@ -1480,6 +1499,8 @@ class ModelScanner:
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
if _dir_entry_key(path, entry.name) == recipes_dir_key:
|
||||
continue
|
||||
count_recursive(entry.path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
||||
@@ -2407,8 +2428,49 @@ class ModelScanner:
|
||||
else:
|
||||
self._schedule_all_folders_backfill()
|
||||
|
||||
reserved = self._recipes_folder_rel_paths()
|
||||
if reserved:
|
||||
folders = {
|
||||
folder
|
||||
for folder in folders
|
||||
if not any(
|
||||
folder.casefold() == rel or folder.casefold().startswith(rel + "/")
|
||||
for rel in reserved
|
||||
)
|
||||
}
|
||||
|
||||
return sorted(folders, key=lambda x: x.lower())
|
||||
|
||||
def _recipes_folder_rel_paths(self) -> Set[str]:
|
||||
"""Casefolded library-relative names of the recipes dir, per root.
|
||||
|
||||
The recipe library sits inside a lora root by default; it is a
|
||||
reserved directory, never a model folder. The scan walks no longer
|
||||
record it, but snapshots persisted before that exclusion still carry
|
||||
it, so readers filter it out here.
|
||||
"""
|
||||
try:
|
||||
recipes_dir = get_effective_recipes_dir()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return set()
|
||||
if not recipes_dir:
|
||||
return set()
|
||||
|
||||
recipes_abs = os.path.abspath(recipes_dir)
|
||||
recipes_key = os.path.normcase(recipes_abs)
|
||||
reserved: Set[str] = set()
|
||||
for root in self.get_model_roots():
|
||||
root_abs = os.path.abspath(root)
|
||||
root_key = os.path.normcase(root_abs)
|
||||
if recipes_key != root_key and not recipes_key.startswith(
|
||||
root_key + os.sep
|
||||
):
|
||||
continue
|
||||
rel = os.path.relpath(recipes_abs, root_abs).replace(os.sep, "/")
|
||||
if rel and rel != ".":
|
||||
reserved.add(rel.casefold())
|
||||
return reserved
|
||||
|
||||
async def add_known_folder(self, folder: str) -> None:
|
||||
"""Record a folder (and its parents) in the known folder list.
|
||||
|
||||
@@ -2894,17 +2956,25 @@ class ModelScanner:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Runs in a worker thread. Hidden directories (any segment starting
|
||||
with '.') and the pending-delete staging dir are excluded.
|
||||
with '.'), the pending-delete staging dir and the recipes storage
|
||||
dir are excluded.
|
||||
"""
|
||||
discovered: Set[str] = set()
|
||||
visited_real_paths: Set[str] = set()
|
||||
# The recipe library is a reserved directory (see the reconcile walk).
|
||||
recipes_dir_key = normalized_recipes_dir_key()
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for root, dirnames, _files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
dirnames[:] = [
|
||||
d
|
||||
for d in dirnames
|
||||
if not _is_excluded_dir(d)
|
||||
and _dir_entry_key(root, d) != recipes_dir_key
|
||||
]
|
||||
# realpath is used only for symlink dedup, never for the
|
||||
# recorded path (business paths stay unresolved).
|
||||
real_root = os.path.realpath(root)
|
||||
@@ -3256,6 +3326,10 @@ class ModelScanner:
|
||||
processed_real_files: Set[str] = set()
|
||||
visited_real_dirs: Set[str] = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
# The recipe library sits inside a lora root by default; it is a
|
||||
# reserved directory, never a model folder, so the scan must not
|
||||
# descend into it.
|
||||
recipes_dir_key = normalized_recipes_dir_key()
|
||||
|
||||
async def handle_progress(current_name: str = '') -> None:
|
||||
if progress_callback is None:
|
||||
@@ -3334,6 +3408,11 @@ class ModelScanner:
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
if (
|
||||
_dir_entry_key(current_path, entry.name)
|
||||
== recipes_dir_key
|
||||
):
|
||||
continue
|
||||
# Record every directory (including empty ones) so
|
||||
# the folder tree can be served without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
|
||||
@@ -2676,23 +2676,11 @@ class RecipeScanner:
|
||||
@property
|
||||
def recipes_dir(self) -> str:
|
||||
"""Get path to recipes directory"""
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.recipes_paths import get_effective_recipes_dir
|
||||
|
||||
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
||||
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
||||
recipes_dir = os.path.abspath(
|
||||
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
||||
)
|
||||
recipes_dir = get_effective_recipes_dir()
|
||||
if recipes_dir:
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
return recipes_dir
|
||||
|
||||
if not config.loras_roots:
|
||||
return ""
|
||||
|
||||
# config.loras_roots already sorted case-insensitively, use the first one
|
||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes")
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
return recipes_dir
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Resolution of the recipe library's storage directory.
|
||||
|
||||
Kept side-effect free (no directory creation): model scanners and file
|
||||
services consult these helpers on hot paths, where silently materializing
|
||||
the folder would be a surprise. ``RecipeScanner.recipes_dir`` remains the
|
||||
write endpoint and adds the ``makedirs`` itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def get_effective_recipes_dir() -> str:
|
||||
"""Resolve the directory recipe data lives in. May not exist on disk.
|
||||
|
||||
Mirrors ``RecipeScanner.recipes_dir`` minus the auto-create: the
|
||||
``recipes_path`` setting wins; otherwise the default is ``recipes``
|
||||
under the first configured lora root (``config.loras_roots`` is already
|
||||
sorted case-insensitively). Returns ``""`` when no lora root exists.
|
||||
"""
|
||||
# Local imports: services -> config -> utils dependency direction must
|
||||
# not pick up a utils -> services edge at module import time.
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..config import config
|
||||
|
||||
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
||||
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
||||
return os.path.abspath(
|
||||
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
||||
)
|
||||
|
||||
lora_roots = [
|
||||
path
|
||||
for path in (getattr(config, "loras_roots", None) or [])
|
||||
if isinstance(path, str) and path.strip()
|
||||
]
|
||||
if not lora_roots:
|
||||
return ""
|
||||
|
||||
return os.path.abspath(os.path.join(lora_roots[0], "recipes"))
|
||||
|
||||
|
||||
def normalized_recipes_dir_key() -> str:
|
||||
"""Comparison key (normcase + abspath) for the recipes dir; "" if unknown."""
|
||||
try:
|
||||
recipes_dir = get_effective_recipes_dir()
|
||||
except Exception: # pragma: no cover - never break a scan over settings access
|
||||
return ""
|
||||
if not recipes_dir:
|
||||
return ""
|
||||
return os.path.normcase(os.path.abspath(recipes_dir))
|
||||
|
||||
|
||||
def path_is_or_contains_recipes_dir(path: str) -> bool:
|
||||
"""True when *path* is the recipes dir or one of its ancestors.
|
||||
|
||||
Deleting or renaming an ancestor wipes the recipe library just the same,
|
||||
so folder operations must refuse both shapes.
|
||||
"""
|
||||
key = normalized_recipes_dir_key()
|
||||
if not key:
|
||||
return False
|
||||
candidate = os.path.normcase(os.path.abspath(path))
|
||||
return candidate == key or key.startswith(candidate + os.sep)
|
||||
Reference in New Issue
Block a user