Compare commits

..

4 Commits

Author SHA1 Message Date
Will Miao 6d3f82976f fix(scanner): serve folder tree from scan-recorded, persisted directory list (#1110)
The include_empty folder tree (download/move modals) walked every model
root synchronously on the event loop via get_all_folders(). On network
(NAS) roots this froze the whole server for the duration of the walk —
blocking WebSocket progress, aria2 RPC and the download queue — and the
5s TTL re-triggered the walk on nearly every modal interaction.

The scanners already visit every directory during cache scans, so record
the full directory list (including empty folders) there instead:

- _gather_model_data/_reconcile_cache collect directories during the
  existing walks; reconcile refreshes and persists the list even when no
  model files changed.
- ModelCache gains an all_folders field (None = never recorded).
- PersistentModelCache stores the list in a new folders table, with a
  cache_meta flag distinguishing 'recorded empty' from legacy snapshots.
- get_all_folders() is now a pure in-memory read. A legacy snapshot
  triggers a one-shot backfill walk in a worker thread (never on the
  event loop) that records and persists the list.
- Moves add the destination folder (and parents) incrementally instead
  of invalidating a TTL cache.
2026-09-11 23:03:24 +08:00
Will Miao 91b2735dad fix(recipes): make batch-import directory browser work on Windows (#1106)
The browse endpoint and its frontend were written with POSIX-only
assumptions, so on Windows pressing Browse immediately failed with
"Access denied to this directory":

- The frontend opened the browser at "/", which resolves to the
  current drive root on Windows.
- The allowlist check used Path("/"), which has no drive letter on
  Windows, so relative_to() rejected every drive-qualified path —
  anything outside the user profile was denied.

Fixes:
- Empty browse path now defaults to the user home directory instead of
  erroring; the frontend sends "" rather than the POSIX-only "/".
- The access check is platform-aware (drive-qualified on Windows,
  absolute on POSIX).
- Parent navigation uses the server-provided parent_path; the root
  check is now path.parent == path (the old str/anchor comparison
  self-looped at Windows drive roots).
- Browsing up from a Windows drive root shows a virtual list of
  available drives so users can switch drives without typing a path.
2026-09-11 22:23:55 +08:00
Will Miao 3112869a21 docs(technical): record Windows case-fold fallback follow-up in reconcile
The Windows-only case-insensitive match in ModelScanner._reconcile_cache
is the only pass left unverified by the recent realpath cleanup: realpath
may already cover case differences on Windows, and if the branch is ever
reachable it is O(files x cache entries). Records the reachability
question, the verification steps for a Windows run, and the two possible
fixes.
2026-09-11 22:23:55 +08:00
Will Miao aa630bf85b perf(services): skip per-file realpath work in cache reconciliation
A no-change Refresh still computed os.path.realpath for every model file
in the library and for every cached entry. Both values are only ever
consulted when a discovered file is missing from the cache, so on a
50k-file library they cost ~1.3s and ~0.6s while being used zero times.

- Compute the per-file realpath only after the exact cache match fails
- Build the physical-path alias map lazily on the first miss; the
  cross-run alias guard (overlapping roots / symlink layout changes)
  still keeps the cached entry instead of a delete + re-add, which would
  re-read metadata and re-hash the whole library
- Snapshot get_model_roots() once for the new-file pass instead of
  re-reading it for every added file
- Run the duplicate-path integrity pass only when the snapshot already
  contained duplicates or files were appended; a clean, unchanged cache
  has nothing to clean. Duplicates can only be introduced by external
  code rewriting raw_data or by this pass's own appends.

Zero-change reconcile drops from ~1400ms to ~120ms on 50k files, and an
alias flip still re-processes 0 files (#1108 investigation).
2026-09-11 22:23:55 +08:00
10 changed files with 850 additions and 147 deletions
@@ -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 项全部通过。
+65 -27
View File
@@ -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),
}
)
+5
View File
@@ -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
View File
@@ -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()
+50 -1
View File
@@ -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)
+23 -16
View File
@@ -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
+223 -33
View File
@@ -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)