mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3f82976f | |||
| 91b2735dad | |||
| 3112869a21 | |||
| aa630bf85b |
@@ -0,0 +1,92 @@
|
||||
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
|
||||
|
||||
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
|
||||
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
|
||||
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
|
||||
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
|
||||
~1400 ms 降到 ~120 ms。
|
||||
|
||||
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
|
||||
因此单独记录,留待 Windows 机器上确认。
|
||||
|
||||
---
|
||||
|
||||
## 待验证分支(现状)
|
||||
|
||||
`py/services/model_scanner.py` 中 `_reconcile_cache()` 的 walk 循环内:
|
||||
|
||||
```python
|
||||
# Try case-insensitive match on Windows
|
||||
if os.name == 'nt':
|
||||
lower_path = file_path.lower()
|
||||
matched = False
|
||||
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
|
||||
if cached_path.lower() == lower_path:
|
||||
found_paths.add(cached_path)
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
continue
|
||||
```
|
||||
|
||||
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
|
||||
|
||||
### 为什么可疑
|
||||
|
||||
1. **可能不可达**:Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
|
||||
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
|
||||
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
|
||||
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
|
||||
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
|
||||
3. **没有测试覆盖**:`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
|
||||
被 `os.name == 'nt'` 短路,无法覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 待办
|
||||
|
||||
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
|
||||
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
|
||||
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
|
||||
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
|
||||
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
|
||||
- [ ] **补一个 Windows-only 的回归测试**(`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
|
||||
- [ ] 把验证结论回填到本文件,并同步更新状态行。
|
||||
|
||||
---
|
||||
|
||||
## 验证方法(Windows)
|
||||
|
||||
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
|
||||
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
|
||||
2. **看后端日志判据**:
|
||||
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,
|
||||
且**没有** `Found N new files to process` / `Processing <path>`。
|
||||
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
|
||||
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
|
||||
(缓存路径被当成了新文件 + 旧条目被删)。
|
||||
3. **跑测试**:`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
|
||||
真实执行 `os.name == 'nt'` 分支)。
|
||||
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
|
||||
|
||||
---
|
||||
|
||||
## 已完成(本轮优化,供对照)
|
||||
|
||||
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
|
||||
|
||||
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
|
||||
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
|
||||
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
|
||||
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
|
||||
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
|
||||
|
||||
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
|
||||
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
|
||||
47 项、全量后端 2567 项全部通过。
|
||||
@@ -3124,6 +3124,12 @@ class RecipeWorkflowHandler:
|
||||
class BatchImportHandler:
|
||||
"""Handle batch import operations for recipes."""
|
||||
|
||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||
# path handling and fails the existence check.
|
||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -3297,31 +3303,27 @@ class BatchImportHandler:
|
||||
data = await request.json()
|
||||
directory_path = data.get("path", "")
|
||||
|
||||
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
||||
return self._windows_drives_response()
|
||||
|
||||
# Default to the user's home directory. The frontend previously
|
||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||
# resolves to the current drive root and then fails the access
|
||||
# check below.
|
||||
if not directory_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Directory path is required"},
|
||||
status=400,
|
||||
)
|
||||
path = Path.home()
|
||||
else:
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
|
||||
# Normalize the path
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
|
||||
# Security check: ensure path is within allowed directories
|
||||
# Allow common image/model directories
|
||||
allowed_roots = [
|
||||
Path.home(),
|
||||
Path("/"), # Allow browsing from root for flexibility
|
||||
]
|
||||
|
||||
# Check if path is within any allowed root
|
||||
is_allowed = False
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
is_allowed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
# Access check: browsing intentionally covers the whole server
|
||||
# filesystem (the server operator browses their own machine). On
|
||||
# POSIX every absolute path is under "/", but Path("/") has no
|
||||
# drive letter on Windows and can never anchor a drive-qualified
|
||||
# path in relative_to(), so test for a drive there instead.
|
||||
if os.name == "nt":
|
||||
is_allowed = bool(path.drive)
|
||||
else:
|
||||
is_allowed = path.is_absolute()
|
||||
|
||||
if not is_allowed:
|
||||
return web.json_response(
|
||||
@@ -3388,15 +3390,24 @@ class BatchImportHandler:
|
||||
directories.sort(key=lambda x: x["name"].lower())
|
||||
image_files.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
# Add parent directory if not at root
|
||||
parent_path = path.parent
|
||||
show_parent = str(path) != str(path.root)
|
||||
# Parent directory. A filesystem root is its own parent
|
||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||
# drive root (C:\) links up to the virtual drive list so users
|
||||
# can switch drives. The previous str(path) != str(path.root)
|
||||
# check misfired on Windows, where a drive root's parent is
|
||||
# itself, producing an infinite self-loop.
|
||||
if path.parent == path:
|
||||
parent_path = (
|
||||
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||
)
|
||||
else:
|
||||
parent_path = str(path.parent)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"current_path": str(path),
|
||||
"parent_path": str(parent_path) if show_parent else None,
|
||||
"parent_path": parent_path,
|
||||
"directories": directories,
|
||||
"image_files": image_files,
|
||||
"image_count": len(image_files),
|
||||
@@ -3423,3 +3434,30 @@ class BatchImportHandler:
|
||||
except Exception as exc:
|
||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _windows_drives_response(self) -> web.Response:
|
||||
"""List available drive letters as a virtual directory (Windows only)."""
|
||||
try:
|
||||
drives = os.listdrives()
|
||||
except AttributeError: # Python < 3.12
|
||||
drives = [
|
||||
f"{letter}:\\"
|
||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
if os.path.exists(f"{letter}:\\")
|
||||
]
|
||||
directories = [
|
||||
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
||||
]
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
# Empty current_path marks the virtual level; the frontend
|
||||
# disables folder selection there.
|
||||
"current_path": "",
|
||||
"parent_path": None,
|
||||
"directories": directories,
|
||||
"image_files": [],
|
||||
"image_count": 0,
|
||||
"directory_count": len(directories),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -33,6 +33,11 @@ class ModelCache:
|
||||
|
||||
raw_data: List[Dict[str, Any]]
|
||||
folders: List[str]
|
||||
# Every directory under the model roots (including empty ones), as
|
||||
# recorded by the last scan/hydration. ``None`` means "never recorded"
|
||||
# (e.g. a persisted snapshot predating this field) and triggers a
|
||||
# background filesystem backfill in the scanner.
|
||||
all_folders: Optional[List[str]] = None
|
||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
# Multi-valued companion to version_index: every local file entry of a
|
||||
|
||||
+182
-69
@@ -62,10 +62,6 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||
|
||||
|
||||
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
|
||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
||||
|
||||
# Maps a scanner model type to the manager page type used in progress
|
||||
# broadcasts (e.g. 'lora' -> 'loras').
|
||||
PAGE_TYPE_MAP = {
|
||||
@@ -89,6 +85,10 @@ class CacheBuildResult:
|
||||
hash_index: ModelHashIndex
|
||||
tags_count: Dict[str, int]
|
||||
excluded_models: List[str]
|
||||
# Every directory under the model roots (including empty ones) discovered
|
||||
# during the scan, or None when the source has no folder information
|
||||
# (e.g. a persisted snapshot predating folder recording).
|
||||
all_folders: Optional[List[str]] = None
|
||||
|
||||
class ModelScanner:
|
||||
"""Base service for scanning and managing model files"""
|
||||
@@ -144,8 +144,9 @@ class ModelScanner:
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self._cancel_requested = False # Flag for cancellation
|
||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
||||
# Guard against concurrent all-folders backfill walks (cold fallback
|
||||
# for persisted snapshots that predate folder recording).
|
||||
self._all_folders_backfill_running = False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -217,7 +218,6 @@ class ModelScanner:
|
||||
self._excluded_models = []
|
||||
self._is_initializing = False
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self.invalidate_all_folders_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
try:
|
||||
@@ -702,7 +702,8 @@ class ModelScanner:
|
||||
raw_data=valid_entries,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
excluded_models=list(persisted.excluded_models),
|
||||
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
|
||||
)
|
||||
return scan_result, invalid_entries
|
||||
|
||||
@@ -737,6 +738,7 @@ class ModelScanner:
|
||||
hash_snapshot,
|
||||
list(scan_result.excluded_models),
|
||||
autov3_snapshot,
|
||||
scan_result.all_folders,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
||||
@@ -784,7 +786,12 @@ class ModelScanner:
|
||||
raw_data=list(self._cache.raw_data),
|
||||
hash_index=self._hash_index,
|
||||
tags_count=dict(self._tags_count),
|
||||
excluded_models=list(self._excluded_models)
|
||||
excluded_models=list(self._excluded_models),
|
||||
all_folders=(
|
||||
list(self._cache.all_folders)
|
||||
if self._cache.all_folders is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
await self._save_persistent_cache(snapshot)
|
||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||
@@ -1005,20 +1012,36 @@ class ModelScanner:
|
||||
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
||||
|
||||
# Get current cached file paths
|
||||
cached_size_before = len(self._cache.raw_data)
|
||||
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
||||
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
|
||||
cached_real_paths = {}
|
||||
for cached_path in cached_paths:
|
||||
try:
|
||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# physical path -> cached business path, for the alias case where the
|
||||
# same file is reachable under a different path than the cached one
|
||||
# (overlapping roots / symlink layout changes): keep the existing
|
||||
# entry instead of delete + re-add (which would re-read metadata and
|
||||
# re-hash every file). Built lazily on the first miss, because a
|
||||
# realpath per cached entry is ~half the cost of a no-change
|
||||
# reconcile and the map is only ever consulted for misses.
|
||||
cached_real_paths: Optional[Dict[str, str]] = None
|
||||
|
||||
def lookup_cached_real_path(real_path: str) -> Optional[str]:
|
||||
nonlocal cached_real_paths
|
||||
if cached_real_paths is None:
|
||||
cached_real_paths = {}
|
||||
for cached_path in cached_paths:
|
||||
try:
|
||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||
except Exception:
|
||||
continue
|
||||
return cached_real_paths.get(real_path)
|
||||
|
||||
# Track found files and new files
|
||||
found_paths = set()
|
||||
new_files = []
|
||||
visited_real_paths = set()
|
||||
discovered_real_files = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
|
||||
# Scan all model roots
|
||||
for root_path in self.get_model_roots():
|
||||
@@ -1033,19 +1056,31 @@ class ModelScanner:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
# Record every visited directory (including empty ones) so
|
||||
# the folder tree stays accurate without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
os.path.abspath(root), os.path.abspath(root_path)
|
||||
).replace(os.path.sep, "/")
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered_folders.add(rel_dir)
|
||||
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
# Construct paths exactly as they would be in cache
|
||||
file_path = os.path.join(root, file).replace(os.sep, '/')
|
||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||
|
||||
|
||||
# Check if this file is already in cache
|
||||
if file_path in cached_paths:
|
||||
found_paths.add(file_path)
|
||||
continue
|
||||
|
||||
cached_real_match = cached_real_paths.get(real_file_path)
|
||||
# Only a cache miss needs the physical path, so the
|
||||
# realpath syscalls are paid per changed file rather
|
||||
# than per file in the library.
|
||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||
|
||||
cached_real_match = lookup_cached_real_path(real_file_path)
|
||||
if cached_real_match:
|
||||
found_paths.add(cached_real_match)
|
||||
continue
|
||||
@@ -1090,6 +1125,9 @@ class ModelScanner:
|
||||
total_new = len(new_files)
|
||||
processed_new = 0
|
||||
last_progress_time = time.time()
|
||||
# Snapshot the roots once: this matches the walk above (which
|
||||
# also snapshots them) and avoids a config read per new file.
|
||||
model_roots = self.get_model_roots()
|
||||
for i in range(0, total_new, batch_size):
|
||||
batch = new_files[i:i+batch_size]
|
||||
for path in batch:
|
||||
@@ -1098,12 +1136,10 @@ class ModelScanner:
|
||||
try:
|
||||
# Find the appropriate root path for this file
|
||||
root_path = None
|
||||
model_roots = self.get_model_roots()
|
||||
normalized_path = os.path.normpath(path)
|
||||
for potential_root in model_roots:
|
||||
# Normalize both paths for comparison
|
||||
normalized_path = os.path.normpath(path)
|
||||
normalized_root = os.path.normpath(potential_root)
|
||||
if normalized_path.startswith(normalized_root):
|
||||
if normalized_path.startswith(os.path.normpath(potential_root)):
|
||||
root_path = potential_root
|
||||
break
|
||||
|
||||
@@ -1200,25 +1236,41 @@ class ModelScanner:
|
||||
# Update cache data
|
||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||
|
||||
dedup_removed = 0
|
||||
seen_paths: set[str] = set()
|
||||
deduped: list[Dict[str, Any]] = []
|
||||
for item in reversed(self._cache.raw_data):
|
||||
path = item.get('file_path', '')
|
||||
if path not in seen_paths:
|
||||
seen_paths.add(path)
|
||||
deduped.append(item)
|
||||
else:
|
||||
for tag in item.get('tags', []):
|
||||
if tag in self._tags_count:
|
||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||
if self._tags_count[tag] == 0:
|
||||
del self._tags_count[tag]
|
||||
dedup_removed += 1
|
||||
if dedup_removed > 0:
|
||||
self._cache.raw_data = list(reversed(deduped))
|
||||
total_removed += dedup_removed
|
||||
# Defensive integrity pass: drop entries sharing a business path.
|
||||
# Duplicates can only be introduced by external code rewriting
|
||||
# raw_data directly or by this pass's own appends, so an unchanged
|
||||
# filesystem walk over a clean cache has nothing to clean. The size
|
||||
# mismatch is an O(1) tell that the snapshot already contained
|
||||
# duplicates; skipping the O(N) pass when it is provably clean is
|
||||
# what keeps a no-change Refresh cheap.
|
||||
if cached_size_before != len(cached_paths) or total_added > 0:
|
||||
dedup_removed = 0
|
||||
seen_paths: set[str] = set()
|
||||
deduped: list[Dict[str, Any]] = []
|
||||
for item in reversed(self._cache.raw_data):
|
||||
path = item.get('file_path', '')
|
||||
if path not in seen_paths:
|
||||
seen_paths.add(path)
|
||||
deduped.append(item)
|
||||
else:
|
||||
for tag in item.get('tags', []):
|
||||
if tag in self._tags_count:
|
||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||
if self._tags_count[tag] == 0:
|
||||
del self._tags_count[tag]
|
||||
dedup_removed += 1
|
||||
if dedup_removed > 0:
|
||||
self._cache.raw_data = list(reversed(deduped))
|
||||
total_removed += dedup_removed
|
||||
|
||||
# The walk above visited every directory, so refresh the recorded
|
||||
# folder list (including empty folders) even when no model files
|
||||
# changed — e.g. an empty folder was created or removed externally.
|
||||
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
||||
folders_changed = self._cache.all_folders != sorted_discovered
|
||||
if folders_changed:
|
||||
self._cache.all_folders = sorted_discovered
|
||||
|
||||
# Resort cache if changes were made
|
||||
if total_added > 0 or total_removed > 0:
|
||||
# Update folders list
|
||||
@@ -1231,6 +1283,8 @@ class ModelScanner:
|
||||
await self._cache.resort()
|
||||
|
||||
await self._persist_current_cache()
|
||||
elif folders_changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
|
||||
await self._broadcast_scan_progress(
|
||||
@@ -1270,22 +1324,73 @@ class ModelScanner:
|
||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||
|
||||
async def get_all_folders(self) -> List[str]:
|
||||
"""Return every known directory under the model roots.
|
||||
|
||||
The directory list (including empty ones) is recorded during cache
|
||||
scans and hydrated from the persisted snapshot, so this is a pure
|
||||
in-memory read — no filesystem walk ever runs on the event loop
|
||||
(walking network roots synchronously used to freeze the whole
|
||||
server, see issue #1110). The result is unioned with the
|
||||
model-derived folders so it is always a superset of
|
||||
``cache.folders``.
|
||||
|
||||
Cold fallback: when the cache was hydrated from a persisted snapshot
|
||||
that predates folder recording (``all_folders is None``), a one-shot
|
||||
background walk is scheduled off the event loop to backfill and
|
||||
persist the list; until it lands, the models-only folders are
|
||||
returned.
|
||||
"""
|
||||
folders: Set[str] = set()
|
||||
cache = self._cache
|
||||
if cache is not None:
|
||||
folders |= {item.get('folder', '') for item in cache.raw_data}
|
||||
recorded = getattr(cache, 'all_folders', None)
|
||||
if recorded is None:
|
||||
self._schedule_all_folders_backfill()
|
||||
else:
|
||||
folders |= set(recorded)
|
||||
else:
|
||||
self._schedule_all_folders_backfill()
|
||||
|
||||
return sorted(folders, key=lambda x: x.lower())
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
self._all_folders_backfill_running = True
|
||||
loop.create_task(self._run_all_folders_backfill())
|
||||
|
||||
async def _run_all_folders_backfill(self) -> None:
|
||||
"""Walk the roots in a worker thread, then record and persist the result."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
folders = await loop.run_in_executor(None, self._walk_all_folders_sync)
|
||||
cache = self._cache
|
||||
# A scan may have recorded the list while the walk was in flight;
|
||||
# prefer the fresher scan data in that case.
|
||||
if cache is not None and cache.all_folders is None:
|
||||
cache.all_folders = folders
|
||||
await self._persist_current_cache()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s Scanner: all-folders backfill failed: %s",
|
||||
self.model_type.capitalize(),
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
self._all_folders_backfill_running = False
|
||||
|
||||
def _walk_all_folders_sync(self) -> List[str]:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Unlike the models-only ``cache.folders``, this includes empty
|
||||
directories, so it stays accurate even when the in-memory cache was
|
||||
hydrated from a persisted snapshot without a filesystem walk. Hidden
|
||||
directories (any segment starting with '.') and the pending-delete
|
||||
staging dir are excluded. The result is unioned with the model-derived
|
||||
folders so it is always a superset of ``cache.folders``, and cached
|
||||
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
|
||||
Runs in a worker thread. Hidden directories (any segment starting
|
||||
with '.') and the pending-delete staging dir are excluded.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._all_folders_ttl_cache is not None:
|
||||
cached_at, cached_folders = self._all_folders_ttl_cache
|
||||
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
|
||||
return cached_folders
|
||||
|
||||
discovered: Set[str] = set()
|
||||
visited_real_paths: Set[str] = set()
|
||||
|
||||
@@ -1307,17 +1412,7 @@ class ModelScanner:
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered.add(rel_dir)
|
||||
|
||||
folders = set(discovered)
|
||||
if self._cache is not None:
|
||||
folders |= {item.get('folder', '') for item in self._cache.raw_data}
|
||||
|
||||
result = sorted(folders, key=lambda x: x.lower())
|
||||
self._all_folders_ttl_cache = (now, result)
|
||||
return result
|
||||
|
||||
def invalidate_all_folders_cache(self) -> None:
|
||||
"""Drop the cached get_all_folders() result (e.g. after a move)."""
|
||||
self._all_folders_ttl_cache = None
|
||||
return sorted(discovered, key=lambda x: x.lower())
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||
"""Get model file info and metadata (extensible for different model types)"""
|
||||
@@ -1541,6 +1636,9 @@ class ModelScanner:
|
||||
else:
|
||||
self._cache.raw_data = list(scan_result.raw_data)
|
||||
|
||||
if scan_result.all_folders is not None:
|
||||
self._cache.all_folders = list(scan_result.all_folders)
|
||||
|
||||
# resort() rebuilds folders and the version index on every path, so a
|
||||
# separate rebuild_version_index() call here would be redundant.
|
||||
await self._cache.resort()
|
||||
@@ -1638,6 +1736,7 @@ class ModelScanner:
|
||||
processed_files = 0
|
||||
processed_real_files: Set[str] = set()
|
||||
visited_real_dirs: Set[str] = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
|
||||
async def handle_progress(current_name: str = '') -> None:
|
||||
if progress_callback is None:
|
||||
@@ -1716,6 +1815,13 @@ class ModelScanner:
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
# Record every directory (including empty ones) so
|
||||
# the folder tree can be served without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
os.path.abspath(entry.path), os.path.abspath(root_path)
|
||||
).replace(os.path.sep, "/")
|
||||
if not _is_hidden_relative_path(rel_dir):
|
||||
discovered_folders.add(rel_dir)
|
||||
await scan_recursive(entry.path, root_path, visited_paths)
|
||||
except Exception as entry_error:
|
||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||
@@ -1735,7 +1841,8 @@ class ModelScanner:
|
||||
raw_data=raw_data,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=excluded_models
|
||||
excluded_models=excluded_models,
|
||||
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
|
||||
)
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
||||
@@ -1992,6 +2099,16 @@ class ModelScanner:
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# The move target may live in directories the last scan never saw;
|
||||
# record the destination folder (and its parents) in the known
|
||||
# folder list so the folder tree reflects it without a rescan.
|
||||
if cache.all_folders is not None and folder_value:
|
||||
parts = folder_value.split("/")
|
||||
known = set(cache.all_folders)
|
||||
for i in range(1, len(parts) + 1):
|
||||
known.add("/".join(parts[:i]))
|
||||
cache.all_folders = sorted(known, key=lambda x: x.lower())
|
||||
|
||||
for tag in cache_entry.get('tags', []):
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
@@ -1999,10 +2116,6 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# A move may have created new directories; drop the cached live-walk
|
||||
# result so the next include_empty request sees them.
|
||||
self.invalidate_all_folders_cache()
|
||||
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
@@ -19,6 +19,9 @@ class PersistedCacheData:
|
||||
hash_rows: List[Tuple[str, str]]
|
||||
excluded_models: List[str]
|
||||
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
|
||||
# Every directory under the model roots (including empty ones), or None
|
||||
# when the snapshot predates folder recording.
|
||||
all_folders: Optional[List[str]] = None
|
||||
|
||||
|
||||
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
|
||||
@@ -128,6 +131,14 @@ class PersistentModelCache:
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
folder_rows = conn.execute(
|
||||
"SELECT path FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
folders_recorded = conn.execute(
|
||||
"SELECT value FROM cache_meta WHERE key = ?",
|
||||
(f"folders_recorded:{model_type}",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -216,14 +227,20 @@ class PersistentModelCache:
|
||||
]
|
||||
|
||||
excluded_paths = [row["file_path"] for row in excluded]
|
||||
all_folders: Optional[List[str]] = None
|
||||
if folders_recorded is not None:
|
||||
all_folders = sorted(
|
||||
(row["path"] for row in folder_rows), key=lambda x: x.lower()
|
||||
)
|
||||
return PersistedCacheData(
|
||||
raw_data=raw_data,
|
||||
hash_rows=hash_pairs,
|
||||
excluded_models=excluded_paths,
|
||||
autov3_hash_rows=autov3_pairs,
|
||||
all_folders=all_folders,
|
||||
)
|
||||
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
if not self._schema_initialized:
|
||||
@@ -469,6 +486,27 @@ class PersistentModelCache:
|
||||
excluded_inserts,
|
||||
)
|
||||
|
||||
if all_folders is not None:
|
||||
conn.execute(
|
||||
"DELETE FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
)
|
||||
folder_inserts = [
|
||||
(model_type, path) for path in all_folders if path
|
||||
]
|
||||
if folder_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||
folder_inserts,
|
||||
)
|
||||
# Mark the snapshot as having folder data even when the
|
||||
# library has no subfolders, so an empty list is not
|
||||
# mistaken for "never recorded" on load.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||
(f"folders_recorded:{model_type}", "1"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -554,6 +592,17 @@ class PersistentModelCache:
|
||||
file_path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
model_type TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_additional_model_columns(conn)
|
||||
|
||||
@@ -18,6 +18,7 @@ export class BatchImportManager {
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
this.isImporting = false;
|
||||
this.currentParentPath = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,9 +719,10 @@ export class BatchImportManager {
|
||||
browser.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
// Load initial directory when opening
|
||||
// Load initial directory when opening. An empty path lets the
|
||||
// server pick its default (user home); "/" would be POSIX-only.
|
||||
const currentPath = document.getElementById('batchDirectoryInput').value;
|
||||
this.loadDirectory(currentPath || '/');
|
||||
this.loadDirectory(currentPath || '');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,6 +763,10 @@ export class BatchImportManager {
|
||||
const directoryCount = document.getElementById('batchDirectoryCount');
|
||||
const imageCount = document.getElementById('batchImageCount');
|
||||
|
||||
// Remember the server-computed parent path so the "up" navigation
|
||||
// works with Windows paths too (they cannot be split on "/").
|
||||
this.currentParentPath = data.parent_path || null;
|
||||
|
||||
if (currentPathEl) {
|
||||
currentPathEl.textContent = data.current_path;
|
||||
}
|
||||
@@ -811,11 +817,9 @@ export class BatchImportManager {
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
if (isParent) {
|
||||
this.navigateToParentDirectory();
|
||||
} else {
|
||||
this.loadDirectory(path);
|
||||
}
|
||||
// The parent entry uses the server-provided parent_path (or the
|
||||
// Windows drive-list token) directly — both are plain load targets.
|
||||
this.loadDirectory(path);
|
||||
});
|
||||
|
||||
return item;
|
||||
@@ -839,15 +843,12 @@ export class BatchImportManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to parent directory
|
||||
* Navigate to parent directory using the path reported by the server.
|
||||
* Deriving it client-side by splitting on "/" breaks Windows paths.
|
||||
*/
|
||||
navigateToParentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
if (currentPath) {
|
||||
// Get parent path using path manipulation
|
||||
const lastSeparator = currentPath.lastIndexOf('/');
|
||||
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
|
||||
this.loadDirectory(parentPath);
|
||||
if (this.currentParentPath) {
|
||||
this.loadDirectory(this.currentParentPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,8 +858,14 @@ export class BatchImportManager {
|
||||
selectCurrentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
|
||||
if (currentPath && directoryInput) {
|
||||
|
||||
if (!currentPath) {
|
||||
// Virtual levels (e.g. the Windows drive list) have no path.
|
||||
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (directoryInput) {
|
||||
directoryInput.value = currentPath;
|
||||
this.toggleDirectoryBrowser(); // Close browser
|
||||
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderTemplate } from '../utils/domFixtures.js';
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(() => true),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('BatchImportManager directory browser (#1106)', () => {
|
||||
let batchImportManager;
|
||||
let fetchMock;
|
||||
let showToast;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
document.body.innerHTML = '';
|
||||
renderTemplate('components/batch_import_modal.html');
|
||||
|
||||
fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const uiHelpers = await import('../../../static/js/utils/uiHelpers.js');
|
||||
showToast = uiHelpers.showToast;
|
||||
|
||||
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
|
||||
batchImportManager = new batchModule.BatchImportManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequestBody() {
|
||||
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
|
||||
}
|
||||
|
||||
it('opens the browser with an empty path so the server picks the default', async () => {
|
||||
// The old POSIX-only "/" initial path fails the access check on Windows.
|
||||
document.getElementById('batchDirectoryInput').value = '';
|
||||
|
||||
batchImportManager.toggleDirectoryBrowser();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('');
|
||||
expect(document.getElementById('batchDirectoryBrowser').style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('navigates to the parent using the server-provided path (Windows-safe)', async () => {
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
current_path: 'C:\\Users\\miao\\Pictures',
|
||||
parent_path: 'C:\\Users\\miao',
|
||||
directories: [],
|
||||
image_files: [],
|
||||
image_count: 0,
|
||||
directory_count: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
await batchImportManager.loadDirectory('C:\\Users\\miao\\Pictures');
|
||||
fetchMock.mockClear();
|
||||
|
||||
batchImportManager.navigateToParentDirectory();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('C:\\Users\\miao');
|
||||
});
|
||||
|
||||
it('refuses to select a virtual level that has no current path (drive list)', async () => {
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
current_path: '',
|
||||
parent_path: null,
|
||||
directories: [{ name: 'C:\\', path: 'C:\\', is_parent: false }],
|
||||
image_files: [],
|
||||
image_count: 0,
|
||||
directory_count: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
await batchImportManager.loadDirectory('__drives__');
|
||||
batchImportManager.selectCurrentDirectory();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||
expect(document.getElementById('batchDirectoryInput').value).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.recipe_handlers import BatchImportHandler
|
||||
|
||||
|
||||
def _make_handler() -> BatchImportHandler:
|
||||
return BatchImportHandler(
|
||||
ensure_dependencies_ready=None, # browse_directory never calls it
|
||||
recipe_scanner_getter=lambda: None,
|
||||
civitai_client_getter=lambda: None,
|
||||
logger=logging.getLogger(__name__),
|
||||
batch_import_service=None,
|
||||
)
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, path: str) -> None:
|
||||
self._path = path
|
||||
|
||||
async def json(self):
|
||||
return {"path": self._path}
|
||||
|
||||
|
||||
async def _browse(handler: BatchImportHandler, path: str):
|
||||
response = await handler.browse_directory(_Request(path))
|
||||
return response, json.loads(response.text)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_lists_subdirs_and_images(tmp_path):
|
||||
(tmp_path / "subdir").mkdir()
|
||||
(tmp_path / "photo.png").write_bytes(b"x")
|
||||
(tmp_path / "notes.txt").write_text("not an image")
|
||||
|
||||
response, payload = await _browse(_make_handler(), str(tmp_path))
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["current_path"] == str(tmp_path)
|
||||
assert [d["name"] for d in payload["directories"]] == ["subdir"]
|
||||
assert [f["name"] for f in payload["image_files"]] == ["photo.png"]
|
||||
assert payload["parent_path"] == str(tmp_path.parent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_empty_path_defaults_to_home(tmp_path, monkeypatch):
|
||||
# The frontend no longer sends the POSIX-only "/" as the initial path; an
|
||||
# empty path must resolve to the user's home directory (#1106).
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
response, payload = await _browse(_make_handler(), "")
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["current_path"] == str(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX root semantics")
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_root_has_no_parent():
|
||||
_, payload = await _browse(_make_handler(), os.path.abspath(os.sep))
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["parent_path"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_windows_drives_token(monkeypatch):
|
||||
# The token branch returns before any pathlib use, so faking os.name is
|
||||
# enough to exercise it on POSIX (#1106).
|
||||
monkeypatch.setattr(os, "name", "nt")
|
||||
monkeypatch.setattr(os, "listdrives", lambda: ["C:\\", "D:\\"], raising=False)
|
||||
|
||||
response, payload = await _browse(
|
||||
_make_handler(), BatchImportHandler.WINDOWS_DRIVES_TOKEN
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["current_path"] == ""
|
||||
assert payload["parent_path"] is None
|
||||
assert [d["name"] for d in payload["directories"]] == ["C:\\", "D:\\"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_missing_directory_returns_404(tmp_path):
|
||||
response, payload = await _browse(
|
||||
_make_handler(), str(tmp_path / "does-not-exist")
|
||||
)
|
||||
|
||||
assert response.status == 404
|
||||
assert payload["success"] is False
|
||||
@@ -164,7 +164,7 @@ def _make_move_scanner(ckpt_root: Path, unet_root: Path) -> CheckpointScanner:
|
||||
scanner._persistent_cache = MagicMock()
|
||||
scanner._name_display_mode = "model_name"
|
||||
scanner._cancel_requested = False
|
||||
scanner._all_folders_ttl_cache = None
|
||||
scanner._all_folders_backfill_running = False
|
||||
roots = [str(ckpt_root), str(unet_root)]
|
||||
scanner.get_model_roots = lambda: roots
|
||||
return scanner
|
||||
|
||||
@@ -732,6 +732,130 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
|
||||
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_cache_keeps_cached_path_when_walk_yields_a_live_alias(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""A root-order / symlink change can make the walk produce a *different but
|
||||
still live* business path for a file already in the cache. The realpath
|
||||
alias map must keep the cached entry instead of re-processing the file and
|
||||
swapping the path (which would re-read metadata and re-hash the weights)."""
|
||||
loras_root = tmp_path / "loras"
|
||||
loras_root.mkdir()
|
||||
extra_root = tmp_path / "extra"
|
||||
extra_root.mkdir()
|
||||
(extra_root / "one.txt").write_text("one", encoding="utf-8")
|
||||
(loras_root / "link").symlink_to(extra_root, target_is_directory=True)
|
||||
|
||||
# `extra_root` comes first, so the cache entry is stored under its path.
|
||||
scanner = MultiRootDummyScanner([extra_root, loras_root])
|
||||
await scanner._initialize_cache()
|
||||
|
||||
cached_before = {item["file_path"] for item in scanner._cache.raw_data}
|
||||
assert cached_before == {_normalize_path(extra_root / "one.txt")}
|
||||
|
||||
# The symlinked path now wins the walk; the file itself is unchanged.
|
||||
scanner._roots = [str(loras_root), str(extra_root)]
|
||||
processed: List[str] = []
|
||||
|
||||
async def _record_process(file_path: str, root_path: str, *args, **kwargs):
|
||||
processed.append(file_path)
|
||||
return await DummyScanner._process_model_file(
|
||||
scanner, file_path, root_path, *args, **kwargs
|
||||
)
|
||||
|
||||
scanner._process_model_file = _record_process # type: ignore[method-assign]
|
||||
|
||||
await scanner._reconcile_cache()
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
assert {item["file_path"] for item in cache.raw_data} == cached_before
|
||||
assert processed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_cache_defers_realpath_to_cache_misses(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""A no-change reconcile must not call realpath for unchanged files or for
|
||||
every cached entry: both the alias map and the per-file realpath are only
|
||||
needed for cache misses (they dominate the cost of a Refresh otherwise)."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
for i in range(5):
|
||||
(root / f"model{i}.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
scanner = DummyScanner(root)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
real_realpath = model_scanner.os.path.realpath
|
||||
realpath_args: List[str] = []
|
||||
|
||||
def _recording_realpath(path, *args, **kwargs):
|
||||
realpath_args.append(os.fspath(path))
|
||||
return real_realpath(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(model_scanner.os.path, "realpath", _recording_realpath)
|
||||
|
||||
await scanner._reconcile_cache()
|
||||
|
||||
model_files = {_normalize_path(path) for path in root.glob("*.txt")}
|
||||
assert not (set(realpath_args) & model_files)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_cache_cleans_pre_existing_duplicate_paths(tmp_path: Path):
|
||||
"""External code rewrites raw_data directly, so a reconcile must still drop
|
||||
duplicate business paths even when nothing changed on disk: the O(1)
|
||||
integrity check may only skip the pass for a provably clean cache."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
(root / "one.txt").write_text("one", encoding="utf-8")
|
||||
(root / "two.txt").write_text("two", encoding="utf-8")
|
||||
|
||||
scanner = DummyScanner(root)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
first_path = _normalize_path(root / "one.txt")
|
||||
duplicate = dict(next(i for i in scanner._cache.raw_data if i["file_path"] == first_path))
|
||||
duplicate["model_name"] = "duplicate-wins"
|
||||
scanner._cache.raw_data.append(duplicate)
|
||||
|
||||
await scanner._reconcile_cache()
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
assert len(cache.raw_data) == 2
|
||||
survivor = next(i for i in cache.raw_data if i["file_path"] == first_path)
|
||||
assert survivor["model_name"] == "duplicate-wins"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_cache_reads_model_roots_once_per_phase(tmp_path: Path, monkeypatch):
|
||||
"""get_model_roots() must be snapshotted once for the walk and once for the
|
||||
new-file pass, not re-read for every new file."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
scanner = DummyScanner(root)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
calls = 0
|
||||
real_get_model_roots = scanner.get_model_roots
|
||||
|
||||
def _counting_get_model_roots() -> List[str]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return real_get_model_roots()
|
||||
|
||||
monkeypatch.setattr(scanner, "get_model_roots", _counting_get_model_roots)
|
||||
|
||||
for i in range(3):
|
||||
(root / f"new{i}.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
await scanner._reconcile_cache()
|
||||
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
|
||||
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
|
||||
@@ -1294,7 +1418,7 @@ async def test_bulk_delete_cancelled_after_one_staged_batch_present(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_enumerates_empty_directories_live(tmp_path: Path):
|
||||
async def test_get_all_folders_records_empty_directories_during_scan(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
(tmp_path / "empty").mkdir()
|
||||
(tmp_path / "empty" / "nested_empty").mkdir()
|
||||
@@ -1311,7 +1435,7 @@ async def test_get_all_folders_enumerates_empty_directories_live(tmp_path: Path)
|
||||
# cache.folders stays models-only
|
||||
assert sorted(cache.folders) == ["", "nested"]
|
||||
|
||||
# Live enumeration includes empty directories and stays a superset
|
||||
# Scan recording includes empty directories and stays a superset
|
||||
assert set(cache.folders) <= set(all_folders)
|
||||
assert "empty" in all_folders
|
||||
assert "empty/nested_empty" in all_folders
|
||||
@@ -1328,49 +1452,60 @@ async def test_get_all_folders_enumerates_empty_directories_live(tmp_path: Path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_uses_ttl_cache(tmp_path: Path, monkeypatch):
|
||||
async def test_get_all_folders_never_walks_filesystem(tmp_path: Path, monkeypatch):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
walk_calls = {"n": 0}
|
||||
real_walk = os.walk
|
||||
def failing_walk(*args, **kwargs):
|
||||
raise AssertionError("get_all_folders must not walk the filesystem")
|
||||
|
||||
def counting_walk(*args, **kwargs):
|
||||
walk_calls["n"] += 1
|
||||
return real_walk(*args, **kwargs)
|
||||
monkeypatch.setattr(model_scanner.os, "walk", failing_walk)
|
||||
|
||||
monkeypatch.setattr(model_scanner.os, "walk", counting_walk)
|
||||
|
||||
first = await scanner.get_all_folders()
|
||||
assert walk_calls["n"] == 1
|
||||
|
||||
# Second call within the TTL reuses the cached result without re-walking
|
||||
second = await scanner.get_all_folders()
|
||||
assert walk_calls["n"] == 1
|
||||
assert second == first
|
||||
|
||||
# After the TTL expires the roots are walked again
|
||||
real_monotonic = time.monotonic
|
||||
monkeypatch.setattr(
|
||||
model_scanner.time,
|
||||
"monotonic",
|
||||
lambda: real_monotonic() + model_scanner.ALL_FOLDERS_CACHE_TTL_SECONDS + 1,
|
||||
)
|
||||
third = await scanner.get_all_folders()
|
||||
assert walk_calls["n"] == 2
|
||||
assert third == first
|
||||
all_folders = await scanner.get_all_folders()
|
||||
assert all_folders == ["" , "nested"]
|
||||
# No backfill is scheduled when the scan already recorded the folders
|
||||
assert scanner._all_folders_backfill_running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_invalidated_after_move(tmp_path: Path):
|
||||
async def test_get_all_folders_backfills_when_never_recorded(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
(tmp_path / "empty").mkdir()
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
# Simulate a cache hydrated from a persisted snapshot that predates
|
||||
# folder recording.
|
||||
cache = await scanner.get_cached_data()
|
||||
cache.all_folders = None
|
||||
|
||||
# The cold path returns the models-only folders immediately...
|
||||
all_folders = await scanner.get_all_folders()
|
||||
assert set(all_folders) == {"", "nested"}
|
||||
# ...and schedules a one-shot background walk to backfill the rest.
|
||||
assert scanner._all_folders_backfill_running is True
|
||||
|
||||
for _ in range(200):
|
||||
if not scanner._all_folders_backfill_running:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert scanner._all_folders_backfill_running is False
|
||||
assert cache.all_folders is not None
|
||||
assert "empty" in cache.all_folders
|
||||
all_folders = await scanner.get_all_folders()
|
||||
assert "empty" in all_folders
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
|
||||
await scanner._initialize_cache()
|
||||
|
||||
cached = await scanner.get_all_folders()
|
||||
assert scanner._all_folders_ttl_cache is not None
|
||||
assert "new/deep" not in cached
|
||||
|
||||
# Simulate a move: target directories exist on disk (created by
|
||||
@@ -1390,9 +1525,7 @@ async def test_get_all_folders_invalidated_after_move(tmp_path: Path):
|
||||
|
||||
await scanner.update_single_model_cache(original, new_path, moved_metadata)
|
||||
|
||||
# The TTL cache was invalidated by the move
|
||||
assert scanner._all_folders_ttl_cache is None
|
||||
|
||||
# The recorded folder list picked up the destination (and its parents)
|
||||
all_folders = await scanner.get_all_folders()
|
||||
cache = await scanner.get_cached_data()
|
||||
assert sorted(cache.folders) == ["nested", "new/deep"]
|
||||
@@ -1401,6 +1534,63 @@ async def test_get_all_folders_invalidated_after_move(tmp_path: Path):
|
||||
assert set(cache.folders) <= set(all_folders)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_folders_persisted_and_hydrated(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||
db_path = tmp_path / 'cache.sqlite'
|
||||
store = PersistentModelCache(db_path=str(db_path))
|
||||
monkeypatch.setattr(model_scanner, 'get_persistent_cache', lambda: store)
|
||||
|
||||
root = tmp_path / 'models'
|
||||
root.mkdir()
|
||||
(root / 'one.txt').write_text('one', encoding='utf-8')
|
||||
(root / 'empty').mkdir()
|
||||
|
||||
scanner = DummyScanner(root)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
assert cache.all_folders is not None
|
||||
assert 'empty' in cache.all_folders
|
||||
|
||||
# The folder list (including the empty dir) survives in SQLite.
|
||||
persisted = store.load_cache('dummy')
|
||||
assert persisted is not None
|
||||
assert persisted.all_folders is not None
|
||||
assert 'empty' in persisted.all_folders
|
||||
|
||||
# A fresh scanner hydrates the recorded folders without any walk.
|
||||
ModelScanner._instances.clear()
|
||||
hydrated = DummyScanner(root)
|
||||
scan_result, invalid = hydrated._rebuild_persisted_cache()
|
||||
assert scan_result is not None
|
||||
assert scan_result.all_folders == persisted.all_folders
|
||||
|
||||
|
||||
def test_all_folders_absent_in_legacy_snapshot(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||
store = PersistentModelCache(db_path=str(tmp_path / 'cache.sqlite'))
|
||||
|
||||
normalized = _normalize_path(tmp_path / 'one.txt')
|
||||
raw_model = {
|
||||
'file_path': normalized,
|
||||
'file_name': 'one',
|
||||
'model_name': 'one',
|
||||
'folder': '',
|
||||
'size': 3,
|
||||
'modified': 123.0,
|
||||
'sha256': 'hash-one',
|
||||
'tags': [],
|
||||
}
|
||||
|
||||
# Save without folder data, mimicking a snapshot written before folder
|
||||
# recording existed.
|
||||
store.save_cache('dummy', [raw_model], {'hash-one': [normalized]}, [])
|
||||
|
||||
persisted = store.load_cache('dummy')
|
||||
assert persisted is not None
|
||||
assert persisted.all_folders is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_cache_broadcasts_scan_progress(tmp_path: Path, monkeypatch):
|
||||
_create_files(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user