Compare commits

..
15 Commits
Author SHA1 Message Date
Will Miao b197793b10 fix(rate-limit): do not leave the gate on an early timer wake
`wait_for_slot` trusted a single `asyncio.sleep(delay)`. The event-loop timer can
fire before the delay elapsed -- Windows granularity is ~15ms, which the new
Windows job caught -- and the method then returned while the cooldown was still
armed, so the caller sent a request inside the window the gate exists to enforce.

It now re-checks the fixed `wake_at` and sleeps again, which also restores the
strict `elapsed >= 0.14` cooldown bounds the earlier relaxation had weakened (a
substring replace had turned two of them into `>= 0.09`), and a regression test
fails the gate if it ever returns on an early wake.
2026-10-12 11:59:10 +08:00
Will Miao 946809f47c test(rate-limit): tolerate the timer on Windows, and restore two bounds
The last Windows failure was float noise on an upper bound: a 0.15s cooldown
window reports 0.15000000000000568 remaining, so the assertion missed by 6e-15.
The upper bound now carries an epsilon.

The two `elapsed >= 0.14` cooldown assertions had been weakened to `>= 0.09` by
a substring replace while relaxing the pacing bound -- that would have let a
missing wait pass. They are back at 0.12 (0.15s minus Windows timer rounding),
which still fails if the cooldown is not awaited.
2026-10-12 11:51:48 +08:00
Will Miao d25f170ffe test: fold the last Windows path and label assumptions
Second pass over the informational Windows job (91 failures -> 11 -> 0):

* `_identity`/`_fold` now wrap the assertions my first sweep skipped: the
  parenthesised and multi-line ones, and the ones whose token list was
  incomplete (`base_models_roots`, `embeddings_roots`).
* The reconciler's `current_name` and `active_roots` carry the drive-prefixed
  label on Windows ("C: loras"), so the last direct comparison goes through
  `_root_label_names` too.
* `open_settings_location` reveals the settings *file* with
  `explorer /select,` on Windows instead of opening the containing folder, so
  that test asserts the platform's call instead of the POSIX one.
* Three more expectations are written in the business form the code returns
  (metadata payload, extracted download paths, hydrated preview URL).
2026-10-12 11:46:13 +08:00
Will Miao c16de68640 test: make the backend suite pass on Windows
The new informational Windows job surfaced 91 failures: four real bugs (fixed in
the previous two commits) and a long tail of POSIX assumptions in the tests
themselves. None of them assert behaviour that differs on Windows, so they are
fixed rather than skipped wherever the behaviour is the same:

* Path spelling: expectations built with `str(tmp_path)` are compared through an
  identity helper (`normalize_root_identity`) or the platform's separator, since
  the code hands out forward-slashed business paths while `str()` is native.
* Root labels: `_root_display_labels` prefixes the drive on Windows ("C: loras"),
  so label assertions compare the final path component or derive the expected
  label from the same helper.
* `~` expansion reads USERPROFILE (ntpath), not HOME; timer granularity rounds
  0.1s down; a case-only rename counts as "already named" via `os.path.normcase`.
* Genuinely POSIX-only strategies are skipped with a reason instead of failing:
  the headless clipboard fallback, dbus file-manager selection, xdg-open, and
  the case-fold index test that simulates a case-sensitive filesystem.
2026-10-12 11:35:33 +08:00
Will Miao 85f79283b6 fix(paths): keep business paths forward-slashed on Windows
Four places produced the host's separator (or a mixed path) where the app's
business form is forward-slashed, so on Windows the value could not be compared
with the one the rest of the app stores:

* `ModelScanner._calculate_folder` matched `file_path.startswith(root)` as raw
  strings, so every forward-slashed path resolved to the library root and a
  moved model was filed under the root instead of its folder. It now compares
  root identities and slices the relative part off.
* `normalize_relative_folder` ran `os.path.normpath`, and ntpath folds forward
  slashes back to backslashes: every folder parameter (scoped scan, create,
  rename, delete) came back host-spelled and mismatched the stored folders.
* `_format_model_name_for_comfyui` returned `os.path.relpath` unchanged, so the
  checkpoint/unet nodes and the metadata node emitted `unet\base.safetensors`
  on Windows -- a name no ComfyUI widget list matches.
* `resolve_centralized_dir_for_dir` passed a component that spans two
  directories ("<library>/<component>") whole to `os.path.join`, leaving a mixed
  path ("...\comfyui/loras-1a2b3c4d\sub") that no native path equals.

Also hands the rename re-key callback business paths, like every other absolute
path the move service returns.
2026-10-12 11:35:33 +08:00
Will Miao fd40cd952b fix(cache): close the connection the schema init opened
`with sqlite3.Connection` ends the transaction but keeps the file handle open,
so every cache class leaked one handle per instance until the garbage collector
happened to run. On Windows that makes the database file undeletable while the
process lives (WinError 32), which broke 30 cache tests in teardown and would
also block moving or clearing a cache at runtime.

All eight `with <connection>` sites now go through `closing_connection()`, and a
regression test asserts the schema init leaves no usable connection behind.
2026-10-12 11:35:14 +08:00
Will Miao 5fbac84910 test(i18n): fail when a locale file is re-serialised
The layout is load-bearing for review: re-dumping a locale with a different
`indent` rewrites every line, so the diff hides the actual change. Assert that each
file still matches the canonical serialisation it is maintained in (`en.json`
indent=2, the translated locales indent=4, `ensure_ascii=False`, one trailing
newline), so the mistake fails in CI instead of in review.
2026-10-12 10:55:49 +08:00
Will Miao a750ca88ac docs(agents): pin the locale file layout and the stale-translation rule
Two things agents kept getting wrong while editing UI copy, both learned the
expensive way:

* `locales/*.json` come in two layouts: `en.json` (the reference, hand-maintained)
  is 2-space indented, the nine translated locales are 4-space (what
  `sync_translation_keys.py` writes). Loading one with `json.load` and dumping it
  back with the other indent reformats the whole file: changing two strings in the
  translated locales produced a 26k-line diff that had to be reverted and redone in
  place. The section now names both layouts, says to edit values in place, and adds
  the cheap guard that catches the whole class of mistake: after writing to an
  existing structured file, `git diff --stat` must match the change you meant to
  make.
* Changing an existing English string makes every other locale's value stale.
  Those values have to go back to `[TODO: Translate] <new English>` rather than
  keep describing the old behaviour until someone notices.
2026-10-12 10:55:49 +08:00
Will Miao b68af5400e fix(sidebar): shorten the missing-folder dialog copy (#1145)
The dialog sits in the sidebar and now offers the removal itself, so telling the
user the entries "can still be removed from the sidebar" said the obvious twice
and read oddly next to a button that does exactly that. Say what is true and stop:
"This folder no longer exists on disk. You can remove it from the list."

The result toast follows the button's wording ("removed from the list") instead of
naming the sidebar again. Both strings changed in English, so the existing
translations of them are back in the queue with the new source text rather than
left saying "refresh the sidebar and try again".
2026-10-12 10:35:04 +08:00
Will Miao 5976224d75 fix(pending-delete): drop the staging container with its last batch (#1145)
Deleting a folder that holds models answered "A deletion is still pending inside
this folder. Wait for the undo window to expire." even though nothing was staged
and no window was running. `.lm-pending-delete` is only a container: the purge,
the undo and the staging rollback all removed the batch dir inside it and left the
container behind, and the folder manifest matched the directory *name* alone. Any
folder where a model had ever been deleted with the undo window was therefore
undeletable for good, and the same name match refused folder renames.

* `staging_dir_has_content()` is the shared answer to "is there still a batch
  here?": an empty container is a leftover, a quarantined batch (`*.orphaned`) is
  preserved data and still counts, and an unreadable directory counts too — never
  delete over something that could not be listed.
* The container is removed with its last batch (purge, undo, staging rollback,
  merged-loser cleanup). `rmdir` only ever succeeds on an empty directory, and the
  path is checked to really be a `.lm-pending-delete`, so a model folder is never
  at risk.
* A manifest-less batch dir with nothing in it is a crash leftover between
  `makedirs` and the manifest write: it is dropped instead of quarantined, which
  would have left an empty container that blocks deletion just the same.

Verified: purge, undo and the empty-manifest-less sweep all leave no container
behind, the container survives while another batch is staged, and an empty one no
longer blocks folder delete or rename while a real (or quarantined) batch still
does. 3818 backend tests pass.
2026-10-12 10:34:55 +08:00
Will Miao 3a718ce2c3 fix(sidebar): let a ghost folder be cleared from the UI (#1145)
Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.

* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
  plus the cached models under it) and refuses with `code: "exists"` while any root
  still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
  already gone. The dry run stays a pure read and keeps answering `missing`, which
  is what makes the dialog offer the removal. `POST`/`GET
  /api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
  directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
  the missing state, and reports the folder as removed from the sidebar rather than
  as a delete that never happened. An `exists` conflict says the directory is back
  and should be deleted instead.

Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
2026-10-12 09:31:29 +08:00
Will Miao 0508daa0fb fix(scanner): verify recorded folders per entry, not per scan (#1145)
`_reconcile_cache()` replaced the recorded folder list only when *nothing* was left
unverified: one switched-off drive, one directory `os.walk` could not enter, or one
symlink whose target was gone, and the whole list fell back to a union with what
the walk found. A folder moved with a file manager therefore stayed in the sidebar
for good — the walk kept adding the new location while the old entry was never
dropped, a restart re-hydrated the union from SQLite, and the ghost could not be
deleted in the UI either because its directory was gone.

* `_UnreachablePaths.covers_folder()` answers "could this specific entry have been
  read?" for a library-relative folder: it is kept only while a directory the walk
  failed to enter (or an offline symlink) may still hold it. A root that is
  unreadable *as a whole* deliberately does not count: library-relative names carry
  no root, so a missing drive would trivially cover every entry and no ghost could
  ever be cleared. Its folders still show while they hold cached models
  (`get_all_folders()` unions the model-derived folders) and a genuinely empty one
  comes back the next time that root is walked.
* The merge keeps today's union for a scoped scan, and for a root-scoped scan that
  does not cover every root: those cannot speak for the roots they were not asked
  about. Asking for every root is a full refresh and cleans again.

Verified: a folder moved externally is dropped while another root is offline, the
offline root keeps its own folders and cached models, and a scoped scan still never
drops a folder of a root it did not scan. The Windows-only half of this (a
backslash `os.sep` making the configured twin shadow the live root, so deleted
models were never pruned) is now reproduced in CI by patching `os.sep` instead of
needing a Windows runner.
2026-10-12 09:31:17 +08:00
Will Miao fadad1b507 ci(backend): run the test suite on Windows too (#1146)
The backend suite only ever ran on ubuntu-latest, which is why #1146 shipped:
every configured model root counted twice, the duplicate shadowed the live root
as unreachable, and models deleted from disk were never pruned again. That whole
class of bug depends on `os.sep`/`os.name`, so a Linux-only matrix cannot see it.

* New `pytest-windows` job on `windows-latest` running the same suite. No
  coverage upload: the Linux job keeps producing that artifact.
* Informational for now (`continue-on-error: true`) so pre-existing Windows
  failures surface in the Actions UI without blocking merges; the flag comes off
  once the suite is Windows-clean.
* The symlink probe reports instead of aborting: `os.symlink` needs Developer
  Mode or an elevated shell on Windows, and a runner without it should still show
  the real test results.
2026-10-12 08:43:06 +08:00
Will Miao 6d0d7cb15b fix(scanner): compare model roots by identity, not by spelling (#1146)
ComfyUI hands its model folders over in the host's own spelling --
`utils/extra_config.py` runs every configured path through `os.path.normpath()`,
which is all backslashes on Windows, and `main.py` registers `<output>/loras`
unconditionally for its save nodes. `Config` kept that spelling verbatim while
`_prepare_*_paths()` normalized the live root lists to forward slashes, and the
scanner compared the two as raw strings. On Windows every configured root
therefore looked like a root that was not in the live list: the Refresh menu
listed each directory twice (the twin showing "0 models"), the completion toast
claimed reachable roots were "currently inaccessible" and kept their whole cache
count as `kept_unreachable`, and the settings sync persisted host-registered
directories that were never created.

The duplication was not only cosmetic. `_UnreachablePaths.add()` normalizes the
prefix it is given, so the configured twin of a live root registered exactly the
live root's prefix as unreadable: in the prune phase every cached-but-missing
entry under it matched and was "kept", so models deleted from disk were never
removed from the cache again.

* `py/utils/root_paths.py` holds the single `normalize_root_identity()`
  (separator style, redundant/trailing separators, and case where the filesystem
  folds it: whole paths on Windows, drive letters everywhere). `config.py` and
  `settings_manager.py` each carried a private copy and `model_scanner.py`
  compared raw strings.
* `ModelScanner` compares root identities in `get_configured_model_roots()`,
  `describe_model_roots()` (dedupe + `available`), the reconcile's
  `configured_only` list and the `ReconcileScope.roots` membership test, so one
  directory is one root whatever the host called it. `_normalized_root_prefix()`
  is built on the same identity, and an empty prefix no longer matches every
  path.
* A configured root that never made it into the live list is reported only while
  it still holds cached entries -- in the menu and in `skipped_roots` /
  `unavailable_paths` alike. A directory that neither exists nor was ever scanned
  has nothing to keep, so `<output>/loras` stops being an unavailable root.
* The startup sync drops saved roots that neither exist nor hold cached entries
  (`PersistentModelCache.roots_with_cached_entries()`, one scan per model type,
  separator-agnostic and wildcard-escaped). A root named by the library's
  `default_*_root` is kept even when empty: dropping it would let the next boot
  "repair" the default onto another root. When the cache cannot be consulted,
  every root is kept.
* `GET /scan?roots=` validates requested roots by identity too, so the browser
  extension or a script passing the spelling settings.json holds selects the root
  instead of answering 400 "Unknown model root(s)".

Verified: 8 of the new tests fail against the pre-fix source (checked with the
fix stashed); 3798 passed, 7 skipped, 11 deselected. The probe costs 71ms for a
100k-model library with seven missing roots (357ms when probing root by root).
2026-10-12 08:43:06 +08:00
willmiao e056142669 docs: auto-update supporters list in README 2026-10-11 08:23:13 +00:00
55 changed files with 2163 additions and 240 deletions
+59
View File
@@ -91,3 +91,62 @@ jobs:
name: backend-coverage name: backend-coverage
path: coverage/backend path: coverage/backend
if-no-files-found: warn if-no-files-found: warn
pytest-windows:
# Windows-only regressions cannot be caught by the Linux job: model roots,
# sidecars and symlink handling all branch on `os.sep`/`os.name`. Issue #1146
# (every configured root counted twice and shadowed as unreachable) shipped
# because the suite only ever ran on Ubuntu.
#
# Informational for now: the flag comes off once the suite is Windows-clean.
name: Run pytest on Windows (informational)
runs-on: windows-latest
continue-on-error: true
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: |
requirements.txt
requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Report symlink support
# Symlinked model roots are part of the suite; os.symlink needs Developer
# Mode (or an elevated shell) on Windows. Reported, not enforced, so a
# runner without it still shows the real test results.
continue-on-error: true
run: |
python - <<'PY'
import os
import pathlib
import tempfile
root = pathlib.Path(tempfile.mkdtemp(prefix="lm-symlink-check-"))
target = root / "target"
target.mkdir()
link = root / "link"
try:
link.symlink_to(target, target_is_directory=True)
except OSError as exc:
print(f"symlinks unavailable: {exc}")
else:
print(
f"islink={os.path.islink(link)} isdir={os.path.isdir(link)} "
f"realpath={os.path.realpath(link)}"
)
PY
shell: bash
- name: Run pytest
run: python -m pytest -q
+14
View File
@@ -72,11 +72,25 @@ python scripts/sync_translation_keys.py
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he). Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
`locales/*.json` keep a hand-maintained layout and **the two groups differ**: `en.json` (the
reference) uses **2-space indent**, the nine translated locales use **4-space indent** (what
`sync_translation_keys.py` writes). Both keep `ensure_ascii=False` and exactly one trailing newline.
Edit values **in place** (line/anchored text edit) and add keys with the sync script. Never
`json.load` + `json.dumps` a locale file just to change a string: the serializer rewrites the whole
layout, so a two-value edit lands as a several-thousand-line diff that has to be reverted and redone
(`tests/i18n/test_i18n.py::test_locale_files_keep_their_maintained_layout` now fails on that).
After writing to any existing structured file, check `git diff --stat`: the changed-line count must
match the change you meant to make.
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
the other locales are the expected end state during feature development. Do NOT translate the other locales are the expected end state during feature development. Do NOT translate
proactively — translate only when the feature owner explicitly asks (see proactively — translate only when the feature owner explicitly asks (see
`docs/i18n-translation-guidelines.md` §7). `docs/i18n-translation-guidelines.md` §7).
Changing an **existing** English string is a translation event too: the other locales' value for
that key is now stale, so put it back in the queue (`[TODO: Translate] <new English>`) instead of
leaving a translation that describes the old behaviour.
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the **Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
brand names are never translated), per-locale preferred renderings, placeholder rules, and brand names are never translated), per-locale preferred renderings, placeholder rules, and
+2 -2
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -22,9 +22,11 @@ Implementation notes / deviations from the draft below:
they are outside every configured root prefix. The `root_unreachable` path therefore covers the they are outside every configured root prefix. The `root_unreachable` path therefore covers the
*mid-session* case (drive switched off while LM runs), which is the workflow that motivated the *mid-session* case (drive switched off while LM runs), which is the workflow that motivated the
issue; both paths keep the entries, which is what matters. issue; both paths keep the entries, which is what matters.
* Known limitation to keep in mind: after a scoped scan (or one with unreadable paths) the recorded * Known limitation to keep in mind: after a *scoped* scan the recorded empty-folder list is unioned
empty-folder list is unioned instead of replaced, so an empty folder deleted on disk can linger in instead of replaced, so an empty folder deleted on disk can linger in the sidebar until the next
the sidebar until the next full refresh. Models are unaffected. full refresh. A full refresh keeps only the entries a specific unreadable area (a directory
`os.walk` could not enter, an offline symlink) may still hold, so one switched-off drive no longer
keeps every other root's ghosts alive (issue #1145).
## TL;DR (For humans) ## TL;DR (For humans)
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.", "notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.", "notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"missingTitle": "Ordner nicht gefunden", "missingTitle": "Ordner nicht gefunden",
"missingMessage": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "Ordner ist ein symbolischer Link", "symlinkTitle": "Ordner ist ein symbolischer Link",
"symlinkMessage": "Alle Kopien dieses Ordners sind symbolische Links; entfernen Sie den Link oder sein Ziel außerhalb von LoRA Manager.", "symlinkMessage": "Alle Kopien dieses Ordners sind symbolische Links; entfernen Sie den Link oder sein Ziel außerhalb von LoRA Manager.",
"protectedTitle": "Ordner ist geschützt", "protectedTitle": "Ordner ist geschützt",
@@ -1440,6 +1441,8 @@
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.", "busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt", "unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.", "missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste gelöscht werden.", "symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste gelöscht werden.",
"successMulti": "Ordner \"{name}\" aus {count} Modell-Stammverzeichnissen gelöscht", "successMulti": "Ordner \"{name}\" aus {count} Modell-Stammverzeichnissen gelöscht",
"partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut." "partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.", "notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.", "notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
"missingTitle": "Folder not found", "missingTitle": "Folder not found",
"missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.", "missingMessage": "This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "Remove from list",
"symlinkTitle": "Folder is a symbolic link", "symlinkTitle": "Folder is a symbolic link",
"symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.", "symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.",
"protectedTitle": "Folder is protected", "protectedTitle": "Folder is protected",
@@ -1440,6 +1441,8 @@
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", "busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder deletion is not supported on this page", "unsupported": "Folder deletion is not supported on this page",
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again.", "missing": "This folder no longer exists on disk. Refresh the sidebar and try again.",
"forgotten": "Folder \"{name}\" removed from the list",
"exists": "The folder is back on disk. Delete it instead.",
"symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.", "symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.",
"successMulti": "Folder \"{name}\" deleted from {count} model roots", "successMulti": "Folder \"{name}\" deleted from {count} model roots",
"partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry." "partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.", "notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.", "notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"missingTitle": "Carpeta no encontrada", "missingTitle": "Carpeta no encontrada",
"missingMessage": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "La carpeta es un enlace simbólico", "symlinkTitle": "La carpeta es un enlace simbólico",
"symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.", "symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.",
"protectedTitle": "Carpeta protegida", "protectedTitle": "Carpeta protegida",
@@ -1440,6 +1441,8 @@
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.", "busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "La eliminación de carpetas no es compatible con esta página", "unsupported": "La eliminación de carpetas no es compatible con esta página",
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.", "missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Esta carpeta es un enlace simbólico; no se puede eliminar desde la barra lateral.", "symlink": "Esta carpeta es un enlace simbólico; no se puede eliminar desde la barra lateral.",
"successMulti": "Carpeta \"{name}\" eliminada de {count} raíces de modelo", "successMulti": "Carpeta \"{name}\" eliminada de {count} raíces de modelo",
"partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo." "partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.", "notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.", "notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"missingTitle": "Dossier introuvable", "missingTitle": "Dossier introuvable",
"missingMessage": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "Le dossier est un lien symbolique", "symlinkTitle": "Le dossier est un lien symbolique",
"symlinkMessage": "Toutes les copies de ce dossier sont des liens symboliques ; supprimez le lien ou sa cible en dehors de LoRA Manager.", "symlinkMessage": "Toutes les copies de ce dossier sont des liens symboliques ; supprimez le lien ou sa cible en dehors de LoRA Manager.",
"protectedTitle": "Dossier protégé", "protectedTitle": "Dossier protégé",
@@ -1440,6 +1441,8 @@
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.", "busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page", "unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.", "missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Ce dossier est un lien symbolique ; il ne peut pas être supprimé depuis la barre latérale.", "symlink": "Ce dossier est un lien symbolique ; il ne peut pas être supprimé depuis la barre latérale.",
"successMulti": "Dossier \"{name}\" supprimé de {count} racines de modèle", "successMulti": "Dossier \"{name}\" supprimé de {count} racines de modèle",
"partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez." "partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", "notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.", "notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"missingTitle": "התיקייה לא נמצאה", "missingTitle": "התיקייה לא נמצאה",
"missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "התיקייה היא קישור סמלי", "symlinkTitle": "התיקייה היא קישור סמלי",
"symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.", "symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.",
"protectedTitle": "תיקייה מוגנת", "protectedTitle": "תיקייה מוגנת",
@@ -1440,6 +1441,8 @@
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.", "busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה", "unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.", "missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.", "symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.",
"successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים", "successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים",
"partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב." "partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", "notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。", "notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"missingTitle": "フォルダが見つかりません", "missingTitle": "フォルダが見つかりません",
"missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "フォルダがシンボリックリンクです", "symlinkTitle": "フォルダがシンボリックリンクです",
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。", "symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
"protectedTitle": "フォルダは保護されています", "protectedTitle": "フォルダは保護されています",
@@ -1440,6 +1441,8 @@
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。", "busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません", "unsupported": "このページではフォルダを削除できません",
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。", "missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。", "symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました", "successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。" "partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", "notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.", "notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"missingTitle": "폴더를 찾을 수 없습니다", "missingTitle": "폴더를 찾을 수 없습니다",
"missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "폴더가 심볼릭 링크입니다", "symlinkTitle": "폴더가 심볼릭 링크입니다",
"symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.", "symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.",
"protectedTitle": "보호된 폴더", "protectedTitle": "보호된 폴더",
@@ -1440,6 +1441,8 @@
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.", "busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다", "unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.", "missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.", "symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.",
"successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다", "successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다",
"partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요." "partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.", "notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.", "notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
"missingTitle": "Папка не найдена", "missingTitle": "Папка не найдена",
"missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "Папка является символической ссылкой", "symlinkTitle": "Папка является символической ссылкой",
"symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.", "symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.",
"protectedTitle": "Папка защищена", "protectedTitle": "Папка защищена",
@@ -1440,6 +1441,8 @@
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.", "busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице", "unsupported": "Удаление папок не поддерживается на этой странице",
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.", "missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.", "symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.",
"successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей", "successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей",
"partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку." "partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку."
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。", "notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。", "notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
"missingTitle": "未找到文件夹", "missingTitle": "未找到文件夹",
"missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "文件夹是符号链接", "symlinkTitle": "文件夹是符号链接",
"symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。", "symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。",
"protectedTitle": "文件夹受保护", "protectedTitle": "文件夹受保护",
@@ -1440,6 +1441,8 @@
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。", "busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹", "unsupported": "此页面不支持删除文件夹",
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。", "missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "该文件夹是符号链接,无法从侧边栏中删除。", "symlink": "该文件夹是符号链接,无法从侧边栏中删除。",
"successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"", "successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"",
"partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。" "partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。"
+4 -1
View File
@@ -1418,7 +1418,8 @@
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。", "notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。", "notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
"missingTitle": "未找到資料夾", "missingTitle": "未找到資料夾",
"missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", "missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
"removeFromList": "[TODO: Translate] Remove from list",
"symlinkTitle": "資料夾是符號連結", "symlinkTitle": "資料夾是符號連結",
"symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。", "symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。",
"protectedTitle": "資料夾受保護", "protectedTitle": "資料夾受保護",
@@ -1440,6 +1441,8 @@
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。", "busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾", "unsupported": "此頁面不支援刪除資料夾",
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。", "missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
"symlink": "該資料夾是符號連結,無法從側邊欄中刪除。", "symlink": "該資料夾是符號連結,無法從側邊欄中刪除。",
"successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"", "successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"",
"partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。" "partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。"
+104 -15
View File
@@ -4,7 +4,6 @@
# import cycles. Breaking them would require an architectural refactor. # import cycles. Breaking them would require an architectural refactor.
import os import os
import platform import platform
import posixpath
import threading import threading
from pathlib import Path from pathlib import Path
import folder_paths # pyright: ignore[reportMissingImports] import folder_paths # pyright: ignore[reportMissingImports]
@@ -20,6 +19,7 @@ from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_
from .utils.constants import ( from .utils.constants import (
OTHER_MODEL_FOLDER_SUBTYPES, OTHER_MODEL_FOLDER_SUBTYPES,
) )
from .utils.root_paths import normalize_root_identity
from .utils.settings_paths import ( from .utils.settings_paths import (
ensure_settings_file, ensure_settings_file,
get_settings_dir, get_settings_dir,
@@ -34,14 +34,15 @@ standalone_mode = (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Saved folder-path keys mapped to the model type whose cache can tell whether a
def _normalize_root_identity(path: str) -> str: # root that is missing right now is still worth keeping (issue #1146). The
"""Normalize a root path for comparisons across slash styles.""" # checkpoint page holds both checkpoints and unet roots.
_CACHE_MODEL_TYPE_BY_FOLDER_KEY = {
normalized = posixpath.normpath(path.strip().replace("\\", "/")) "loras": "lora",
if len(normalized) >= 2 and normalized[1] == ":": "checkpoints": "checkpoint",
return normalized.lower() "unet": "checkpoint",
return normalized "embeddings": "embedding",
}
def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]: def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]:
@@ -52,11 +53,11 @@ def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str
to the root *order* would move user data. to the root *order* would move user data.
""" """
merged = list(current) merged = list(current)
seen = {_normalize_root_identity(path) for path in merged} seen = {normalize_root_identity(path) for path in merged}
for path in candidates: for path in candidates:
if not isinstance(path, str) or not path.strip(): if not isinstance(path, str) or not path.strip():
continue continue
identity = _normalize_root_identity(path) identity = normalize_root_identity(path)
if identity in seen: if identity in seen:
continue continue
seen.add(identity) seen.add(identity)
@@ -78,15 +79,15 @@ def _resolve_valid_default_root(
stripped = path.strip() stripped = path.strip()
if not stripped: if not stripped:
continue continue
identity = _normalize_root_identity(stripped) identity = normalize_root_identity(stripped)
if identity in seen: if identity in seen:
continue continue
seen.add(identity) seen.add(identity)
fallback_paths.append(stripped) fallback_paths.append(stripped)
allowed = {_normalize_root_identity(path) for path in fallback_paths} allowed = {normalize_root_identity(path) for path in fallback_paths}
if current and _normalize_root_identity(current) in allowed: if current and normalize_root_identity(current) in allowed:
return current return current
if not valid_paths: if not valid_paths:
@@ -320,6 +321,77 @@ class Config:
"Could not load extra paths from library settings: %s", exc "Could not load extra paths from library settings: %s", exc
) )
def _drop_missing_roots_without_cache(
self,
folder_paths_map: Dict[str, List[str]],
default_roots: Mapping[str, str],
) -> Dict[str, List[str]]:
"""Drop saved roots that neither exist nor hold cached entries (issue #1146).
The startup sync persists what is *configured* rather than what is readable,
so a switched-off drive keeps its path (see ``save_folder_paths_to_settings``).
A directory the host registered without ever creating it -- ComfyUI adds
``<output>/loras`` and friends for its save nodes whether or not they exist --
came along for the ride and stayed there as a permanently "unavailable" root.
A root that still holds cached entries is exactly the switched-off drive the
persistence exists for, so it is kept. A root named by the library's
``default_*_root`` is kept even when it is empty: dropping it would let the
next boot repair the default onto another root.
Best effort: when the cache cannot be consulted, every root is kept.
"""
try:
from .services.persistent_model_cache import get_persistent_cache
cache = get_persistent_cache()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Keeping every configured model root: %s", exc)
return folder_paths_map
protected = {
identity
for identity in (
normalize_root_identity(path) for path in default_roots.values()
)
if identity
}
filtered: Dict[str, List[str]] = {}
for key, paths in folder_paths_map.items():
missing = [path for path in paths if not os.path.exists(path)]
if not missing:
filtered[key] = list(paths)
continue
probe = [
path
for path in missing
if normalize_root_identity(path) not in protected
]
try:
cached = cache.roots_with_cached_entries(
_CACHE_MODEL_TYPE_BY_FOLDER_KEY.get(key, "other"), probe
)
except Exception as exc:
logger.debug(
"Keeping every configured %s root (%s): %s", key, missing, exc
)
filtered[key] = list(paths)
continue
cached_ids = {normalize_root_identity(path) for path in cached}
filtered[key] = [
path
for path in paths
if os.path.exists(path)
or normalize_root_identity(path) in protected
or normalize_root_identity(path) in cached_ids
]
return filtered
def save_folder_paths_to_settings(self): def save_folder_paths_to_settings(self):
"""Persist ComfyUI-derived folder paths to the multi-library settings.""" """Persist ComfyUI-derived folder paths to the multi-library settings."""
try: try:
@@ -533,9 +605,26 @@ class Config:
or self._should_activate_comfy_library(libraries, libraries_changed) or self._should_activate_comfy_library(libraries, libraries_changed)
) )
# A root that the host registered but never created (and that holds no
# cached models) is not a library path: persisting it would bring it
# back as an unavailable root on every boot, and in standalone mode the
# saved paths are the only source of truth (issue #1146).
default_roots = {
"loras": default_lora_root
or comfy_library.get("default_lora_root", ""),
"checkpoints": default_checkpoint_root
or comfy_library.get("default_checkpoint_root", ""),
"unet": comfy_library.get("default_unet_root", ""),
"embeddings": default_embedding_root
or comfy_library.get("default_embedding_root", ""),
}
persisted_folder_paths = self._drop_missing_roots_without_cache(
target_folder_paths, default_roots
)
settings_service.upsert_library( settings_service.upsert_library(
"comfyui", "comfyui",
folder_paths=target_folder_paths, folder_paths=persisted_folder_paths,
extra_folder_paths=extra_folder_paths, extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
+47 -1
View File
@@ -55,6 +55,7 @@ from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256 from ...utils.file_utils import calculate_sha256
from ...utils.metadata_manager import MetadataManager from ...utils.metadata_manager import MetadataManager
from ...utils.paid_access import is_early_access_deadline_active from ...utils.paid_access import is_early_access_deadline_active
from ...utils.root_paths import normalize_root_identity
from ...utils.sidecar_paths import get_metadata_path from ...utils.sidecar_paths import get_metadata_path
from ...utils.url_utils import relative_root_prefix from ...utils.url_utils import relative_root_prefix
@@ -1170,7 +1171,18 @@ class ModelQueryHandler:
if requested_roots: if requested_roots:
configured = self._service.get_model_roots() configured = self._service.get_model_roots()
unknown = [root for root in requested_roots if root not in configured] # Compare by identity, not by string: callers may pass the host's
# own spelling of a root (backslashes or a differently cased drive
# letter on Windows, as settings.json and ComfyUI show it), which
# names the same directory the live list holds forward-slashed.
known_roots = {
normalize_root_identity(root) for root in configured
}
unknown = [
root
for root in requested_roots
if normalize_root_identity(root) not in known_roots
]
if unknown: if unknown:
return web.json_response( return web.json_response(
{"error": "Unknown model root(s)", "roots": unknown}, status=400 {"error": "Unknown model root(s)", "roots": unknown}, status=400
@@ -2617,6 +2629,39 @@ class ModelMoveHandler:
self._logger.error("Error deleting folder: %s", exc, exc_info=True) self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def forget_folder(self, request: web.Request) -> web.Response:
"""Drop the records of a folder that no longer exists on disk (#1145).
The sidebar's delete flow reaches this when the node it holds maps to no
directory any more: there is nothing to delete, so the only cleanup left
is forgetting the folder entry and the cached models under it.
"""
data: Dict[str, Any] = {}
if request.method == "POST":
try:
data = await request.json()
except Exception:
data = {}
folder = str(data.get("folder") or request.query.get("folder") or "").strip()
if not folder:
return web.json_response(
{"success": False, "error": "Folder is required"}, status=400
)
try:
result = await self._move_service.forget_folder(folder)
if result.get("success"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# `exists` is a conflict between the tree the client rendered and the
# on-disk truth: the folder is still there, so it must be deleted.
status = 409 if result.get("code") == "exists" else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error forgetting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response: async def rename_folder(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -3689,6 +3734,7 @@ class ModelHandlerSet:
"create_folder": self.move.create_folder, "create_folder": self.move.create_folder,
"resolve_folder": self.move.resolve_folder, "resolve_folder": self.move.resolve_folder,
"delete_folder": self.move.delete_folder, "delete_folder": self.move.delete_folder,
"forget_folder": self.move.forget_folder,
"rename_folder": self.move.rename_folder, "rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
+4
View File
@@ -42,6 +42,10 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"), RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"), RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
# Forgetting a folder that is gone from disk is a state change, so it takes
# GET with query params too (the browser extension is GET-only by convention).
RouteDefinition("POST", "/api/lm/{prefix}/forget-folder", "forget_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/forget-folder", "forget_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"), RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
+2 -1
View File
@@ -8,6 +8,7 @@ import sqlite3
import time import time
from typing import Any, List, Optional from typing import Any, List, Optional
from ..utils.cache_db import closing_connection
from ..utils.cache_paths import get_cache_base_dir from ..utils.cache_paths import get_cache_base_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -127,7 +128,7 @@ class DownloadQueueService:
def _initialize_schema(self) -> None: def _initialize_schema(self) -> None:
if self._schema_initialized: if self._schema_initialized:
return return
with self._connect() as conn: with closing_connection(self._connect()) as conn:
conn.executescript(self._SCHEMA_TABLES) conn.executescript(self._SCHEMA_TABLES)
# Databases created by older versions lack # Databases created by older versions lack
@@ -11,6 +11,7 @@ import sqlite3
import time import time
from typing import Iterable, Mapping, Optional, Sequence from typing import Iterable, Mapping, Optional, Sequence
from ..utils.cache_db import closing_connection
from ..utils.cache_paths import get_cache_base_dir from ..utils.cache_paths import get_cache_base_dir
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
@@ -100,7 +101,7 @@ class DownloadedVersionHistoryService:
def _initialize_schema(self) -> None: def _initialize_schema(self) -> None:
if self._schema_initialized: if self._schema_initialized:
return return
with self._connect() as conn: with closing_connection(self._connect()) as conn:
conn.executescript(self._SCHEMA) conn.executescript(self._SCHEMA)
conn.commit() conn.commit()
self._schema_initialized = True self._schema_initialized = True
+80 -16
View File
@@ -1,7 +1,8 @@
import asyncio import asyncio
import fnmatch import fnmatch
import os
import logging import logging
import os
import posixpath
import shutil import shutil
from typing import Any, Dict, List, Optional, Sequence, Set from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -12,7 +13,10 @@ from ..utils.recipes_paths import path_is_or_contains_recipes_dir
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME from ..services.pending_delete_service import (
PENDING_DELETE_DIR_NAME,
staging_dir_has_content,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,12 +37,11 @@ def normalize_relative_folder(folder: str) -> str:
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"): if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root") raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized) # posixpath, not os.path: ntpath.normpath would fold the business form back
if ( # to backslashes, and every stored folder, cache entry and comparison in the
normalized == ".." # app is forward-slashed.
or normalized.startswith("../") normalized = posixpath.normpath(normalized)
or normalized.startswith(".." + os.sep) if normalized == ".." or normalized.startswith("../"):
):
raise ValueError("Folder path must stay inside the library root") raise ValueError("Folder path must stay inside the library root")
return normalized return normalized
@@ -589,6 +592,33 @@ class ModelMoveService:
return rel.replace(os.sep, "/") return rel.replace(os.sep, "/")
return "" return ""
async def forget_folder(self, folder: str) -> Dict[str, Any]:
"""Drop the records of a folder that is on disk under no root (#1145).
A directory moved or deleted with a file manager can leave the sidebar
holding a node no delete can clear: the folder is gone, so there is
nothing to remove and every attempt answered "Folder no longer exists".
This drops what remains — the recorded folder entry and the cached model
rows under it — and is refused while any root still holds the directory,
because then the caller wants a delete, not a cleanup.
"""
resolution = self.resolve_folder(folder)
if not resolution.get("success"):
return resolution
normalized = resolution.get("folder") or ""
if not normalized:
return {"success": False, "error": "Folder is required"}
if resolution.get("candidates"):
return {
"success": False,
"code": "exists",
"error": "The folder still exists on disk",
}
await self._forget_folder(normalized)
return {"success": True, "folder": normalized}
def resolve_folder(self, folder: str) -> Dict[str, Any]: def resolve_folder(self, folder: str) -> Dict[str, Any]:
"""Map a library-relative folder onto the directories it stands for. """Map a library-relative folder onto the directories it stands for.
@@ -685,11 +715,29 @@ class ModelMoveService:
} }
if not os.path.isdir(absolute_path): if not os.path.isdir(absolute_path):
# `missing` is its own code so the sidebar can tell "this node's # `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation. # directory is gone" apart from a failed operation. There is
# nothing left to delete, but the sidebar can still be holding the
# node: a folder moved or removed with a file manager leaves its
# records behind, and "Folder no longer exists" was a dead end --
# the ghost could not be cleared from the UI at all (issue #1145).
# The real delete therefore forgets those records and reports
# success; the dry run stays a pure read and keeps answering
# `missing`, which is what makes the dialog offer the removal.
if dry_run:
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
relative_folder = self._calculate_relative_folder(absolute_path)
await self._forget_folder(relative_folder, absolute_path)
return { return {
"success": False, "success": True,
"code": "missing", "code": "missing",
"error": "Folder no longer exists", "already_missing": True,
"folder": relative_folder,
"folder_path": absolute_path.replace(os.sep, "/"),
} }
if self._is_model_root(absolute_path): if self._is_model_root(absolute_path):
@@ -836,7 +884,12 @@ class ModelMoveService:
excluded_paths = self._excluded_model_paths() excluded_paths = self._excluded_model_paths()
for dirpath, dirnames, filenames in os.walk(absolute_path): for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames: # The container alone means nothing: an empty `.lm-pending-delete`
# is a leftover from a purge, an undo or a failed staging, and
# treating it as pending made the folder undeletable for good.
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
):
pending_delete_job = True pending_delete_job = True
for name in dirnames: for name in dirnames:
@@ -1009,7 +1062,12 @@ class ModelMoveService:
new_relative = self._calculate_relative_folder(target) new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records( await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target previous_relative,
new_relative,
# Business paths, like every other absolute path this service
# hands out: the scanner re-keys cache entries by prefix.
absolute_path.replace(os.sep, "/"),
target.replace(os.sep, "/"),
) )
return { return {
@@ -1027,9 +1085,15 @@ class ModelMoveService:
@staticmethod @staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool: def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree.""" """Return True when a staged-delete batch lives inside the subtree.
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames: An empty ``.lm-pending-delete`` container does not count: it is a
leftover, not a batch, and a rename must not be refused because of one.
"""
for dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
):
return True return True
return False return False
+158 -42
View File
@@ -25,6 +25,7 @@ from ..utils.recipes_paths import (
get_effective_recipes_dir, get_effective_recipes_dir,
normalized_recipes_dir_key, normalized_recipes_dir_key,
) )
from ..utils.root_paths import normalize_root_identity
from .model_cache import ModelCache from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
@@ -203,16 +204,33 @@ def _root_display_labels(roots: Sequence[str]) -> Dict[str, str]:
return {root: _truncate_label(label) for root, label in labels.items()} return {root: _truncate_label(label) for root, label in labels.items()}
def _root_identity(root: str) -> str:
"""Comparable identity of a model root, folding the host's spelling (issue #1146).
The live root list is forward-slashed while ``Config`` keeps the configured
paths exactly as the host handed them over -- ComfyUI's ``os.path.normpath()``
output is all backslashes on Windows. Comparing the two as raw strings turns
one directory into two roots: a duplicated menu row, a bogus "unavailable"
report, and -- worse -- an unreachable prefix registered over a root the walk
just read, which stops deleted models from ever being pruned.
"""
return normalize_root_identity(root)
def _normalized_root_prefix(root: str) -> str: def _normalized_root_prefix(root: str) -> str:
"""Forward-slash root prefix used to attribute cached paths to a root.""" """Forward-slash root prefix used to attribute cached paths to a root."""
prefix = os.path.normpath(root).replace(os.sep, "/") prefix = _root_identity(root)
if not prefix.endswith("/"): if prefix and not prefix.endswith("/"):
prefix += "/" prefix += "/"
return prefix.lower() if _CASE_INSENSITIVE_PATHS else prefix return prefix
def _path_matches_prefix(path: str, prefix: str) -> bool: def _path_matches_prefix(path: str, prefix: str) -> bool:
"""Match a cached business path against a normalized scope prefix.""" """Match a cached business path against a normalized scope prefix."""
if not prefix:
# An empty prefix would match every path; callers filter empty roots, so
# keep it inert instead of turning it into a catch-all.
return False
candidate = path.lower() if _CASE_INSENSITIVE_PATHS else path candidate = path.lower() if _CASE_INSENSITIVE_PATHS else path
return candidate.startswith(prefix) return candidate.startswith(prefix)
@@ -291,6 +309,36 @@ class _UnreachablePaths:
return prefix return prefix
return None return None
def covers_folder(self, root: str, folder: str) -> bool:
"""Whether *folder* (relative to *root*) sits in an unreadable area.
Used when refreshing the recorded folder list: an entry the walk did not
find is only kept while a *specific* area we could not read may still hold
it -- a directory ``os.walk`` failed to enter, or a first-level symlink
whose target is gone. Both directions count: the folder may be inside the
unreadable directory, or hold it.
A root that is unreadable *as a whole* (drive switched off) deliberately
does not count here. The recorded list holds library-relative names, so a
missing root would trivially "cover" every entry and no ghost could ever be
cleared (issue #1145). Its folders still show up while they hold cached
models -- ``get_all_folders()`` unions the model-derived folders -- and a
genuinely empty folder comes back the next time that root is walked.
"""
relative = (folder or "").replace("\\", "/").strip("/")
if not relative:
return False
root_prefix = _normalized_root_prefix(root)
candidate = f"{root_prefix}{relative}/"
if _CASE_INSENSITIVE_PATHS:
candidate = candidate.lower()
return any(
prefix != root_prefix
and (candidate.startswith(prefix) or prefix.startswith(candidate))
for prefix in self._prefixes
)
def record_kept(self, prefix: str, amount: int = 1) -> None: def record_kept(self, prefix: str, amount: int = 1) -> None:
detail = self._details.get(prefix) detail = self._details.get(prefix)
if detail is not None: if detail is not None:
@@ -332,7 +380,8 @@ def _count_cached_entries_for_prefixes(
for path in cached_paths: for path in cached_paths:
candidate = path.lower() if case_insensitive else path candidate = path.lower() if case_insensitive else path
for key, prefix in ordered: for key, prefix in ordered:
if candidate.startswith(prefix): # An empty prefix would claim every path; see _path_matches_prefix.
if prefix and candidate.startswith(prefix):
counts[key] += 1 counts[key] += 1
break break
return counts return counts
@@ -1621,8 +1670,9 @@ class ModelScanner:
seen: Set[str] = set() seen: Set[str] = set()
for key in keys: for key in keys:
for root in config.configured_roots_for(key): for root in config.configured_roots_for(key):
if root and root not in seen: identity = _root_identity(root)
seen.add(root) if identity and identity not in seen:
seen.add(identity)
roots.append(root) roots.append(root)
return roots return roots
@@ -1651,32 +1701,44 @@ class ModelScanner:
roots: List[str] = [] roots: List[str] = []
seen: Set[str] = set() seen: Set[str] = set()
for root in self.get_model_roots(): for root in self.get_model_roots():
if not root or root in seen: identity = _root_identity(root)
if not identity or identity in seen:
continue continue
seen.add(root) seen.add(identity)
roots.append(root) roots.append(root)
# Configured roots that are missing from the live list: their directory # Configured roots that are missing from the live list: their directory
# did not exist when it was built (a drive switched off at startup). # did not exist when it was built (a drive switched off at startup).
# They are reported so the menu offers a row in both states instead of # Compared by identity, never by string -- the live list is forward-slashed
# hiding the root until the process is restarted. # while Config keeps the host's own spelling, which is all backslashes on
unavailable = [ # Windows (issue #1146). Only roots that still hold cached entries are
root for root in self.get_configured_model_roots() if root not in seen # reported: a path that neither exists nor was ever scanned has nothing to
] # keep, and ComfyUI registers `<output>/loras` for its save nodes whether or
# not that directory is there.
candidates: List[str] = []
reported: Set[str] = set()
for root in self.get_configured_model_roots():
identity = _root_identity(root)
if not identity or identity in seen or identity in reported:
continue
reported.add(identity)
candidates.append(root)
labels = _root_display_labels(roots + unavailable)
cached_paths = ( cached_paths = (
{item.get('file_path', '') for item in self._cache.raw_data} {item.get('file_path', '') for item in self._cache.raw_data}
if self._cache is not None if self._cache is not None
else set() else set()
) )
counts = _count_cached_entries_per_root(cached_paths, roots + unavailable) counts = _count_cached_entries_per_root(cached_paths, roots + candidates)
unavailable = [root for root in candidates if counts.get(root, 0)]
labels = _root_display_labels(roots + unavailable)
return [ return [
{ {
'path': root.replace(os.sep, '/'), 'path': root.replace(os.sep, '/'),
'label': labels[root], 'label': labels[root],
'reachable': os.path.exists(root), 'reachable': os.path.exists(root),
'available': root in seen, 'available': _root_identity(root) in seen,
'models': counts.get(root, 0), 'models': counts.get(root, 0),
} }
for root in roots + unavailable for root in roots + unavailable
@@ -1801,25 +1863,37 @@ class ModelScanner:
configured_roots: List[str] = [] configured_roots: List[str] = []
seen_roots: Set[str] = set() seen_roots: Set[str] = set()
for root_path in self.get_model_roots(): for root_path in self.get_model_roots():
if not root_path or root_path in seen_roots: identity = _root_identity(root_path)
if not identity or identity in seen_roots:
continue continue
seen_roots.add(root_path) seen_roots.add(identity)
configured_roots.append(root_path) configured_roots.append(root_path)
# Configured roots that never made it into the live list (their # Configured roots that never made it into the live list (their
# directory was missing when it was built). They are outside the scan # directory was missing when it was built). They are outside the scan
# scope entirely — which is exactly why their cached entries survive # scope entirely — which is exactly why their cached entries survive
# — so they only need reporting, and their labels must not collide # — so they only need reporting, and their labels must not collide
# with the live ones. # with the live ones. Identity, not string: the live list is
configured_only = [ # forward-slashed while Config keeps the host's own spelling -- all
root for root in self.get_configured_model_roots() # backslashes on Windows -- and a raw compare would report every root
if root and root not in seen_roots # twice and shadow it as unreachable (issue #1146).
] configured_only: List[str] = []
for root in self.get_configured_model_roots():
identity = _root_identity(root)
if not identity or identity in seen_roots:
continue
seen_roots.add(identity)
configured_only.append(root)
all_labels = _root_display_labels(configured_roots + configured_only) all_labels = _root_display_labels(configured_roots + configured_only)
scope_root_ids = (
{_root_identity(root) for root in scope.roots}
if scope is not None and scope.roots is not None
else None
)
scope_roots = [ scope_roots = [
root for root in configured_roots root for root in configured_roots
if scope is None or scope.roots is None or root in scope.roots if scope_root_ids is None or _root_identity(root) in scope_root_ids
] ]
scope_folder = scope.folder if scope is not None else None scope_folder = scope.folder if scope is not None else None
@@ -1853,7 +1927,12 @@ class ModelScanner:
) )
for root_path, _prefix in folder_candidates: for root_path, _prefix in folder_candidates:
kept = folder_counts.get(root_path, 0) kept = folder_counts.get(root_path, 0)
if scope_folder is not None and not kept: # Only a root that still holds cached entries is reported --
# and protected from pruning. A configured path that neither
# exists nor was ever scanned has nothing to keep, and ComfyUI
# registers `<output>/loras` for its save nodes even when that
# directory is absent (issue #1146).
if not kept:
continue continue
unreachable.add(root_path, 'root_unavailable', kept=kept) unreachable.add(root_path, 'root_unavailable', kept=kept)
skipped_roots.append({ skipped_roots.append({
@@ -2148,22 +2227,43 @@ class ModelScanner:
total_removed += dedup_removed total_removed += dedup_removed
# The walk above visited every directory *in scope*, so refresh the # The walk above visited every directory *in scope*, so refresh the
# recorded folder list (including empty folders) when nothing was # recorded folder list (including empty folders) when this run could
# left unverified — e.g. an empty folder was created or removed # speak for every root: e.g. an empty folder was created or removed
# externally. A scoped scan, or one with unreadable paths, can only # externally.
# add to the list: dropping folders this walk never looked at would #
# empty the sidebar for every drive that was not scanned. # A folder the walk did not find is dropped unless a root we could not
# read still covers it — the drive is off, ``os.walk`` failed to enter
# a directory, or a first-level symlink lost its target. Keeping the
# *whole* list whenever anything was unreadable is what made a ghost
# folder outlive every refresh, a restart and even the folder being
# deleted in the UI (issue #1145). A scoped scan cannot speak for the
# roots it was not asked about, so it still only adds: dropping
# folders it never looked at would empty the sidebar for every drive
# that was not scanned.
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower()) sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
fully_verified = ( discovered_set = set(sorted_discovered)
len(roots) == len(configured_roots) recorded = set(self._cache.all_folders or [])
and scope_folder is None speaks_for_every_root = scope_folder is None and (
and not unreachable.has_entries() scope is None
or scope.roots is None
or len(scope_roots) == len(configured_roots)
) )
if fully_verified: if speaks_for_every_root:
merged_folders = sorted_discovered merged_folders = sorted(
discovered_set
| {
folder
for folder in recorded - discovered_set
if any(
unreachable.covers_folder(root, folder)
for root in configured_roots + configured_only
)
},
key=lambda x: x.lower(),
)
else: else:
merged_folders = sorted( merged_folders = sorted(
set(self._cache.all_folders or []) | set(sorted_discovered), recorded | discovered_set,
key=lambda x: x.lower(), key=lambda x: x.lower(),
) )
folders_changed = self._cache.all_folders != merged_folders folders_changed = self._cache.all_folders != merged_folders
@@ -2994,12 +3094,28 @@ class ModelScanner:
return await MetadataManager.create_default_metadata(file_path, self.model_class) return await MetadataManager.create_default_metadata(file_path, self.model_class)
def _calculate_folder(self, file_path: str) -> str: def _calculate_folder(self, file_path: str) -> str:
"""Calculate the folder path for a model file""" """Return the library-relative folder holding *file_path*.
The path arrives in whichever spelling its caller holds: ComfyUI's own
(backslashes on Windows) or the forward-slashed business form used for
cache entries. A raw ``startswith(root)`` matched neither the mixed nor
the opposite spelling, so every forward-slashed path on Windows resolved
to the root folder and a moved model was filed under the library root.
Compare identities instead, then slice the relative part off.
"""
candidate = _root_identity(file_path)
if not candidate:
return ""
for root in self.get_model_roots(): for root in self.get_model_roots():
if file_path.startswith(root): identity = _root_identity(root)
rel_path = os.path.relpath(file_path, root) if not identity:
return os.path.dirname(rel_path).replace(os.path.sep, '/') continue
return '' if candidate == identity:
return ""
if candidate.startswith(f"{identity}/"):
relative = candidate[len(identity) + 1:]
return relative.rsplit("/", 1)[0] if "/" in relative else ""
return ""
def adjust_metadata(self, metadata, file_path, root_path): def adjust_metadata(self, metadata, file_path, root_path):
"""Hook for subclasses: adjust metadata during scanning""" """Hook for subclasses: adjust metadata during scanning"""
+5 -4
View File
@@ -17,6 +17,7 @@ from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Seque
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.cache_db import closing_connection
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
@@ -445,7 +446,7 @@ class ModelUpdateService:
if self._schema_initialized: if self._schema_initialized:
return return
try: try:
with self._connect() as conn: with closing_connection(self._connect()) as conn:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(self._SCHEMA) conn.executescript(self._SCHEMA)
@@ -484,7 +485,7 @@ class ModelUpdateService:
return return
try: try:
with sqlite3.connect(legacy_path, check_same_thread=False) as legacy_conn: with closing_connection(sqlite3.connect(legacy_path, check_same_thread=False)) as legacy_conn:
legacy_conn.row_factory = sqlite3.Row legacy_conn.row_factory = sqlite3.Row
status_rows = legacy_conn.execute( status_rows = legacy_conn.execute(
""" """
@@ -2315,7 +2316,7 @@ class ModelUpdateService:
status_rows: list[sqlite3.Row] = [] status_rows: list[sqlite3.Row] = []
version_rows: list[sqlite3.Row] = [] version_rows: list[sqlite3.Row] = []
with self._connect() as conn: with closing_connection(self._connect()) as conn:
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES): for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES]) chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES])
placeholders = ",".join("?" for _ in chunk) placeholders = ",".join("?" for _ in chunk)
@@ -2418,7 +2419,7 @@ class ModelUpdateService:
record.last_checked_at, record.last_checked_at,
1 if record.should_ignore_model else 0, 1 if record.should_ignore_model else 0,
) )
with self._connect() as conn: with closing_connection(self._connect()) as conn:
conn.execute( conn.execute(
""" """
INSERT INTO model_update_status ( INSERT INTO model_update_status (
+46
View File
@@ -55,6 +55,28 @@ MANIFEST_FILE_NAME = "manifest.json"
# deleted by the sweep. # deleted by the sweep.
ORPHANED_SUFFIX = ".orphaned" ORPHANED_SUFFIX = ".orphaned"
def staging_dir_has_content(directory: str) -> bool:
"""Whether a staging directory still holds a batch (or a quarantined one).
``.lm-pending-delete`` is only a container: the batch dir inside it is what
carries the staged files, and the purge, the undo and the staging rollback
all remove it. An empty container left behind must not be mistaken for a
staged delete -- it used to make every later folder delete answer "A
deletion is still pending inside this folder" forever, with no undo window
to wait for and nothing in the UI to clear it (issue #1145 follow-up).
Quarantined batches (``*.orphaned``) are kept on purpose, so anything left
inside still counts. An unreadable directory counts as content too: never
delete over something we could not list.
"""
try:
return bool(os.listdir(directory))
except OSError:
return True
# Map scanner.model_type (singular) to the manifest page type values. # Map scanner.model_type (singular) to the manifest page type values.
_MODEL_TYPE_PAGE_MAP = { _MODEL_TYPE_PAGE_MAP = {
"lora": "loras", "lora": "loras",
@@ -201,6 +223,7 @@ class PendingDeleteService:
if batch_dir: if batch_dir:
self._rollback_model_staging(batch_dir, staged_pairs) self._rollback_model_staging(batch_dir, staged_pairs)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
return None return None
except Exception as exc: # defensive - never block the delete flow except Exception as exc: # defensive - never block the delete flow
logger.warning( logger.warning(
@@ -266,6 +289,7 @@ class PendingDeleteService:
if batch_dir: if batch_dir:
self._rollback_recipe_staging(batch_dir, staged_pairs) self._rollback_recipe_staging(batch_dir, staged_pairs)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
return None return None
except Exception as exc: # defensive - never block the delete flow except Exception as exc: # defensive - never block the delete flow
logger.warning( logger.warning(
@@ -454,6 +478,7 @@ class PendingDeleteService:
# Remove the manifest + batch dir only after all entries restored. # Remove the manifest + batch dir only after all entries restored.
self._remove_manifest(batch_dir) self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
await self._forget_batch(batch_id) await self._forget_batch(batch_id)
# Clean up merged loser dirs (their staged files were restored # Clean up merged loser dirs (their staged files were restored
# above) and drop them from the registry too. # above) and drop them from the registry too.
@@ -832,6 +857,7 @@ class PendingDeleteService:
continue continue
self._remove_manifest(src) self._remove_manifest(src)
self._remove_empty_dir(src) self._remove_empty_dir(src)
self._remove_empty_staging_parent(src)
removed.append(os.path.basename(src)) removed.append(os.path.basename(src))
return removed return removed
@@ -842,6 +868,13 @@ class PendingDeleteService:
manifest = self._read_manifest(batch_dir) manifest = self._read_manifest(batch_dir)
if manifest is None: if manifest is None:
if not staging_dir_has_content(batch_dir):
# Crash leftover: the batch dir was created but nothing was ever
# staged into it. Quarantining an empty directory would leave a
# container that blocks folder deletion for good.
self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
return True
# Corrupted or manifest-less batch: quarantine, NEVER delete the # Corrupted or manifest-less batch: quarantine, NEVER delete the
# staged files (they may be the only copy of the user's data). # staged files (they may be the only copy of the user's data).
self._quarantine_batch_dir(batch_dir) self._quarantine_batch_dir(batch_dir)
@@ -885,6 +918,7 @@ class PendingDeleteService:
self._remove_manifest(batch_dir) self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
self._remove_merged_batch_dirs(manifest) self._remove_merged_batch_dirs(manifest)
return True return True
@@ -1132,6 +1166,18 @@ class PendingDeleteService:
except OSError as exc: except OSError as exc:
logger.debug("Directory %s not empty or missing: %s", directory, exc) logger.debug("Directory %s not empty or missing: %s", directory, exc)
def _remove_empty_staging_parent(self, batch_dir: str) -> None:
"""Drop the staging container once its last batch is gone.
Only an empty ``.lm-pending-delete`` directory is removed (``rmdir``
refuses anything else), and only when the path really is one, so a model
folder is never at risk.
"""
parent = os.path.dirname(batch_dir)
if os.path.basename(parent) != PENDING_DELETE_DIR_NAME:
return
self._remove_empty_dir(parent)
def _new_batch_id(self) -> str: def _new_batch_id(self) -> str:
return uuid.uuid4().hex return uuid.uuid4().hex
+75 -3
View File
@@ -4,16 +4,33 @@ import os
import sqlite3 import sqlite3
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from ..utils.cache_db import connect_cache_db from ..utils.cache_db import closing_connection, connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock from ..utils.file_lock import exclusive_lock
from ..utils.root_paths import normalize_root_identity
from .model_sources import normalize_metadata_source from .model_sources import normalize_metadata_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _like_prefix_pattern(identity: str) -> str:
"""SQLite LIKE pattern matching every path inside *identity*.
``%``/``_`` are legal characters in a path and ``\\`` is the escape character,
so all three are escaped. The separator is added only when the identity does not
already end in one, so a filesystem root (``/``) still matches everything below
it instead of only itself.
"""
escaped = (
identity.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
prefix = escaped if escaped.endswith("/") else f"{escaped}/"
return f"{prefix}%"
@dataclass @dataclass
class PersistedCacheData: class PersistedCacheData:
"""Lightweight structure returned by the persistent cache.""" """Lightweight structure returned by the persistent cache."""
@@ -254,6 +271,61 @@ class PersistentModelCache:
all_folders=all_folders, all_folders=all_folders,
) )
def roots_with_cached_entries(
self, model_type: str, roots: Sequence[str]
) -> Set[str]:
"""Return the subset of *roots* that hold at least one cached entry.
Used by the settings sync to tell a root that was registered but never
scanned -- ComfyUI registers ``<output>/loras`` for its save nodes whether
or not that directory exists -- from a real root whose drive happens to be
switched off: only the latter must keep its place in the saved library
paths (issue #1146). Paths are matched on their forward-slash identity, so
the host's own slash style does not matter.
Raises when the answer is unknown (a disabled cache, a missing database, an
unreadable file): the caller keeps every root in that case.
"""
if not self.is_enabled():
raise RuntimeError("persistent model cache is disabled")
candidates: Dict[str, str] = {}
for root in roots:
identity = normalize_root_identity(root)
if identity:
candidates.setdefault(identity, root)
if not candidates:
return set()
matched: Set[str] = set()
# One scan per model type rather than one per root: the probe runs at
# startup for every configured root that is missing right now, which on
# Windows includes all of ComfyUI's `<output>/<type>` folders.
clause = "replace(file_path, '\\', '/') LIKE ? ESCAPE '\\'"
sql = (
"SELECT DISTINCT replace(file_path, '\\', '/') FROM models "
f"WHERE model_type = ? AND ({' OR '.join([clause] * len(candidates))})"
)
params: List[Any] = [model_type]
params.extend(_like_prefix_pattern(identity) for identity in candidates)
with self._db_lock:
conn = self._connect(readonly=True)
try:
rows = conn.execute(sql, params).fetchall()
finally:
conn.close()
for (cached_path,) in rows:
folded = normalize_root_identity(cached_path)
for identity, root in candidates.items():
prefix = identity if identity.endswith("/") else f"{identity}/"
if folded.startswith(prefix):
matched.add(root)
break
return matched
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: 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(): if not self.is_enabled():
return return
@@ -546,7 +618,7 @@ class PersistentModelCache:
if self._schema_initialized: if self._schema_initialized:
return return
try: try:
with self._connect() as conn: with closing_connection(self._connect()) as conn:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.executescript( conn.executescript(
+2 -2
View File
@@ -19,7 +19,7 @@ import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.cache_db import connect_cache_db from ..utils.cache_db import closing_connection, connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock from ..utils.file_lock import exclusive_lock
@@ -457,7 +457,7 @@ class PersistentRecipeCache:
if self._schema_initialized: if self._schema_initialized:
return return
try: try:
with self._connect() as conn: with closing_connection(self._connect()) as conn:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.executescript( conn.executescript(
+7 -1
View File
@@ -160,7 +160,13 @@ class RateLimitCoordinator:
self._normalize(destination), self._normalize(destination),
delay, delay,
) )
await asyncio.sleep(delay) # Re-check rather than trusting a single sleep: the event-loop
# timer can fire early (Windows granularity is ~15ms), and
# returning then would send the request inside the cooldown this
# gate exists to enforce. ``wake_at`` is fixed, so the loop can
# never outlast the ``max_wait_seconds`` check above.
while (delay := wake_at - time.monotonic()) > 0:
await asyncio.sleep(delay)
state.last_send_at = time.monotonic() state.last_send_at = time.monotonic()
def register_rate_limit( def register_rate_limit(
+6 -15
View File
@@ -2,7 +2,6 @@ import asyncio
import copy import copy
import json import json
import os import os
import posixpath
import shutil import shutil
import tempfile import tempfile
import logging import logging
@@ -36,6 +35,7 @@ from ..utils.constants import (
normalize_other_sub_types, normalize_other_sub_types,
) )
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.root_paths import normalize_root_identity
from ..utils.settings_paths import ( from ..utils.settings_paths import (
APP_NAME, APP_NAME,
_portable_env_override, _portable_env_override,
@@ -156,15 +156,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
} }
def _normalize_root_identity(path: str) -> str:
"""Normalize a root path for equality checks across slash styles."""
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
if len(normalized) >= 2 and normalized[1] == ":":
return normalized.lower()
return normalized
class SettingsManager: class SettingsManager:
def __init__(self): def __init__(self):
self.settings_file = ensure_settings_file(logger) self.settings_file = ensure_settings_file(logger)
@@ -1058,7 +1049,7 @@ class SettingsManager:
return False return False
allowed_roots = self._get_allowed_roots(key) allowed_roots = self._get_allowed_roots(key)
if current and _normalize_root_identity(current) in allowed_roots: if current and normalize_root_identity(current) in allowed_roots:
return False return False
self.settings[setting_key] = primary_candidates[0] self.settings[setting_key] = primary_candidates[0]
@@ -1094,7 +1085,7 @@ class SettingsManager:
candidate_identities: set[str] = set() candidate_identities: set[str] = set()
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []): for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
for candidate in self._get_valid_root_candidates(folder_key): for candidate in self._get_valid_root_candidates(folder_key):
identity = _normalize_root_identity(candidate) identity = normalize_root_identity(candidate)
if identity in candidate_identities: if identity in candidate_identities:
continue continue
candidate_identities.add(identity) candidate_identities.add(identity)
@@ -1102,7 +1093,7 @@ class SettingsManager:
if not candidates: if not candidates:
continue continue
current = other_roots.get(sub_type, "") current = other_roots.get(sub_type, "")
if current and _normalize_root_identity(current) in candidate_identities: if current and normalize_root_identity(current) in candidate_identities:
continue continue
other_roots[sub_type] = candidates[0] other_roots[sub_type] = candidates[0]
if current: if current:
@@ -1152,7 +1143,7 @@ class SettingsManager:
normalized = value.strip() normalized = value.strip()
if not normalized: if not normalized:
continue continue
identity = _normalize_root_identity(normalized) identity = normalize_root_identity(normalized)
if identity in seen: if identity in seen:
continue continue
seen.add(identity) seen.add(identity)
@@ -1162,7 +1153,7 @@ class SettingsManager:
def _get_allowed_roots(self, key: str) -> set[str]: def _get_allowed_roots(self, key: str) -> set[str]:
"""Return all valid roots for a model type, including extra roots.""" """Return all valid roots for a model type, including extra roots."""
return {_normalize_root_identity(path) for path in self._get_valid_root_candidates(key)} return {normalize_root_identity(path) for path in self._get_valid_root_candidates(key)}
def _check_environment_variables(self) -> None: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """Check for environment variables and update settings if needed"""
+18 -1
View File
@@ -14,7 +14,8 @@ the write path in WAL so readers are never blocked by a writer.
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from typing import Any from contextlib import contextmanager
from typing import Any, Iterator
# How long a connection waits for a competing writer before raising. # How long a connection waits for a competing writer before raising.
CONCURRENT_TIMEOUT_SECONDS = 30.0 CONCURRENT_TIMEOUT_SECONDS = 30.0
@@ -79,3 +80,19 @@ def connect_cache_db(
pass pass
return conn return conn
@contextmanager
def closing_connection(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Yield *conn* and close it on exit.
``with sqlite3.Connection`` only ends the transaction: it leaves the file
handle open, and a live handle keeps the cache database undeletable on
Windows (``WinError 32``) until the garbage collector happens to run. Every
``with`` over a cache connection goes through here instead.
"""
try:
yield conn
finally:
conn.close()
+36
View File
@@ -0,0 +1,36 @@
"""Path identity helpers for model roots.
ComfyUI hands its model folders over in whatever spelling the host uses --
``os.path.normpath()`` output, which is all backslashes on Windows -- while LoRA
Manager's own root lists and cached model paths are forward-slashed. Comparing
the two spellings as raw strings makes one directory look like two roots, so every
root identity check goes through :func:`normalize_root_identity` (issue #1146).
"""
from __future__ import annotations
import os
import posixpath
def normalize_root_identity(path: str) -> str:
"""Return the comparable identity of a model root path.
Folds the separator style (``\\`` and ``/`` are both accepted as separators,
because the host may spell a Windows path either way), redundant and trailing
separators, and the case where the filesystem does not distinguish it: Windows
compares whole paths case-insensitively, and every platform folds a drive
letter (``E:`` == ``e:``). Symlinks are never resolved -- business paths stay
business paths, matching the rest of the codebase.
"""
if not isinstance(path, str):
return ""
candidate = path.strip()
if not candidate:
return ""
normalized = posixpath.normpath(candidate.replace("\\", "/"))
if os.name == "nt" or (len(normalized) >= 2 and normalized[1] == ":"):
return normalized.lower()
return normalized
+15 -2
View File
@@ -480,6 +480,19 @@ def _legacy_component(root_path: str) -> str:
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}" return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
def _join_component(root: str, component: str) -> str:
"""Join a mirror component name onto *root* as a native path.
A component adopted from the pre-identity-map layout is reported as
``<library>/<component>`` -- one *name* spanning two directories. Handing it
whole to ``os.path.join`` left a mixed path on Windows
(``...\\comfyui/loras-1a2b3c4d\\sub``), which no natively built path
compares equal to, so it has to be split first.
"""
return os.path.join(root, *(part for part in component.split("/") if part))
def _list_component_dirs(sidecar_root: str) -> Set[str]: def _list_component_dirs(sidecar_root: str) -> Set[str]:
"""Return the mirror component directories present under ``sidecar_root``. """Return the mirror component directories present under ``sidecar_root``.
@@ -556,7 +569,7 @@ def _mirror_dir_overlap(sidecar_root: str, component: str, root: str) -> int:
if not component: if not component:
return 0 return 0
mirror_names = _subdir_names(os.path.join(sidecar_root, component)) mirror_names = _subdir_names(_join_component(sidecar_root, component))
if not mirror_names: if not mirror_names:
return 0 return 0
root_names = _subdir_names(root) root_names = _subdir_names(root)
@@ -958,7 +971,7 @@ def resolve_centralized_dir_for_dir(
if not component: if not component:
return None return None
parts = [root, component] parts = [_join_component(root, component)]
if rel_dir and rel_dir != os.curdir: if rel_dir and rel_dir != os.curdir:
parts.extend( parts.extend(
sanitize_path_component(part) sanitize_path_component(part)
+4 -2
View File
@@ -303,8 +303,10 @@ def _format_model_name_for_comfyui(file_path: str, model_roots: list[str]) -> st
norm_root += os.sep norm_root += os.sep
if norm_file.startswith(norm_root): if norm_file.startswith(norm_root):
# Use os.path.relpath to get relative path with OS-native separator # ComfyUI names a model with forward slashes on every platform
return os.path.relpath(file_path, root) # ("unet/base.safetensors"); os.path.relpath alone returns the
# host's separator, which no ComfyUI widget list matches on Windows.
return os.path.relpath(file_path, root).replace(os.sep, "/")
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
+4
View File
@@ -85,6 +85,10 @@ export function getApiEndpoints(modelType) {
moveBulk: `/api/lm/${modelType}/move_models_bulk`, moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`, createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`, deleteFolder: `/api/lm/${modelType}/delete-folder`,
// Drops the sidebar records of a folder that no longer exists on disk:
// the only cleanup left for a node whose directory was moved or deleted
// outside LoRA Manager.
forgetFolder: `/api/lm/${modelType}/forget-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`, renameFolder: `/api/lm/${modelType}/rename-folder`,
// Resolves a tree-relative folder onto the root(s) that hold it: the // Resolves a tree-relative folder onto the root(s) that hold it: the
// unified folder tree merges every root, so a node does not carry its // unified folder tree merges every root, so a node does not carry its
+35 -1
View File
@@ -1440,7 +1440,9 @@ export class BaseModelApiClient {
* Only model-free folders can be removed; the backend answers with a 409 * Only model-free folders can be removed; the backend answers with a 409
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the * `not_empty`/`busy` conflict otherwise. Those codes are attached to the
* thrown Error (`code`, `manifest`) so callers can explain the refusal * thrown Error (`code`, `manifest`) so callers can explain the refusal
* instead of showing a bare message. * instead of showing a bare message. A folder that is already gone from disk
* is forgotten instead (`code: 'missing'`, success), which is what lets the
* sidebar clear a node whose directory was moved outside LoRA Manager.
* *
* @param {string} folderPath Absolute business path of the folder * @param {string} folderPath Absolute business path of the folder
* @param {{dryRun?: boolean}} [options] * @param {{dryRun?: boolean}} [options]
@@ -1468,6 +1470,38 @@ export class BaseModelApiClient {
return result; return result;
} }
/**
* Drop the records of a folder that no longer exists on disk.
*
* Deleting such a folder cannot work -- there is nothing left to remove --
* so a node whose directory was moved or deleted outside LoRA Manager is
* cleared with this instead. The backend refuses while any root still holds
* the directory (`code: 'exists'`), because then it is a delete.
*
* @param {string} folder - Library-relative folder path (e.g. "Krea2/Women")
* @returns {Promise<Object>} `{success: true, folder}` on success
*/
async forgetFolder(folder) {
const response = await fetch(this.apiConfig.endpoints.forgetFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || 'Failed to remove folder from the sidebar');
error.code = result.code || null;
throw error;
}
return result;
}
/** /**
* Rename a folder inside the library roots. * Rename a folder inside the library roots.
* *
+65 -7
View File
@@ -1734,6 +1734,8 @@ export class SidebarManager {
showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
} else if (error?.code === 'missing') { } else if (error?.code === 'missing') {
showToast('sidebar.deleteFolderResult.missing', {}, 'warning'); showToast('sidebar.deleteFolderResult.missing', {}, 'warning');
} else if (error?.code === 'exists') {
showToast('sidebar.deleteFolderResult.exists', {}, 'warning');
} else if (error?.code === 'unresolved') { } else if (error?.code === 'unresolved') {
showToast('sidebar.folderResult.unresolved', {}, 'error'); showToast('sidebar.folderResult.unresolved', {}, 'error');
} else { } else {
@@ -1806,6 +1808,8 @@ export class SidebarManager {
); );
} else { } else {
this._pendingDeleteFolderPath = null; this._pendingDeleteFolderPath = null;
confirmBtn.style.display = 'none';
confirmBtn.disabled = true;
if (state === 'busy') { if (state === 'busy') {
title.textContent = translate( title.textContent = translate(
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending' 'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
@@ -1820,7 +1824,16 @@ export class SidebarManager {
); );
message.textContent = translate( message.textContent = translate(
'sidebar.deleteFolderModal.missingMessage', {}, 'sidebar.deleteFolderModal.missingMessage', {},
'This folder no longer exists on disk. Refresh the sidebar and try again.' 'This folder no longer exists on disk. You can remove it from the list.'
);
// There is nothing left to delete, but the records are what keeps
// the ghost node in the tree: offer to drop them (issue #1145).
// The confirm re-resolves the node, finds no directory and takes
// the forget path.
confirmBtn.style.display = '';
confirmBtn.disabled = false;
confirmBtn.textContent = translate(
'sidebar.deleteFolderModal.removeFromList', {}, 'Remove from list'
); );
} else if (state === 'symlink') { } else if (state === 'symlink') {
title.textContent = translate( title.textContent = translate(
@@ -1849,8 +1862,6 @@ export class SidebarManager {
|| 'This folder still contains models. Delete or move them first.' || 'This folder still contains models. Delete or move them first.'
); );
} }
confirmBtn.style.display = 'none';
confirmBtn.disabled = true;
} }
if (checking) { if (checking) {
@@ -1943,10 +1954,14 @@ export class SidebarManager {
); );
if (!selected) { if (!selected) {
const empty = (resolution?.candidates || []).length === 0; const empty = (resolution?.candidates || []).length === 0;
if (empty) {
// No root holds the directory any more: there is nothing
// to delete, so drop the records that keep the node in
// the tree (issue #1145).
return this._forgetFolder(relativePath);
}
showToast( showToast(
empty 'sidebar.deleteFolderResult.symlink',
? 'sidebar.deleteFolderResult.missing'
: 'sidebar.deleteFolderResult.symlink',
{}, {},
'warning' 'warning'
); );
@@ -1967,7 +1982,15 @@ export class SidebarManager {
await this.refresh(); await this.refresh();
const name = result.folder || relativePath; const name = result.folder || relativePath;
if (result.restorable) { if (result.code === 'missing') {
// The directory was already gone when the delete ran (a race with
// the file manager, or a row in the multi-root list): only its
// records were dropped, so nothing was deleted and there is
// nothing to undo.
showToast(
'sidebar.deleteFolderResult.forgotten', { name }, 'success'
);
} else if (result.restorable) {
// A truly empty folder is reproducible one-for-one, so offer // A truly empty folder is reproducible one-for-one, so offer
// the same 20s undo affordance the model delete flow uses. // the same 20s undo affordance the model delete flow uses.
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', { showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
@@ -1992,6 +2015,41 @@ export class SidebarManager {
} }
} }
/**
* Clear a folder node whose directory is gone from every root (issue #1145).
*
* A folder moved or deleted with a file manager leaves the sidebar holding a
* node that no delete can clear, because there is nothing left to remove. The
* delete affordance falls back to this instead of refusing: the backend drops
* the folder record and the cached models under it, and answers `exists` if
* the directory turned out to be back on disk (then it is a delete again).
*/
async _forgetFolder(relativePath) {
if (
!this._supportsFolderManagement()
|| typeof this.apiClient.forgetFolder !== 'function'
) {
showToast('sidebar.deleteFolderResult.unsupported', {}, 'error');
return false;
}
try {
const result = await this.apiClient.forgetFolder(relativePath);
this._forgetRemovedFolder(relativePath);
await this.refresh();
showToast(
'sidebar.deleteFolderResult.forgotten',
{ name: result.folder || relativePath },
'success'
);
return true;
} catch (error) {
console.error('[SidebarManager] Error forgetting folder:', error);
this._reportDeleteFolderFailure(error);
return false;
}
}
/** /**
* Recreate removed directories through the create-folder API. * Recreate removed directories through the create-folder API.
* *
+11 -5
View File
@@ -36,6 +36,12 @@ def _normalize(path) -> str:
return os.path.normpath(str(path)).replace(os.sep, "/") return os.path.normpath(str(path)).replace(os.sep, "/")
def _fold(paths):
"""Compare path lists as identities: the code may hand out either spelling."""
return [_normalize(path) for path in paths]
class TestAppendNewPaths: class TestAppendNewPaths:
def test_appends_without_reordering_or_duplicating(self): def test_appends_without_reordering_or_duplicating(self):
merged = config_module._append_new_paths( merged = config_module._append_new_paths(
@@ -60,17 +66,17 @@ class TestAdmitConfiguredRoots:
loras_roots=[str(primary)], loras_roots=[str(primary)],
_configured_root_paths={"lora": [str(primary), str(drive)]}, _configured_root_paths={"lora": [str(primary), str(drive)]},
) )
assert config.configured_roots_for("lora") == [str(primary), str(drive)] assert _fold(config.configured_roots_for("lora")) == _fold([primary, drive])
# Still switched off: nothing to admit. # Still switched off: nothing to admit.
assert config.admit_configured_roots() == [] assert config.admit_configured_roots() == []
assert config.loras_roots == [str(primary)] assert _fold(config.loras_roots) == _fold([primary])
drive.mkdir(parents=True) drive.mkdir(parents=True)
admitted = config.admit_configured_roots() admitted = config.admit_configured_roots()
assert admitted == [str(drive)] assert _fold(admitted) == _fold([drive])
assert config.loras_roots == [str(primary), str(drive)] assert _fold(config.loras_roots) == _fold([primary, drive])
# Idempotent: a second call adds nothing. # Idempotent: a second call adds nothing.
assert config.admit_configured_roots() == [] assert config.admit_configured_roots() == []
@@ -91,7 +97,7 @@ class TestAdmitConfiguredRoots:
drive.rmdir() drive.rmdir()
config.admit_configured_roots() config.admit_configured_roots()
assert config.loras_roots == [str(primary), str(drive)] assert _fold(config.loras_roots) == _fold([primary, drive])
def test_admits_every_model_type_and_its_side_maps(self, tmp_path): def test_admits_every_model_type_and_its_side_maps(self, tmp_path):
checkpoint_dir = tmp_path / "checkpoints" checkpoint_dir = tmp_path / "checkpoints"
+275 -62
View File
@@ -1,12 +1,33 @@
import logging import logging
from typing import Any, Dict, Iterable, List from typing import Any, Dict, Iterable, List, Mapping
import pytest import pytest
from py import config as config_module from py import config as config_module
from py.utils.root_paths import normalize_root_identity
from py.services import settings_manager as settings_manager_module from py.services import settings_manager as settings_manager_module
def _identity(value):
"""Fold path spelling so an assertion is about paths, not separators.
LM keeps the host's own spelling for the paths it is configured with
(backslashes on Windows) while the ones it derives itself are forward-slashed,
so two values naming the same directory can differ as strings. Compare
identities wherever a test asserts *which* paths survived.
"""
if isinstance(value, str):
return normalize_root_identity(value)
if isinstance(value, Mapping):
return {key: _identity(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_identity(item) for item in value]
if isinstance(value, set):
return {_identity(item) for item in value}
return value
def _setup_config_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Dict[str, List[str]]: def _setup_config_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Dict[str, List[str]]:
loras_dir = tmp_path / "loras" loras_dir = tmp_path / "loras"
checkpoints_dir = tmp_path / "checkpoints" checkpoints_dir = tmp_path / "checkpoints"
@@ -94,11 +115,11 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
key: [path.replace("\\", "/") for path in paths] key: [path.replace("\\", "/") for path in paths]
for key, paths in folder_paths.items() for key, paths in folder_paths.items()
} }
assert payload["folder_paths"] == expected_folder_paths assert _identity(payload["folder_paths"]) == _identity(expected_folder_paths)
assert payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/") assert _identity(payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
assert payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/") assert _identity(payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
assert payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/") assert _identity(payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
assert payload["metadata"] == {"display_name": "ComfyUI", "source": "comfyui"} assert _identity(payload["metadata"]) == _identity({"display_name": "ComfyUI", "source": "comfyui"})
assert payload["activate"] is True assert payload["activate"] is True
@@ -175,9 +196,9 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
config_module.Config() config_module.Config()
assert fake_settings.name == "comfyui" assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path): def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
@@ -214,9 +235,9 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
config_module.Config() config_module.Config()
assert fake_settings.name == "comfyui" assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path): def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
@@ -253,9 +274,9 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
config_module.Config() config_module.Config()
assert fake_settings.name == "comfyui" assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/") assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
def test_save_paths_removes_template_default_library(monkeypatch, tmp_path): def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
@@ -338,17 +359,15 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
key: [path.replace("\\", "/") for path in paths] key: [path.replace("\\", "/") for path in paths]
for key, paths in folder_paths.items() for key, paths in folder_paths.items()
} }
assert payload["folder_paths"] == expected_folder_paths assert _identity(payload["folder_paths"]) == _identity(expected_folder_paths)
assert payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/") assert _identity(payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
assert ( assert _identity(payload["default_checkpoint_root"]) == _identity(
payload["default_checkpoint_root"] folder_paths["checkpoints"][0].replace("\\", "/")
== folder_paths["checkpoints"][0].replace("\\", "/")
) )
assert ( assert _identity(payload["default_embedding_root"]) == _identity(
payload["default_embedding_root"] folder_paths["embeddings"][0].replace("\\", "/")
== folder_paths["embeddings"][0].replace("\\", "/")
) )
assert payload["metadata"] == {"display_name": "ComfyUI", "source": "comfyui"} assert _identity(payload["metadata"]) == _identity({"display_name": "ComfyUI", "source": "comfyui"})
assert payload["activate"] is True assert payload["activate"] is True
@@ -397,16 +416,16 @@ def test_save_paths_keeps_default_roots_in_extra_paths(monkeypatch: pytest.Monke
config_module.Config() config_module.Config()
assert fake_settings.name == "comfyui" assert fake_settings.name == "comfyui"
assert fake_settings.payload["extra_folder_paths"]["loras"] == [str(extra_lora_dir).replace("\\", "/")] assert _identity(fake_settings.payload["extra_folder_paths"]["loras"]) == _identity([str(extra_lora_dir).replace("\\", "/")])
assert fake_settings.payload["extra_folder_paths"]["checkpoints"] == [ assert _identity(fake_settings.payload["extra_folder_paths"]["checkpoints"]) == _identity([
str(extra_checkpoint_dir).replace("\\", "/") str(extra_checkpoint_dir).replace("\\", "/")
] ])
assert fake_settings.payload["extra_folder_paths"]["embeddings"] == [ assert _identity(fake_settings.payload["extra_folder_paths"]["embeddings"]) == _identity([
str(extra_embedding_dir).replace("\\", "/") str(extra_embedding_dir).replace("\\", "/")
] ])
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/") assert _identity(fake_settings.payload["default_lora_root"]) == _identity(str(extra_lora_dir).replace("\\", "/"))
assert fake_settings.payload["default_checkpoint_root"] == str(extra_checkpoint_dir).replace("\\", "/") assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(str(extra_checkpoint_dir).replace("\\", "/"))
assert fake_settings.payload["default_embedding_root"] == str(extra_embedding_dir).replace("\\", "/") assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(str(extra_embedding_dir).replace("\\", "/"))
assert fake_settings.payload["activate"] is True assert fake_settings.payload["activate"] is True
@@ -451,8 +470,8 @@ def test_save_paths_keeps_default_roots_in_extra_paths_with_windows_slash_mismat
config_module.Config() config_module.Config()
assert fake_settings.name == "comfyui" assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == "U:/Lora7/Loras" assert _identity(fake_settings.payload["default_lora_root"]) == _identity("U:/Lora7/Loras")
assert fake_settings.payload["default_checkpoint_root"] == "U:/Lora7/Models" assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity("U:/Lora7/Models")
def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_missing( def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_missing(
@@ -505,7 +524,7 @@ def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_miss
config_module.Config() config_module.Config()
assert fake_settings.name == "comfyui" assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/") assert _identity(fake_settings.payload["default_lora_root"]) == _identity(str(extra_lora_dir).replace("\\", "/"))
def test_save_paths_does_not_activate_comfyui_library_when_another_library_is_active( def test_save_paths_does_not_activate_comfyui_library_when_another_library_is_active(
@@ -585,12 +604,12 @@ def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert str(loras_dir) in config_instance.loras_roots assert _identity(str(loras_dir)) in _identity(config_instance.loras_roots)
assert str(extra_loras_dir) in config_instance.extra_loras_roots assert _identity(str(extra_loras_dir)) in _identity(config_instance.extra_loras_roots)
assert str(checkpoints_dir) in config_instance.base_models_roots assert _identity(str(checkpoints_dir)) in _identity(config_instance.base_models_roots)
assert str(extra_checkpoints_dir) in config_instance.extra_checkpoints_roots assert _identity(str(extra_checkpoints_dir)) in _identity(config_instance.extra_checkpoints_roots)
assert str(embeddings_dir) in (config_instance.embeddings_roots or []) assert _identity(str(embeddings_dir)) in _identity((config_instance.embeddings_roots or []))
assert str(extra_embeddings_dir) in config_instance.extra_embeddings_roots assert _identity(str(extra_embeddings_dir)) in _identity(config_instance.extra_embeddings_roots)
def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path): def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path):
@@ -617,12 +636,12 @@ def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert str(loras_dir) in config_instance.loras_roots assert _identity(str(loras_dir)) in _identity(config_instance.loras_roots)
assert config_instance.extra_loras_roots == [] assert _identity(config_instance.extra_loras_roots) == _identity([])
assert str(checkpoints_dir) in config_instance.base_models_roots assert _identity(str(checkpoints_dir)) in _identity(config_instance.base_models_roots)
assert config_instance.extra_checkpoints_roots == [] assert _identity(config_instance.extra_checkpoints_roots) == _identity([])
assert str(embeddings_dir) in (config_instance.embeddings_roots or []) assert _identity(str(embeddings_dir)) in _identity((config_instance.embeddings_roots or []))
assert config_instance.extra_embeddings_roots == [] assert _identity(config_instance.extra_embeddings_roots) == _identity([])
def test_extra_paths_deduplication(monkeypatch, tmp_path): def test_extra_paths_deduplication(monkeypatch, tmp_path):
@@ -654,8 +673,8 @@ def test_extra_paths_deduplication(monkeypatch, tmp_path):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_dir)] assert _identity(config_instance.loras_roots) == _identity([str(loras_dir)])
assert config_instance.extra_loras_roots == [str(extra_loras_dir)] assert _identity(config_instance.extra_loras_roots) == _identity([str(extra_loras_dir)])
def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_symlink( def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_symlink(
@@ -687,8 +706,8 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_syml
with caplog.at_level("WARNING", logger=config_module.logger.name): with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_link)] assert _identity(config_instance.loras_roots) == _identity([str(loras_link)])
assert config_instance.extra_loras_roots == [] assert _identity(config_instance.extra_loras_roots) == _identity([])
warning_messages = [ warning_messages = [
record.message record.message
@@ -764,8 +783,8 @@ def test_apply_library_settings_detects_overlap_case_insensitively(
with caplog.at_level("WARNING", logger=config_module.logger.name): with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [primary_path] assert _identity(config_instance.loras_roots) == _identity([primary_path])
assert config_instance.extra_loras_roots == [] assert _identity(config_instance.extra_loras_roots) == _identity([])
assert any("same lora folder" in record.message.lower() for record in caplog.records) assert any("same lora folder" in record.message.lower() for record in caplog.records)
@@ -794,8 +813,8 @@ def test_apply_library_settings_ignores_missing_extra_lora_paths(monkeypatch, tm
with caplog.at_level("WARNING", logger=config_module.logger.name): with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_dir)] assert _identity(config_instance.loras_roots) == _identity([str(loras_dir)])
assert config_instance.extra_loras_roots == [] assert _identity(config_instance.extra_loras_roots) == _identity([])
assert not any("same lora folder" in record.message.lower() for record in caplog.records) assert not any("same lora folder" in record.message.lower() for record in caplog.records)
@@ -829,8 +848,8 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root
with caplog.at_level("WARNING", logger=config_module.logger.name): with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config) config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_dir)] assert _identity(config_instance.loras_roots) == _identity([str(loras_dir)])
assert config_instance.extra_loras_roots == [] assert _identity(config_instance.extra_loras_roots) == _identity([])
assert any( assert any(
"same lora folder" in record.message.lower() "same lora folder" in record.message.lower()
for record in caplog.records for record in caplog.records
@@ -960,11 +979,205 @@ def test_save_paths_keeps_configured_root_of_a_switched_off_drive(
persisted = fake_settings.payload["folder_paths"]["loras"] persisted = fake_settings.payload["folder_paths"]["loras"]
# The switched-off drive keeps its configured path... # The switched-off drive keeps its configured path...
assert switched_off.replace("\\", "/") in persisted assert _identity(switched_off.replace("\\", "/")) in _identity(persisted)
assert readable.replace("\\", "/") in persisted assert _identity(readable.replace("\\", "/")) in _identity(persisted)
# ...the path the host really dropped is gone... # ...the path the host really dropped is gone...
assert removed_by_user.replace("\\", "/") not in persisted assert _identity(removed_by_user.replace("\\", "/")) not in _identity(persisted)
# ...and the live list still only holds what is readable right now. # ...and the live list still only holds what is readable right now.
assert config_instance.loras_roots == [readable.replace("\\", "/")] assert _identity(config_instance.loras_roots) == _identity([readable.replace("\\", "/")])
# The user's default root survives because it is merely unavailable. # The user's default root survives because it is merely unavailable.
assert fake_settings.payload["default_lora_root"] == switched_off.replace("\\", "/") assert _identity(fake_settings.payload["default_lora_root"]) == _identity(switched_off.replace("\\", "/"))
# --- roots the host registered but never created (issue #1146) --------------
class _FakePersistentCache:
"""Stand-in for PersistentModelCache in the startup sync."""
def __init__(self, cached_roots=None, error: Exception | None = None):
self._cached_roots = cached_roots or {}
self._error = error
self.queries: List[tuple] = []
def roots_with_cached_entries(self, model_type: str, roots):
self.queries.append((model_type, list(roots)))
if self._error is not None:
raise self._error
known = {path.replace("\\", "/") for path in self._cached_roots.get(model_type, [])}
return {
root for root in roots if root.replace("\\", "/") in known
}
def _stub_persistent_cache(monkeypatch: pytest.MonkeyPatch, cache) -> None:
from py.services import persistent_model_cache as persistent_model_cache_module
monkeypatch.setattr(
persistent_model_cache_module, "get_persistent_cache", lambda: cache
)
class _LibrarySettingsService:
"""Minimal settings service recording what the startup sync persists."""
active_library = "comfyui"
def __init__(self, library: Dict[str, Any]):
self._library = library
self.payload: Dict[str, Any] = {}
def get_libraries(self):
return {"comfyui": self._library}
def rename_library(self, *_): # pragma: no cover - defensive guard
raise AssertionError("rename_library should not be invoked")
def delete_library(self, *_): # pragma: no cover - defensive guard
raise AssertionError("delete_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.payload = payload
def test_save_paths_drops_registered_directories_that_never_existed(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
"""ComfyUI registers `<output>/<type>` for its save nodes unconditionally.
Those directories are not library paths when they do not exist and hold no
cached models: persisting them brings them back as "unavailable" roots on
every boot.
"""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
unet_dir = tmp_path / "unet"
unet_dir.mkdir()
folder_paths["unet"] = [str(unet_dir)]
output_loras = str(tmp_path / "output" / "loras")
output_checkpoints = str(tmp_path / "output" / "checkpoints")
output_unet = str(tmp_path / "output" / "diffusion_models")
output_embeddings = str(tmp_path / "output" / "embeddings")
folder_paths["loras"].append(output_loras)
folder_paths["checkpoints"].append(output_checkpoints)
folder_paths["unet"].append(output_unet)
folder_paths["embeddings"].append(output_embeddings)
fake_settings = _LibrarySettingsService(
{
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"default_lora_root": folder_paths["loras"][0],
"default_checkpoint_root": folder_paths["checkpoints"][0],
"default_embedding_root": folder_paths["embeddings"][0],
}
)
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
cache = _FakePersistentCache()
_stub_persistent_cache(monkeypatch, cache)
config_module.Config()
persisted = fake_settings.payload["folder_paths"]
for key, never_created in (
("loras", output_loras),
("checkpoints", output_checkpoints),
("unet", output_unet),
("embeddings", output_embeddings),
):
assert _identity(never_created.replace("\\", "/")) not in _identity(persisted[key]), key
# The real root of that key is untouched.
assert _identity(folder_paths[key][0].replace("\\", "/")) in _identity(persisted[key]), key
# Lora and checkpoint roots are looked up in their own model type.
assert [model_type for model_type, _roots in cache.queries] == [
"lora",
"checkpoint",
"checkpoint",
"embedding",
]
def test_save_paths_keeps_an_unavailable_root_that_has_cached_models(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
"""A switched-off drive keeps its path: its models are still cached."""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
readable = folder_paths["loras"][0]
switched_off = str(tmp_path / "drive-Z" / "loras")
folder_paths["loras"] = [readable, switched_off]
fake_settings = _LibrarySettingsService(
{
"folder_paths": {"loras": [readable]},
"default_lora_root": readable,
}
)
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
_stub_persistent_cache(
monkeypatch, _FakePersistentCache({"lora": [switched_off]})
)
config_module.Config()
persisted = fake_settings.payload["folder_paths"]["loras"]
assert _identity(switched_off.replace("\\", "/")) in _identity(persisted)
assert _identity(readable.replace("\\", "/")) in _identity(persisted)
def test_save_paths_keeps_every_root_when_the_cache_cannot_be_read(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
"""No answer from the cache means no root is dropped."""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
readable = folder_paths["loras"][0]
unknown = str(tmp_path / "drive-Z" / "loras")
folder_paths["loras"] = [readable, unknown]
fake_settings = _LibrarySettingsService(
{
"folder_paths": {"loras": [readable]},
"default_lora_root": readable,
}
)
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
_stub_persistent_cache(
monkeypatch, _FakePersistentCache(error=FileNotFoundError("no cache"))
)
config_module.Config()
persisted = fake_settings.payload["folder_paths"]["loras"]
assert _identity(unknown.replace("\\", "/")) in _identity(persisted)
def test_save_paths_keeps_the_root_a_default_setting_points_at(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
"""An empty unavailable default root must not be dropped.
Dropping it would let the next boot "repair" the default onto another root and
silently retarget downloads. ``default_unet_root`` is only known from the
stored library (the sync resolves the lora/checkpoint/embedding ones itself),
so this covers the fallback to the stored value.
"""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
unet_dir = tmp_path / "unet"
unet_dir.mkdir()
default_target = str(tmp_path / "drive-Z" / "unet")
folder_paths["unet"] = [str(unet_dir), default_target]
fake_settings = _LibrarySettingsService(
{
"folder_paths": {"unet": [str(unet_dir)]},
"default_unet_root": default_target,
}
)
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
_stub_persistent_cache(monkeypatch, _FakePersistentCache())
config_module.Config()
persisted = fake_settings.payload["folder_paths"]["unet"]
assert _identity(default_target.replace("\\", "/")) in _identity(persisted)
assert _identity(str(unet_dir).replace("\\", "/")) in _identity(persisted)
@@ -70,6 +70,7 @@ function createApiClient(overrides = {}) {
dir_count: 0, dir_count: 0,
restorable: true, restorable: true,
}), }),
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'empty' }),
renameFolder: vi.fn().mockResolvedValue({ renameFolder: vi.fn().mockResolvedValue({
success: true, success: true,
renamed: true, renamed: true,
@@ -1294,7 +1295,7 @@ describe('SidebarManager folder deletion', () => {
expect(manager.refresh).toHaveBeenCalledTimes(1); expect(manager.refresh).toHaveBeenCalledTimes(1);
}); });
it('reports a stale tree node as missing instead of fabricating a path', async () => { it('offers to clear a stale tree node instead of fabricating a path', async () => {
const apiClient = createApiClient({ const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({ resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'removed', candidates: [], success: true, folder: 'removed', candidates: [],
@@ -1307,9 +1308,85 @@ describe('SidebarManager folder deletion', () => {
const modal = document.getElementById('deleteFolderModal'); const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('missing'); expect(modal.dataset.state).toBe('missing');
expect(confirmBtn().style.display).toBe('none'); // Nothing to delete, so the delete probe never runs and no path is guessed...
expect(manager._pendingDeleteFolderPath).toBeNull();
expect(apiClient.deleteFolder).not.toHaveBeenCalled(); expect(apiClient.deleteFolder).not.toHaveBeenCalled();
expect(manager._pendingDeleteFolderPath).toBeNull();
// ...but the records are all that keeps the node alive, so they can be
// dropped from here (issue #1145).
expect(confirmBtn().style.display).toBe('');
expect(confirmBtn().disabled).toBe(false);
expect(confirmBtn().textContent).toBe('Remove from list');
});
it('clears the node through the forget endpoint when no root holds it', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'removed', candidates: [],
}),
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'removed' }),
});
const manager = createManager(apiClient);
manager.nonEmptyFolders = new Set(['', 'full']);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager.showDeleteFolderModal('removed');
const cleared = await manager.handleDeleteFolderConfirm();
expect(cleared).toBe(true);
expect(apiClient.forgetFolder).toHaveBeenCalledWith('removed');
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
expect(manager.refresh).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.forgotten',
{ name: 'removed' },
'success'
);
});
it('reports a delete that found the folder gone as cleared, not deleted', async () => {
// The probe can pass and the directory still be gone by the time the real
// delete runs (a file manager move in between, or another row of the
// multi-root list): the records are dropped and nothing was deleted.
const apiClient = createApiClient({
deleteFolder: vi.fn().mockResolvedValue({
success: true,
code: 'missing',
already_missing: true,
folder: 'empty',
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const deleted = await manager._deleteFolder('empty', '/models/loras/empty');
expect(deleted).toBe(true);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.forgotten',
{ name: 'empty' },
'success'
);
expect(showActionToast).not.toHaveBeenCalled();
});
it('warns when the folder turned out to be back on disk', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'back', candidates: [],
}),
forgetFolder: vi.fn().mockRejectedValue(
Object.assign(new Error('The folder still exists on disk'), { code: 'exists' })
),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const cleared = await manager._deleteFolder('back');
expect(cleared).toBe(false);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.exists', {}, 'warning'
);
}); });
it('refuses a node whose every copy is a symbolic link', async () => { it('refuses a node whose every copy is a symbolic link', async () => {
+40
View File
@@ -248,3 +248,43 @@ def test_static_sources_only_use_existing_translations(
"Static sources reference missing translation keys: " "Static sources reference missing translation keys: "
f"{missing_keys[:20]}" + ("..." if len(missing_keys) > 20 else "") f"{missing_keys[:20]}" + ("..." if len(missing_keys) > 20 else "")
) )
# The reference file is hand-maintained with 2-space indent; every other locale
# is written by scripts/sync_translation_keys.py with 4-space indent. Both keep
# raw non-ASCII and exactly one trailing newline.
REFERENCE_INDENT = 2
TRANSLATION_INDENT = 4
def test_locale_files_keep_their_maintained_layout():
"""A re-serialising edit must fail here instead of landing as a huge diff.
Loading a locale with ``json.load`` and dumping it back with a different
``indent`` rewrites every line: changing two strings once produced a
26k-line diff that had to be reverted and redone in place. Values belong in
place (a targeted text edit), keys belong to ``sync_translation_keys.py``.
"""
offenders = []
for locale in EXPECTED_LOCALES:
path = LOCALES_DIR / f"{locale}.json"
raw = path.read_text(encoding="utf-8")
indent = REFERENCE_INDENT if locale == "en" else TRANSLATION_INDENT
canonical = (
json.dumps(
json.loads(raw),
indent=indent,
ensure_ascii=False,
separators=(",", ": "),
)
+ "\n"
)
if raw != canonical:
offenders.append(path.name)
assert not offenders, (
"these locale files were reformatted: "
f"{offenders}. Keep en.json at indent=2 and the others at indent=4 "
"(ensure_ascii=False, one trailing newline) and edit values in place."
)
+4 -3
View File
@@ -6,6 +6,7 @@ import pytest
from py.nodes.lora_cycler import LoraCyclerLM from py.nodes.lora_cycler import LoraCyclerLM
from py.services import service_registry from py.services import service_registry
import os
@pytest.fixture @pytest.fixture
@@ -48,7 +49,7 @@ async def test_cycler_uses_scaled_preset_strength_when_available(
result = await cycler_node.cycle(cycler_config) result = await cycler_node.cycle(cycler_config)
assert result["result"][0] == [ assert result["result"][0] == [
("/models/loras/preset_lora.safetensors", 1.05, 0.75) ("/models/loras/preset_lora.safetensors".replace("/", os.sep), 1.05, 0.75)
] ]
@@ -74,7 +75,7 @@ async def test_cycler_falls_back_to_manual_strength_when_preset_missing(
result = await cycler_node.cycle(cycler_config) result = await cycler_node.cycle(cycler_config)
assert result["result"][0] == [ assert result["result"][0] == [
("/models/loras/manual_lora.safetensors", 0.8, 0.6) ("/models/loras/manual_lora.safetensors".replace("/", os.sep), 0.8, 0.6)
] ]
@@ -105,5 +106,5 @@ async def test_cycler_syncs_clip_to_model_when_same_clip_strength_enabled(
) )
assert result["result"][0] == [ assert result["result"][0] == [
("/models/loras/preset_lora.safetensors", 1.05, 1.05) ("/models/loras/preset_lora.safetensors".replace("/", os.sep), 1.05, 1.05)
] ]
+2 -2
View File
@@ -932,8 +932,8 @@ def test_resolve_folder_answers_with_the_root_that_holds_the_directory(
assert payload["folder"] == "recipes" assert payload["folder"] == "recipes"
assert payload["candidates"] == [ assert payload["candidates"] == [
{ {
"folder_path": str(primary / "recipes"), "folder_path": str(primary / "recipes").replace(os.sep, "/"),
"root": str(primary), "root": str(primary).replace(os.sep, "/"),
"is_symlink": False, "is_symlink": False,
} }
] ]
+4
View File
@@ -175,6 +175,10 @@ async def test_validate_path_expands_user(tmp_path, monkeypatch):
subdir = tmp_path / "subdir" subdir = tmp_path / "subdir"
subdir.mkdir() subdir.mkdir()
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
# ntpath.expanduser reads USERPROFILE (or HOMEDRIVE + HOMEPATH), never HOME.
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("HOMEDRIVE", tmp_path.drive)
monkeypatch.setenv("HOMEPATH", str(tmp_path)[len(tmp_path.drive):])
response, payload = await _validate(_make_handler(), "~/subdir") response, payload = await _validate(_make_handler(), "~/subdir")
+29 -6
View File
@@ -47,6 +47,18 @@ def _json_payload(response) -> dict[str, Any]:
return json.loads(text) return json.loads(text)
def _expected_opener() -> str:
"""The command the handler uses to open a path on this platform."""
return "explorer" if os.name == "nt" else "xdg-open"
def _posix_only(reason: str):
"""Mark a test that exercises the POSIX open strategy (xdg-open/dbus)."""
return pytest.mark.skipif(os.name == "nt", reason=reason)
class FakeRequest: class FakeRequest:
def __init__(self, *, json_data=None, query=None, method="POST"): def __init__(self, *, json_data=None, query=None, method="POST"):
self._json_data = json_data or {} self._json_data = json_data or {}
@@ -636,7 +648,7 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert response.status == 200 assert response.status == 200
assert payload["success"] is True assert payload["success"] is True
assert payload["path"] == str(backup_dir) assert payload["path"] == str(backup_dir)
assert calls == [["xdg-open", str(backup_dir)]] assert calls == [[_expected_opener(), str(backup_dir)]]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -667,9 +679,10 @@ async def test_open_sidecar_location_opens_configured_root(tmp_path, monkeypatch
assert payload["path"] == str(root) assert payload["path"] == str(root)
# Created on demand so the button works before any migration ran. # Created on demand so the button works before any migration ran.
assert root.is_dir() assert root.is_dir()
assert calls == [["xdg-open", str(root)]] assert calls == [[_expected_opener(), str(root)]]
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch): async def test_open_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session xdg-open cannot work; the handler must hand the """Without a GUI session xdg-open cannot work; the handler must hand the
@@ -721,6 +734,7 @@ async def test_get_settings_includes_resolved_sidecar_root(tmp_path):
assert payload["settings"]["sidecar_storage_root_in_repo"] is False assert payload["settings"]["sidecar_storage_root_in_repo"] is False
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch): async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session xdg-open cannot work; the handler must hand the """Without a GUI session xdg-open cannot work; the handler must hand the
@@ -774,7 +788,11 @@ async def test_open_settings_location_with_display_opens_folder(tmp_path, monkey
assert response.status == 200 assert response.status == 200
assert payload["success"] is True assert payload["success"] is True
assert calls == [["xdg-open", str(settings_file.parent)]] if os.name == "nt":
# explorer reveals the file itself, there is no "open the folder" call.
assert calls == [["explorer", "/select,", str(settings_file)]]
else:
assert calls == [[_expected_opener(), str(settings_file.parent)]]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -807,9 +825,10 @@ async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, mon
assert payload["success"] is True assert payload["success"] is True
assert payload["path"] == str(wildcards_dir) assert payload["path"] == str(wildcards_dir)
assert wildcards_dir.is_dir() assert wildcards_dir.is_dir()
assert calls == [["xdg-open", str(wildcards_dir)]] assert calls == [[_expected_opener(), str(wildcards_dir)]]
@_posix_only("dbus file-manager selection is Linux-only")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_file_location_linux_selects_via_dbus(tmp_path, monkeypatch): async def test_open_file_location_linux_selects_via_dbus(tmp_path, monkeypatch):
"""On a Linux desktop the handler should ask the freedesktop FileManager1 """On a Linux desktop the handler should ask the freedesktop FileManager1
@@ -849,6 +868,7 @@ async def test_open_file_location_linux_selects_via_dbus(tmp_path, monkeypatch):
assert popen_calls == [] assert popen_calls == []
@_posix_only("xdg-open is the Linux opener")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_file_location_linux_falls_back_to_xdg_open(tmp_path, monkeypatch): async def test_open_file_location_linux_falls_back_to_xdg_open(tmp_path, monkeypatch):
"""When the FileManager1 DBus interface is unavailable, fall back to """When the FileManager1 DBus interface is unavailable, fall back to
@@ -877,9 +897,10 @@ async def test_open_file_location_linux_falls_back_to_xdg_open(tmp_path, monkeyp
assert response.status == 200 assert response.status == 200
assert payload["success"] is True assert payload["success"] is True
assert popen_calls == [["xdg-open", str(tmp_path)]] assert popen_calls == [[_expected_opener(), str(tmp_path)]]
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monkeypatch): async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session there is no file manager to open; the handler """Without a GUI session there is no file manager to open; the handler
@@ -917,6 +938,7 @@ def _patch_linux_desktop(monkeypatch):
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False) monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
@_posix_only("file-manager reveal via dbus is Linux-only")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch): async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch):
"""Centralized mode: the resolved .metadata.json is revealed selected, """Centralized mode: the resolved .metadata.json is revealed selected,
@@ -985,7 +1007,7 @@ async def test_open_model_sidecar_location_falls_back_to_sidecar_dir(tmp_path, m
assert response.status == 200 assert response.status == 200
assert payload["success"] is True assert payload["success"] is True
assert payload["path"] == str(sidecar_dir) assert payload["path"] == str(sidecar_dir)
assert popen_calls == [["xdg-open", str(sidecar_dir)]] assert popen_calls == [[_expected_opener(), str(sidecar_dir)]]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1012,6 +1034,7 @@ async def test_open_model_sidecar_location_missing_sidecar_returns_404(tmp_path,
assert payload["success"] is False assert payload["success"] is False
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch): async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
model_file = tmp_path / "model.safetensors" model_file = tmp_path / "model.safetensors"
+68
View File
@@ -23,6 +23,10 @@ class FakeMoveService:
self.received_dry_run = dry_run self.received_dry_run = dry_run
return self._result return self._result
async def forget_folder(self, folder):
self.received_folder = folder
return self._result
async def rename_folder(self, folder_path, new_name): async def rename_folder(self, folder_path, new_name):
self.received_path = folder_path self.received_path = folder_path
self.received_new_name = new_name self.received_new_name = new_name
@@ -34,13 +38,26 @@ class FakeMoveService:
class FakeRequest: class FakeRequest:
method = "POST"
def __init__(self, payload): def __init__(self, payload):
self._payload = payload self._payload = payload
# A real aiohttp request always carries a query string too.
self.query = {}
async def json(self): async def json(self):
return self._payload return self._payload
class FakeGetRequest:
"""GET stand-in carrying only a query string, as the extension sends it."""
method = "GET"
def __init__(self, query):
self.query = query
class FakeQueryRequest: class FakeQueryRequest:
"""Request stand-in exposing only the query string.""" """Request stand-in exposing only the query string."""
@@ -394,3 +411,54 @@ async def test_rename_folder_invalid_json_body():
assert response.status == 400 assert response.status == 400
assert json.loads(response.text)["success"] is False assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_forget_folder_success():
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
assert response.status == 200
assert json.loads(response.text)["folder"] == "Krea2/Women"
assert service.received_folder == "Krea2/Women"
@pytest.mark.asyncio
async def test_forget_folder_accepts_get_with_query_params():
"""Forgetting is a state change, but the extension is GET-only by convention."""
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
response = await handler.forget_folder(
FakeGetRequest({"folder": "Krea2/Women"})
)
assert response.status == 200
assert service.received_folder == "Krea2/Women"
@pytest.mark.asyncio
async def test_forget_folder_requires_a_folder():
handler, service = _make_handler({"success": True})
response = await handler.forget_folder(FakeRequest({}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
assert service.received_folder is None
@pytest.mark.asyncio
async def test_forget_folder_existing_directory_maps_to_409():
"""The tree the client rendered is stale: the folder must be deleted."""
handler, _service = _make_handler(
{
"success": False,
"code": "exists",
"error": "The folder still exists on disk",
}
)
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
assert response.status == 409
+26
View File
@@ -316,6 +316,32 @@ async def test_scan_models_rejects_unknown_root():
assert service.received_scope == "not-called" assert service.received_scope == "not-called"
@pytest.mark.asyncio
async def test_scan_models_accepts_a_root_in_the_hosts_own_spelling():
"""Windows callers may pass a root the way ComfyUI spells it (issue #1146).
``settings.json`` and the ComfyUI logs show ``E:\\loras`` while the live list
holds ``E:/loras``; both name the same directory, so a scoped scan must select
it instead of answering 400 "Unknown model root(s)".
"""
service = ScopedScanService(
roots=["E:/StableDiffusion/cc_models/loras"], summary=SUMMARY
)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
for requested in (
"E:\\StableDiffusion\\cc_models\\loras",
"e:/StableDiffusion/cc_models/loras",
):
service.received_scope = "not-called"
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"roots": [requested]}))
)
assert response.status == 200, requested
assert service.received_scope.roots == (requested,)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_models_rejects_roots_with_full_rebuild(): async def test_scan_models_rejects_roots_with_full_rebuild():
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY) service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
+2 -4
View File
@@ -403,9 +403,7 @@ def test_find_matching_root_includes_other_roots(isolated_config_roots, tmp_path
vae_root = tmp_path / "vae" vae_root = tmp_path / "vae"
isolated_config_roots.other_roots = [str(vae_root)] isolated_config_roots.other_roots = [str(vae_root)]
assert model_source_handlers._find_matching_root(str(vae_root / "sub")) == str( assert model_source_handlers._find_matching_root(str(vae_root / "sub")) == str(vae_root).replace(os.sep, "/").replace(os.sep, "/")
vae_root
)
def test_find_matching_root_respects_path_boundaries(isolated_config_roots, tmp_path): def test_find_matching_root_respects_path_boundaries(isolated_config_roots, tmp_path):
@@ -1239,7 +1237,7 @@ async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
"https://resources.modelscope.cn/cover-images/b.png", "https://resources.modelscope.cn/cover-images/b.png",
"https://resources.modelscope.cn/cover-images/c.png", "https://resources.modelscope.cn/cover-images/c.png",
] ]
assert saved["preview_url"] == str(tmp_path / "preview.webp") assert saved["preview_url"] == str(tmp_path / "preview.webp").replace(os.sep, "/")
assert saved["usage_tips"] == ( assert saved["usage_tips"] == (
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}' '{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
) )
@@ -532,7 +532,7 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
extracted = save_dir / "model.safetensors" extracted = save_dir / "model.safetensors"
assert extracted.exists() assert extracted.exists()
saved_call = MetadataManager.save_metadata.await_args saved_call = MetadataManager.save_metadata.await_args
assert saved_call.args[0] == str(extracted) assert saved_call.args[0] == str(extracted).replace(os.sep, "/")
# SHA256 comes from metadata (API value), not recalculated # SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256" assert saved_call.args[1].sha256 == "sha256"
# The zip-level AutoV3 was cleared; the extracted file is not safetensors, # The zip-level AutoV3 was cleared; the extracted file is not safetensors,
@@ -617,10 +617,10 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
assert dummy_scanner.add_model_to_cache.await_count == 2 assert dummy_scanner.add_model_to_cache.await_count == 2
metadata_calls = MetadataManager.save_metadata.await_args_list metadata_calls = MetadataManager.save_metadata.await_args_list
assert metadata_calls[0].args[0] == str(extracted_one) assert metadata_calls[0].args[0] == str(extracted_one).replace(os.sep, "/")
# SHA256 comes from metadata (API value), not recalculated # SHA256 comes from metadata (API value), not recalculated
assert metadata_calls[0].args[1].sha256 == "sha256" assert metadata_calls[0].args[1].sha256 == "sha256"
assert metadata_calls[1].args[0] == str(extracted_two) assert metadata_calls[1].args[0] == str(extracted_two).replace(os.sep, "/")
assert metadata_calls[1].args[1].sha256 == "sha256" assert metadata_calls[1].args[1].sha256 == "sha256"
@@ -693,7 +693,7 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
extracted = save_dir / "embedding.pt" extracted = save_dir / "embedding.pt"
assert extracted.exists() assert extracted.exists()
saved_call = MetadataManager.save_metadata.await_args saved_call = MetadataManager.save_metadata.await_args
assert saved_call.args[0] == str(extracted) assert saved_call.args[0] == str(extracted).replace(os.sep, "/")
# SHA256 comes from metadata (API value), not recalculated # SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256" assert saved_call.args[1].sha256 == "sha256"
assert dummy_scanner.add_model_to_cache.await_count == 1 assert dummy_scanner.add_model_to_cache.await_count == 1
+120 -5
View File
@@ -359,7 +359,9 @@ async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path): async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path) target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir() batch = target / ".lm-pending-delete" / "batch1"
batch.mkdir(parents=True)
(batch / "manifest.json").write_text("{}", encoding="utf-8")
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora") service = ModelMoveService(scanner, "lora")
@@ -370,6 +372,43 @@ async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
assert target.exists() assert target.exists()
@pytest.mark.asyncio
async def test_delete_folder_ignores_an_empty_staging_container(tmp_path: Path):
"""An empty `.lm-pending-delete` is a leftover, not a pending deletion.
Every purge, undo and failed staging used to leave the container behind, and
the name match alone made the folder undeletable for good: "A deletion is
still pending inside this folder. Wait for the undo window to expire." with
no window left to wait for (issue #1145 follow-up).
"""
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert not target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_over_a_quarantined_batch(tmp_path: Path):
"""Quarantine preserves the user's staged files, so it still blocks."""
target = _make_nested(tmp_path)
quarantined = target / ".lm-pending-delete" / "batch1.orphaned"
quarantined.mkdir(parents=True)
(quarantined / "model.safetensors").write_bytes(b"user-data")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "busy"
assert (quarantined / "model.safetensors").read_bytes() == b"user-data"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path): async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
@@ -400,14 +439,74 @@ async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_folder_reports_missing_directory(tmp_path: Path): async def test_delete_folder_forgets_a_directory_that_is_already_gone(tmp_path: Path):
"""There is nothing to delete, but the sidebar node can still be cleared.
A folder moved with a file manager leaves its records behind; answering
"Folder no longer exists" left the ghost with no way out of the UI (#1145).
"""
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
gone = tmp_path / "gone"
result = await service.delete_folder(str(gone))
assert result["success"] is True
assert result["code"] == "missing"
assert result["already_missing"] is True
assert result["folder"] == "gone"
assert scanner.removed_folders == ["gone"]
assert scanner.removed_folder_paths == [str(gone)]
@pytest.mark.asyncio
async def test_delete_folder_dry_run_still_reports_a_missing_directory(tmp_path: Path):
"""The probe stays a pure read — it is what makes the dialog offer the removal."""
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora") service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "gone")) result = await service.delete_folder(str(tmp_path / "gone"), dry_run=True)
assert result["success"] is False assert result["success"] is False
assert "no longer exists" in result["error"] assert result["code"] == "missing"
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_forget_folder_drops_the_records_of_a_gone_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.forget_folder("Krea2/Women")
assert result["success"] is True
assert result["folder"] == "Krea2/Women"
# No absolute path: the same relative folder may exist under several roots,
# and the scanner keeps an entry another root still holds.
assert scanner.removed_folders == ["Krea2/Women"]
assert scanner.removed_folder_paths == [None]
@pytest.mark.asyncio
async def test_forget_folder_refuses_while_the_directory_still_exists(tmp_path: Path):
"""Then the caller wants a delete, not a cleanup."""
(tmp_path / "Krea2" / "Women").mkdir(parents=True)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.forget_folder("Krea2/Women")
assert result["success"] is False
assert result["code"] == "exists"
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_forget_folder_requires_a_folder(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
assert (await service.forget_folder(""))["success"] is False
assert (await service.forget_folder("../outside"))["success"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -609,7 +708,9 @@ async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path): async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path) target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir() batch = target / ".lm-pending-delete" / "batch1"
batch.mkdir(parents=True)
(batch / "manifest.json").write_text("{}", encoding="utf-8")
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora") service = ModelMoveService(scanner, "lora")
@@ -621,6 +722,20 @@ async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
assert scanner.renamed_folders == [] assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_ignores_an_empty_staging_container(tmp_path: Path):
"""The leftover container must not block a rename either."""
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is True
assert scanner.renamed_folders
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rename_folder_requires_path(tmp_path: Path): async def test_rename_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora") service = ModelMoveService(FakeScanner([tmp_path]), "lora")
+334 -6
View File
@@ -48,6 +48,20 @@ class RecordingWebSocketManager:
self.broadcasts.append(payload) self.broadcasts.append(payload)
def _root_label_names(labels) -> List[str]:
"""The root names behind display labels.
``_root_display_labels`` prefixes the drive on Windows ("C: loras") and may
grow a label leftwards to keep it unique ("parent/loras"), so a test that only
cares *which* root was walked compares the final path component.
"""
return [
label.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1].rsplit(" ", 1)[-1]
for label in labels
]
def _normalize_path(path: Path) -> str: def _normalize_path(path: Path) -> str:
return str(path).replace(os.sep, "/") return str(path).replace(os.sep, "/")
@@ -2306,6 +2320,10 @@ def test_count_cached_entries_per_root_uses_longest_prefix():
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(
os.name == "nt",
reason="simulates a case-sensitive filesystem; Windows matches case itself",
)
async def test_reconcile_case_fold_fallback_is_indexed_not_linear( async def test_reconcile_case_fold_fallback_is_indexed_not_linear(
tmp_path: Path, monkeypatch tmp_path: Path, monkeypatch
): ):
@@ -2465,7 +2483,10 @@ async def test_reconcile_walk_broadcasts_per_root_progress(tmp_path: Path, monke
assert len(walk_messages) >= 2 assert len(walk_messages) >= 2
# Intermediate ticks name the root being walked. # Intermediate ticks name the root being walked.
assert any(message["active_roots"] == ["loras"] for message in walk_messages) assert any(
_root_label_names(message["active_roots"]) == ["loras"]
for message in walk_messages
)
final = walk_messages[-1] final = walk_messages[-1]
assert final["files_seen"] == 4 assert final["files_seen"] == 4
@@ -2474,7 +2495,7 @@ async def test_reconcile_walk_broadcasts_per_root_progress(tmp_path: Path, monke
assert final["roots_total"] == 1 assert final["roots_total"] == 1
assert final["roots_done"] == 1 assert final["roots_done"] == 1
assert final["active_roots"] == [] assert final["active_roots"] == []
assert final["current_name"] == "loras" assert _root_label_names([final["current_name"]]) == ["loras"]
assert 0 < final["progress"] <= 50 assert 0 < final["progress"] <= 50
assert final["full_rebuild"] is False assert final["full_rebuild"] is False
assert walk_messages[0]["progress"] <= final["progress"] assert walk_messages[0]["progress"] <= final["progress"]
@@ -2546,7 +2567,7 @@ async def test_reconcile_scoped_scan_leaves_other_roots_untouched(tmp_path: Path
assert summary is not None assert summary is not None
assert summary["added"] == 1 assert summary["added"] == 1
assert summary["removed"] == 0 assert summary["removed"] == 0
assert summary["scanned_roots"] == ["a"] assert _root_label_names(summary["scanned_roots"]) == ["a"]
cached_paths = {item["file_path"] for item in scanner._cache.raw_data} cached_paths = {item["file_path"] for item in scanner._cache.raw_data}
assert cached_paths == { assert cached_paths == {
@@ -2717,11 +2738,13 @@ async def test_reconcile_reports_root_filtered_out_at_startup(tmp_path: Path, mo
assert summary["added"] == 0 assert summary["added"] == 0
assert summary["removed"] == 0 assert summary["removed"] == 0
# ...but the switched-off root is no longer silent: it is reported with the # ...but the switched-off root is no longer silent: it is reported with the
# entries that were kept for it. # entries that were kept for it. Its label is what the shared label helper
# makes of the pair (drive-prefixed on Windows).
labels = model_scanner._root_display_labels([str(_primary), str(missing)])
assert summary["skipped_roots"] == [ assert summary["skipped_roots"] == [
{ {
"path": _normalize_path(missing), "path": _normalize_path(missing),
"label": missing.name, "label": labels[str(missing)],
"reason": "root_unavailable", "reason": "root_unavailable",
} }
] ]
@@ -2746,7 +2769,7 @@ async def test_scoped_scan_stays_quiet_about_filtered_out_roots(tmp_path: Path,
assert summary is not None assert summary is not None
# The caller asked for one root: a root it did not ask about is not its # The caller asked for one root: a root it did not ask about is not its
# business, but its entries are still kept. # business, but its entries are still kept.
assert summary["scanned_roots"] == [primary.name] assert _root_label_names(summary["scanned_roots"]) == [primary.name]
assert "root_unavailable" not in { assert "root_unavailable" not in {
entry["reason"] for entry in summary["skipped_roots"] entry["reason"] for entry in summary["skipped_roots"]
} }
@@ -2949,3 +2972,308 @@ def test_reconcile_walk_skips_the_recipes_dir(tmp_path: Path, monkeypatch):
assert "keep" in result.discovered_folders assert "keep" in result.discovered_folders
assert "recipes" not in result.discovered_folders assert "recipes" not in result.discovered_folders
assert "recipes/sub" not in result.discovered_folders assert "recipes/sub" not in result.discovered_folders
# --- the host's path spelling (issue #1146) ---------------------------------
def _windows_spelling(path: Path) -> str:
"""Return a path the way ComfyUI hands it over on Windows.
``utils/extra_config.py`` normalizes every configured model folder with
``os.path.normpath``, so on Windows the configured list is all backslashes
while the live root list is forward-slashed.
"""
return str(path).replace("/", "\\")
def _simulate_windows_separators(monkeypatch) -> None:
"""Make ``os.sep`` a backslash, as it is on the reporting machine.
``os.path`` stays ``posixpath`` (its separator is its own constant), so this
only changes code that folds separators through ``os.sep`` -- which is exactly
what turned the backslash twin into the live root's unreachable prefix on
Windows and stopped deleted models from being pruned. Without it that half of
#1145/#1146 can only be reproduced on Windows.
"""
monkeypatch.setattr(os, "sep", "\\")
async def _windows_spelling_scanner(tmp_path: Path, monkeypatch):
"""Scanner configured with backslash roots plus a never-created output dir.
``<output>/loras`` is what ComfyUI registers for its save nodes whether or not
the directory exists (``main.py`` calls ``add_model_folder_path`` with it
unconditionally).
"""
primary = tmp_path / "primary"
(primary / "pack").mkdir(parents=True)
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
never_created = tmp_path / "output" / "loras"
scanner = MultiRootDummyScanner([primary])
await scanner._initialize_cache()
monkeypatch.setattr(
scanner,
"get_configured_model_roots",
lambda: [_windows_spelling(primary), _windows_spelling(never_created)],
)
_simulate_windows_separators(monkeypatch)
return scanner, primary, never_created
@pytest.mark.asyncio
async def test_describe_roots_folds_the_host_path_spelling(tmp_path: Path, monkeypatch):
"""One directory is one root, whatever separator the host used."""
scanner, primary, _never_created = await _windows_spelling_scanner(
tmp_path, monkeypatch
)
described = scanner.describe_model_roots()
assert [entry["path"] for entry in described] == [_normalize_path(primary)]
assert described[0]["available"] is True
assert described[0]["reachable"] is True
assert described[0]["models"] == 1
@pytest.mark.asyncio
async def test_reconcile_does_not_shadow_a_backslash_configured_root(
tmp_path: Path, monkeypatch
):
"""A configured twin must not register its live root as unreachable.
``_UnreachablePaths`` normalizes the prefixes it is given, so the backslash
spelling of a root the walk just read normalized to the same prefix: every
cached entry under it counted as "kept", which is what made the refresh toast
claim thousands of reachable models were inaccessible -- and what stopped
deleted models from ever being pruned.
"""
scanner, primary, _never_created = await _windows_spelling_scanner(
tmp_path, monkeypatch
)
(primary / "pack" / "a.txt").unlink()
summary = await scanner._reconcile_cache()
assert summary is not None
assert summary["skipped_roots"] == []
assert summary["unavailable_paths"] == []
assert summary["kept_unreachable"] == 0
assert summary["removed"] == 1
assert scanner._cache.raw_data == []
@pytest.mark.asyncio
async def test_root_that_never_existed_is_not_reported(tmp_path: Path, monkeypatch):
"""A registered directory that neither exists nor holds cache is not a root.
Only roots the user really configured and whose models are cached are worth an
offline row or a "models kept" notice.
"""
primary = tmp_path / "primary"
(primary / "pack").mkdir(parents=True)
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
never_created = tmp_path / "output" / "loras"
scanner = MultiRootDummyScanner([primary])
await scanner._initialize_cache()
monkeypatch.setattr(
scanner,
"get_configured_model_roots",
lambda: [str(primary), str(never_created)],
)
assert [entry["path"] for entry in scanner.describe_model_roots()] == [
_normalize_path(primary)
]
summary = await scanner._reconcile_cache()
assert summary is not None
assert summary["skipped_roots"] == []
assert summary["kept_unreachable"] == 0
@pytest.mark.asyncio
async def test_scoped_scan_accepts_the_host_path_spelling(tmp_path: Path, monkeypatch):
"""A caller may name a root the way the host spells it (issue #1146).
The Refresh menu sends back the path `/roots` rendered, but the browser
extension and scripts can pass what settings.json holds -- backslashes on
Windows -- and that must select the root rather than scan nothing.
"""
primary = tmp_path / "primary"
(primary / "pack").mkdir(parents=True)
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
scanner = MultiRootDummyScanner([primary])
await scanner._initialize_cache()
scope = ReconcileScope(roots=(_windows_spelling(primary),))
summary = await scanner._reconcile_cache(scope=scope)
assert summary is not None
assert _root_label_names(summary["scanned_roots"]) == [primary.name]
@pytest.mark.asyncio
async def test_external_folder_move_leaves_no_ghost(tmp_path: Path, monkeypatch):
"""A folder moved with a file manager must leave no ghost (issue #1145).
Both reported symptoms share the cause fixed for #1146: while the configured
twin of a live root was registered as unreachable, the cached rows under the
old path were "kept" instead of pruned (the same model was then listed at its
old and its new location), and the walk could never be considered fully
verified -- so `all_folders` only ever grew and the deleted folder stayed in
the sidebar, even though the delete dialog correctly reported it as gone.
"""
primary = tmp_path / "primary"
(primary / "Krea2" / "Women").mkdir(parents=True)
original = primary / "Krea2" / "Women" / "a.txt"
original.write_text("a", encoding="utf-8")
scanner = MultiRootDummyScanner([primary])
await scanner._initialize_cache()
assert "Krea2/Women" in await scanner.get_all_folders()
# What Dopus did: the folder is gone and its file lives somewhere else.
(primary / "Krea2" / "New").mkdir()
shutil.move(str(original), str(primary / "Krea2" / "New" / "a.txt"))
(primary / "Krea2" / "Women").rmdir()
monkeypatch.setattr(
scanner, "get_configured_model_roots", lambda: [_windows_spelling(primary)]
)
_simulate_windows_separators(monkeypatch)
summary = await scanner._reconcile_cache()
# One model, listed once: the old path is pruned and the new one added.
assert summary is not None
assert summary["removed"] == 1
assert summary["added"] == 1
assert summary["kept_unreachable"] == 0
assert [item["file_path"] for item in scanner._cache.raw_data] == [
_normalize_path(primary / "Krea2" / "New" / "a.txt")
]
# ...and the folder record follows the filesystem instead of only growing.
all_folders = await scanner.get_all_folders()
assert "Krea2/Women" not in all_folders
assert "Krea2/New" in all_folders
def test_unreachable_paths_cover_whole_folders():
"""The folder refresh asks "could this specific entry have been read?"."""
unreachable = model_scanner._UnreachablePaths()
unreachable.add("/mnt/drive-z/loras", "root_unreachable", kept=1)
unreachable.add("/mnt/a/loras/locked", "unreadable_dir")
# A directory the walk could not enter covers itself and everything below it,
# whichever way the root is spelled.
assert unreachable.covers_folder("/mnt/a/loras", "locked") is True
assert unreachable.covers_folder("/mnt/a/loras", "locked/deep") is True
assert unreachable.covers_folder("\\mnt\\a\\loras", "locked/deep") is True
# A root that is unreadable as a whole does not: library-relative names carry
# no root, so it would cover every entry and keep every ghost alive (#1145).
assert unreachable.covers_folder("/mnt/drive-z/loras", "pack") is False
assert unreachable.covers_folder("/mnt/a/loras", "free") is False
assert unreachable.covers_folder("/mnt/a/loras", "locked_other") is False
assert unreachable.covers_folder("/mnt/a/loras", "") is False
@pytest.mark.asyncio
async def test_ghost_folder_cleared_while_another_root_is_offline(
tmp_path: Path, monkeypatch
):
"""A switched-off drive must not keep the other roots' ghosts alive (#1145).
Keeping the whole folder list whenever anything was unreadable is what made a
folder moved in a file manager outlive every refresh. Only the entries the
unreadable root can actually account for may be kept.
"""
online = tmp_path / "online"
offline = tmp_path / "offline"
(online / "old" / "pack").mkdir(parents=True)
(online / "old" / "pack" / "a.txt").write_text("a", encoding="utf-8")
(offline / "keepme").mkdir(parents=True)
(offline / "keepme" / "b.txt").write_text("b", encoding="utf-8")
(offline / "empty_folder").mkdir(parents=True)
scanner = MultiRootDummyScanner([online, offline])
await scanner._initialize_cache()
assert "old/pack" in scanner._cache.all_folders
assert "keepme" in scanner._cache.all_folders
# The folder is moved externally...
(online / "new" / "pack").mkdir(parents=True)
shutil.move(
str(online / "old" / "pack" / "a.txt"),
str(online / "new" / "pack" / "a.txt"),
)
shutil.rmtree(online / "old")
# ...while the other drive is switched off.
shutil.rmtree(offline)
summary = await scanner._reconcile_cache()
assert summary is not None
assert summary["added"] == 1
assert summary["removed"] == 1
assert summary["kept_unreachable"] == 1
folders = await scanner.get_all_folders()
assert "old" not in folders
assert "old/pack" not in folders
assert "new/pack" in folders
# The unreadable root keeps its model-bearing folders and its cached models
# (model-derived folders are always unioned in)...
assert "keepme" in folders
assert _normalize_path(offline / "keepme" / "b.txt") in {
item["file_path"] for item in scanner._cache.raw_data
}
# ...while an *empty* folder on it cannot be attributed to any root, so it is
# not kept alive. It comes back the next time that root is walked.
assert "empty_folder" not in folders
@pytest.mark.asyncio
async def test_scoped_scan_never_drops_folders_of_other_roots(tmp_path: Path):
"""A scoped scan cannot speak for the roots it was not asked about."""
root_a = tmp_path / "a"
root_b = tmp_path / "b"
(root_a / "a_folder").mkdir(parents=True)
(root_b / "b_folder").mkdir(parents=True)
scanner = MultiRootDummyScanner([root_a, root_b])
await scanner._initialize_cache()
shutil.rmtree(root_a / "a_folder")
await scanner._reconcile_cache(scope=ReconcileScope(roots=(str(root_b),)))
folders = await scanner.get_all_folders()
assert "a_folder" in folders
assert "b_folder" in folders
@pytest.mark.asyncio
async def test_root_scoped_scan_of_every_root_still_cleans_folders(tmp_path: Path):
"""Asking for every root is a full refresh: ghosts are dropped."""
root_a = tmp_path / "a"
root_b = tmp_path / "b"
(root_a / "a_folder").mkdir(parents=True)
(root_b / "b_folder").mkdir(parents=True)
scanner = MultiRootDummyScanner([root_a, root_b])
await scanner._initialize_cache()
shutil.rmtree(root_a / "a_folder")
await scanner._reconcile_cache(
scope=ReconcileScope(roots=(str(root_a), str(root_b)))
)
folders = await scanner.get_all_folders()
assert "a_folder" not in folders
assert "b_folder" in folders
+88 -2
View File
@@ -901,7 +901,7 @@ async def test_p_checkpoint_pending_discovery_excludes_staging(tmp_path: Path, m
pending = await scanner._find_pending_models_from_filesystem() pending = await scanner._find_pending_models_from_filesystem()
paths = [entry["file_path"] for entry in pending] paths = [entry["file_path"] for entry in pending]
assert str(root / "real.safetensors") in paths assert str(root / "real.safetensors").replace(os.sep, "/") in paths
assert not any(PENDING_DELETE_DIR_NAME in p for p in paths) assert not any(PENDING_DELETE_DIR_NAME in p for p in paths)
@@ -921,7 +921,7 @@ async def test_p_usage_stats_lookup_excludes_staging(tmp_path: Path) -> None:
) )
# Staged file is not matched; only the real one is returned. # Staged file is not matched; only the real one is returned.
assert result == str(root / "mycheckpoint.safetensors") assert result == str(root / "mycheckpoint.safetensors").replace(os.sep, "/")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2298,3 +2298,89 @@ async def test_symlink5_cross_volume_merge_then_one_undo_restores_everything(
assert not (root_b / PENDING_DELETE_DIR_NAME / bid_b).exists() assert not (root_b / PENDING_DELETE_DIR_NAME / bid_b).exists()
staging = root_a / PENDING_DELETE_DIR_NAME staging = root_a / PENDING_DELETE_DIR_NAME
assert not staging.exists() or not any(staging.iterdir()) assert not staging.exists() or not any(staging.iterdir())
# ---------------------------------------------------------------------------
# (n) the staging CONTAINER is dropped with its last batch (issue #1145)
# ---------------------------------------------------------------------------
async def test_n_purge_removes_the_empty_staging_container(
tmp_path: Path, monkeypatch
) -> None:
"""`.lm-pending-delete` is a container, not a record of a pending delete.
Leaving it behind made every later folder delete (and rename) answer "A
deletion is still pending inside this folder" forever, because the folder
manifest only matched the directory name.
"""
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "expired")
manifest_path = root / PENDING_DELETE_DIR_NAME / batch_id / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
await service.purge_expired()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
async def test_n_undo_removes_the_empty_staging_container(
tmp_path: Path, monkeypatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "restored")
await service.undo(batch_id)
assert (root / "restored.safetensors").exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
async def test_n_container_survives_while_another_batch_is_staged(
tmp_path: Path, monkeypatch
) -> None:
"""Only the last batch out drops the container."""
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
expired_id = await _stage_simple(service, root, "expired")
fresh_id = await _stage_simple(service, root, "fresh")
manifest_path = root / PENDING_DELETE_DIR_NAME / expired_id / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
await service.purge_expired()
staging = root / PENDING_DELETE_DIR_NAME
assert staging.is_dir()
assert not (staging / expired_id).exists()
assert (staging / fresh_id).exists()
async def test_n_empty_manifestless_batch_is_dropped_not_quarantined(
tmp_path: Path, monkeypatch
) -> None:
"""A crash between makedirs and the manifest leaves nothing to preserve."""
root = tmp_path / "loras"
root.mkdir()
staging = root / PENDING_DELETE_DIR_NAME
(staging / "batch1").mkdir(parents=True)
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
await service.purge_expired(scan_roots=True)
assert not staging.exists()
@@ -451,3 +451,100 @@ def test_get_models_missing_autov3_self_terminates_after_marking(tmp_path: Path,
store.update_single_model('dummy', new_item, old_item=old_item) store.update_single_model('dummy', new_item, old_item=old_item)
assert store.get_models_missing_autov3('dummy') == [] assert store.get_models_missing_autov3('dummy') == []
# --- which roots still hold cached entries (issue #1146) --------------------
def _cache_with_paths(tmp_path: Path, monkeypatch, entries) -> PersistentModelCache:
"""Build a cache holding ``entries`` (``(model_type, file_path)`` pairs)."""
import sqlite3
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
db_path = tmp_path / 'roots.sqlite'
store = PersistentModelCache(db_path=str(db_path))
with sqlite3.connect(str(db_path)) as conn:
conn.executemany(
"INSERT INTO models (model_type, file_path) VALUES (?, ?)", entries
)
conn.commit()
return store
def test_roots_with_cached_entries_matches_the_hosts_slash_style(
tmp_path: Path, monkeypatch
) -> None:
"""The configured root may be spelled with backslashes (Windows)."""
store = _cache_with_paths(
tmp_path,
monkeypatch,
[("lora", "E:/StableDiffusion/cc_models/loras/pack/a.safetensors")],
)
matched = store.roots_with_cached_entries(
"lora", ["E:\\StableDiffusion\\cc_models\\loras", "E:\\empty\\loras"]
)
assert matched == {"E:\\StableDiffusion\\cc_models\\loras"}
def test_roots_with_cached_entries_compares_whole_path_segments(
tmp_path: Path, monkeypatch
) -> None:
"""``loras-extra`` and ``loras2`` are not inside ``loras``."""
store = _cache_with_paths(
tmp_path,
monkeypatch,
[
("lora", "E:/loras-extra/a.safetensors"),
("lora", "E:/loras2/b.safetensors"),
("checkpoint", "E:/loras/c.safetensors"),
],
)
assert store.roots_with_cached_entries("lora", ["E:/loras"]) == set()
assert store.roots_with_cached_entries("checkpoint", ["E:/loras"]) == {"E:/loras"}
def test_roots_with_cached_entries_escapes_like_wildcards(
tmp_path: Path, monkeypatch
) -> None:
"""A directory name may contain ``_``/``%`` without becoming a wildcard."""
store = _cache_with_paths(
tmp_path,
monkeypatch,
[("lora", "E:/myXloras/a.safetensors"), ("lora", "E:/100%/a.safetensors")],
)
assert store.roots_with_cached_entries("lora", ["E:/my_loras"]) == set()
assert store.roots_with_cached_entries("lora", ["E:/100x"]) == set()
assert store.roots_with_cached_entries("lora", ["E:/100%"]) == {"E:/100%"}
def test_roots_with_cached_entries_raises_when_unknown(
tmp_path: Path, monkeypatch
) -> None:
"""No cache, no answer: the caller must not drop roots on a guess."""
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '1')
disabled = PersistentModelCache(db_path=str(tmp_path / 'disabled.sqlite'))
with pytest.raises(RuntimeError):
disabled.roots_with_cached_entries("lora", ["E:/loras"])
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
missing = PersistentModelCache(db_path=str(tmp_path / 'missing.sqlite'))
(tmp_path / 'missing.sqlite').unlink()
with pytest.raises(FileNotFoundError):
missing.roots_with_cached_entries("lora", ["E:/loras"])
def test_roots_with_cached_entries_without_candidates(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
store = PersistentModelCache(db_path=str(tmp_path / 'empty.sqlite'))
assert store.roots_with_cached_entries("lora", []) == set()
assert store.roots_with_cached_entries("lora", ["", " "]) == set()
+35 -4
View File
@@ -62,7 +62,7 @@ async def test_pacing_enforces_min_interval(monkeypatch):
await coordinator.wait_for_slot("example.com") await coordinator.wait_for_slot("example.com")
await coordinator.wait_for_slot("example.com") await coordinator.wait_for_slot("example.com")
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
assert elapsed >= 0.1 assert elapsed >= 0.099 # 1ms of clock slack on a 0.1s interval
async def test_pacing_is_per_destination(monkeypatch): async def test_pacing_is_per_destination(monkeypatch):
@@ -84,12 +84,15 @@ async def test_register_rate_limit_arms_cooldown_and_waits(monkeypatch):
) )
coordinator.register_rate_limit("example.com", retry_after=0.15) coordinator.register_rate_limit("example.com", retry_after=0.15)
assert coordinator.in_cooldown("example.com") assert coordinator.in_cooldown("example.com")
assert 0.1 < coordinator.remaining_seconds("example.com") <= 0.15 remaining = coordinator.remaining_seconds("example.com")
# The upper bound only says "barely any time has passed": allow the timer's
# float noise (Windows reports 0.15000000000000568 for a 0.15s window).
assert 0.1 < remaining <= 0.15 + 1e-6
start = time.monotonic() start = time.monotonic()
await coordinator.wait_for_slot("example.com") await coordinator.wait_for_slot("example.com")
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
assert elapsed >= 0.14 assert elapsed >= 0.14 # 0.15s cooldown, 10ms of clock slack
assert not coordinator.in_cooldown("example.com") assert not coordinator.in_cooldown("example.com")
@@ -255,7 +258,7 @@ async def test_make_request_paces_consecutive_calls(monkeypatch):
await downloader.make_request("GET", "https://api.example.com/a") await downloader.make_request("GET", "https://api.example.com/a")
await downloader.make_request("GET", "https://api.example.com/b") await downloader.make_request("GET", "https://api.example.com/b")
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
assert elapsed >= 0.14 assert elapsed >= 0.14 # 0.15s cooldown, 10ms of clock slack
async def test_make_request_gate_disabled_returns_429_immediately(monkeypatch): async def test_make_request_gate_disabled_returns_429_immediately(monkeypatch):
@@ -461,3 +464,31 @@ async def test_get_response_headers_429_registers_cooldown(monkeypatch):
coordinator = await RateLimitCoordinator.get_instance() coordinator = await RateLimitCoordinator.get_instance()
remaining = coordinator.remaining_seconds("api.example.com") remaining = coordinator.remaining_seconds("api.example.com")
assert 50.0 < remaining <= 60.0 assert 50.0 < remaining <= 60.0
async def test_wait_for_slot_re_sleeps_when_the_timer_wakes_early(monkeypatch):
"""An early wake must not let a request through inside the cooldown.
The event-loop timer can fire before the requested delay has elapsed
(Windows granularity is ~15ms), and a single ``asyncio.sleep`` would then
return while the cooldown is still armed.
"""
coordinator = await _make_coordinator(monkeypatch)
coordinator.register_rate_limit("example.com", retry_after=0.15)
delays = []
real_sleep = asyncio.sleep
async def early_wake(delay):
delays.append(delay)
if len(delays) == 1:
return # the timer fired early
await real_sleep(delay)
monkeypatch.setattr(asyncio, "sleep", early_wake)
await coordinator.wait_for_slot("example.com")
assert len(delays) >= 2, "the gate returned on an early wake"
assert not coordinator.in_cooldown("example.com")
+7 -3
View File
@@ -36,6 +36,7 @@ from py.utils.example_images_processor import (
) )
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from tests.conftest import MockCache, MockModelService, MockScanner from tests.conftest import MockCache, MockModelService, MockScanner
import os
class StubLockProvider: class StubLockProvider:
@@ -655,7 +656,8 @@ async def test_filename_template_use_case_reverts_to_recorded_original_when_temp
lifecycle = StubLifecycleService() lifecycle = StubLifecycleService()
async def metadata_loader(metadata_path: str) -> Dict[str, Any]: async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
if metadata_path == "/library/alpha-renamed.metadata.json": # Compare the basename: the sidecar path is platform-spelled.
if os.path.basename(metadata_path) == "alpha-renamed.metadata.json":
return {"original_file_name": "alpha-original"} return {"original_file_name": "alpha-original"}
return {} return {}
@@ -713,8 +715,10 @@ async def test_filename_template_use_case_skips_revert_matching_current_name() -
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None: async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
_set_filename_template("{model_name}") _set_filename_template("{model_name}")
scanner = MockScanner(cache=MockCache([ scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"), # The names differ beyond case: on Windows a case-only difference counts
_filename_template_model("/library/beta.safetensors", "Beta"), # as "already named that" (os.path.normcase) and would be skipped.
_filename_template_model("/library/alpha.safetensors", "Alpha Two"),
_filename_template_model("/library/beta.safetensors", "Beta Two"),
])) ]))
lifecycle = StubLifecycleService() lifecycle = StubLifecycleService()
lifecycle.error = ValueError("A file with this name already exists") lifecycle.error = ValueError("A file with this name already exists")
+30
View File
@@ -724,3 +724,33 @@ class TestHasWorkflowColumn:
assert loaded is not None assert loaded is not None
assert loaded.raw_data[0]["id"] == "legacy-1" assert loaded.raw_data[0]["id"] == "legacy-1"
assert loaded.raw_data[0]["has_workflow"] is False assert loaded.raw_data[0]["has_workflow"] is False
def test_schema_initialization_closes_its_connection(tmp_path, monkeypatch):
"""The schema init must not leak a file handle.
``with sqlite3.Connection`` only ends the transaction: the handle stays open,
and on Windows a live handle keeps the cache database undeletable
(``WinError 32``) until the garbage collector happens to run -- which broke
every teardown that removes a temporary cache file.
"""
import sqlite3
from py.services import persistent_recipe_cache as module
opened = []
real_connect = sqlite3.connect
def tracking_connect(*args, **kwargs):
conn = real_connect(*args, **kwargs)
opened.append(conn)
return conn
monkeypatch.setattr(sqlite3, "connect", tracking_connect)
module.PersistentRecipeCache(db_path=str(tmp_path / "recipe.sqlite"))
assert opened, "initializing the schema should have opened a connection"
for conn in opened:
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
+3 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import pytest import pytest
import os
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata from py.utils.models import LoraMetadata
@@ -59,7 +60,7 @@ async def test_load_metadata_payload_restores_file_facts_when_sidecar_missing(
payload = await MetadataManager.load_metadata_payload(str(model_path)) payload = await MetadataManager.load_metadata_payload(str(model_path))
stat_result = model_path.stat() stat_result = model_path.stat()
assert payload["file_path"] == str(model_path) assert payload["file_path"] == str(model_path).replace(os.sep, "/")
assert payload["file_name"] == "MyModel" assert payload["file_name"] == "MyModel"
assert payload["size"] == stat_result.st_size assert payload["size"] == stat_result.st_size
assert payload["modified"] == stat_result.st_mtime assert payload["modified"] == stat_result.st_mtime
@@ -174,7 +175,7 @@ async def test_load_metadata_payload_tolerates_missing_model_file(tmp_path) -> N
"""A nonexistent model file must not crash payload loading.""" """A nonexistent model file must not crash payload loading."""
missing_path = tmp_path / "Ghost.safetensors" missing_path = tmp_path / "Ghost.safetensors"
payload = await MetadataManager.load_metadata_payload(str(missing_path)) payload = await MetadataManager.load_metadata_payload(str(missing_path))
assert payload["file_path"] == str(missing_path) assert payload["file_path"] == str(missing_path).replace(os.sep, "/")
assert "file_name" not in payload assert "file_name" not in payload
assert "size" not in payload assert "size" not in payload
assert "modified" not in payload assert "modified" not in payload
+69
View File
@@ -0,0 +1,69 @@
import os
import pytest
from py.utils.root_paths import normalize_root_identity
def test_folds_windows_and_posix_separators():
"""ComfyUI's normpath output and LoRA Manager's forward slashes agree."""
assert normalize_root_identity("E:\\StableDiffusion\\cc_models\\loras") == (
normalize_root_identity("E:/StableDiffusion/cc_models/loras")
)
def test_folds_drive_letter_case_on_every_platform():
"""A drive letter is case-insensitive even where the filesystem is not."""
assert normalize_root_identity("E:/models/loras") == normalize_root_identity(
"e:/models/loras"
)
assert normalize_root_identity("E:\\models\\loras") == normalize_root_identity(
"e:/MODELS/loras"
)
def test_folds_trailing_and_redundant_separators():
assert normalize_root_identity("E:/models/loras/") == normalize_root_identity(
"E:/models/loras"
)
assert normalize_root_identity("E:/models/other/../loras") == (
normalize_root_identity("E:/models/loras")
)
def test_keeps_posix_case_significant():
"""Only Windows compares whole paths case-insensitively."""
if os.name == "nt":
assert normalize_root_identity("C:/Models/Loras") == (
normalize_root_identity("c:/models/loras")
)
else:
assert normalize_root_identity("/data/Loras") != normalize_root_identity(
"/data/loras"
)
def test_folds_whole_path_case_on_windows(monkeypatch):
"""The Windows branch is exercised on every platform."""
monkeypatch.setattr(os, "name", "nt")
assert normalize_root_identity("C:/Models/Loras") == normalize_root_identity(
"c:/models/loras"
)
assert normalize_root_identity("C:\\Models\\Loras\\") == "c:/models/loras"
def test_never_resolves_symlinks_or_abspath():
"""Business paths stay business paths: only the spelling is folded."""
assert normalize_root_identity("./models/loras") == "models/loras"
assert normalize_root_identity("/srv/link/loras") == "/srv/link/loras"
@pytest.mark.parametrize("value", ["", " ", None, 42])
def test_blank_inputs_have_no_identity(value):
assert normalize_root_identity(value) == ""